Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 70 additions & 35 deletions dev-packages/node-integration-tests/utils/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { existsSync } from 'fs';
import { cp, mkdir, readFile, rm, writeFile } from 'fs/promises';
import { basename, join } from 'path';
import { inspect, promisify } from 'util';
import { afterAll, beforeAll, describe, test } from 'vitest';
import { afterAll, beforeAll, test, type TestAPI } from 'vitest';
import type { DeepPartial } from './assertions';
import {
assertEnvelopeHeader,
Expand Down Expand Up @@ -296,47 +296,82 @@ export function createEsmAndCjsTests(
}
}

describe('esm/cjs', () => {
const esmTestFn = options?.failsOnEsm ? test.fails : test;
describe('esm', () => {
callback(
() => createRunner(esmScenarioPathForRun).withFlags('--import', esmInstrumentPathForRun),
esmTestFn,
'esm',
tmpDirPath,
);
// Wrap the test API so it prepends the test name with the mode
// Also wraps the nested fields `only`, `skip`, `each`, and `for`
function wrapTestApi(api: TestAPI | typeof test.fails | typeof test.only | typeof test.skip, suffix: string) {
return new Proxy(api, {
apply: (target, thisArg, args: Parameters<typeof api>) => {
if (typeof args[0] === 'string') {
args[0] = `${args[0]} [${suffix}]`;
}

return Reflect.apply(target, thisArg, args);
},

get: (target, prop: 'only' | 'skip' | 'each' | 'for') => {
if (prop === 'only' || prop === 'skip') {
return wrapTestApi(target[prop], suffix);
}

if (prop === 'each' || prop === 'for') {
return wrapTestEachApi(target[prop], suffix);
}

return Reflect.get(target, prop);
},
});
}

const cjsTestFn = options?.failsOnCjs ? test.fails : test;
describe('cjs', () => {
callback(
() => createRunner(cjsScenarioPath).withFlags('--require', cjsInstrumentPath),
cjsTestFn,
'cjs',
tmpDirPath,
);
// For test.each, we can't just wrap the function itself, but need to wrap the returned function
// As usage is e.g. test.each(...)('test name', () => {})
function wrapTestEachApi<Api extends typeof test.each | typeof test.for>(api: Api, suffix: string) {
return new Proxy(api, {
apply: (target, thisArg, args: Parameters<typeof api>) => {
const res = Reflect.apply(target, thisArg, args);

return new Proxy(res, {
apply: (target, thisArg, args: Parameters<ReturnType<typeof test.each>>) => {
if (typeof args[0] === 'string') {
args[0] = `${args[0]} [${suffix}]`;
}
return Reflect.apply(target, thisArg, args);
},
});
},
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Create tmp directory
beforeAll(async () => {
await createTmpDir();
}, 60_000);
const esmTestFn = options?.failsOnEsm ? wrapTestApi(test.fails, 'esm - fails') : wrapTestApi(test, 'esm');
const cjsTestFn = options?.failsOnCjs ? wrapTestApi(test.fails, 'cjs - fails') : wrapTestApi(test, 'cjs');

// Clean up the tmp directory after both esm and cjs suites have run
afterAll(async () => {
// First do cleanup!
cleanupChildProcesses();
callback(
() => createRunner(esmScenarioPathForRun).withFlags('--import', esmInstrumentPathForRun),
esmTestFn,
'esm',
tmpDirPath,
);

try {
await rm(tmpDirPath, { recursive: true, force: true });
} catch {
if (process.env.DEBUG) {
// eslint-disable-next-line no-console
console.error(`Failed to remove tmp dir: ${tmpDirPath}`);
}
callback(() => createRunner(cjsScenarioPath).withFlags('--require', cjsInstrumentPath), cjsTestFn, 'cjs', tmpDirPath);

// Create tmp directory
beforeAll(async () => {
await createTmpDir();
}, 60_000);

// Clean up the tmp directory after both esm and cjs suites have run
afterAll(async () => {
// First do cleanup!
cleanupChildProcesses();

try {
await rm(tmpDirPath, { recursive: true, force: true });
} catch {
if (process.env.DEBUG) {
// eslint-disable-next-line no-console
console.error(`Failed to remove tmp dir: ${tmpDirPath}`);
}
}, 30_000);
});
}
}, 30_000);
Comment on lines +364 to +374

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Multiple afterAll hooks registered by createEsmAndCjsTests create a race condition, causing one hook to prematurely clear a global CLEANUP_STEPS set, leading to resource leaks.
Severity: MEDIUM

Suggested Fix

Refactor the cleanup mechanism to be robust against concurrent execution. Instead of a single global set that gets cleared, each afterAll hook should be responsible for cleaning up only the resources it created. This could be achieved by having the start() function return the specific cleanup functions required, which are then managed locally within the scope of createEsmAndCjsTests and its afterAll hook, avoiding the shared global state.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: dev-packages/node-integration-tests/utils/runner.ts#L362-L374

Potential issue: When `createEsmAndCjsTests` is invoked multiple times within the same
`describe` block, each call registers an `afterAll` hook. These hooks all reference a
single global `CLEANUP_STEPS` set. Because Vitest may run `afterAll` hooks in parallel,
a race condition is introduced. The first hook to complete will call
`cleanupChildProcesses`, which clears the entire global set. This can happen before
other tests' cleanup functions have been executed, or even added to the set, leading to
leaked child processes, docker containers, and mock servers during test runs.

}

async function convertEsmFileToCjs(inputPath: string, outputPath: string): Promise<void> {
Expand Down
Loading