Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ export declare type TestConfig = {

/**
* The number of workers to use. Defaults to 50% of the logical cpu cores. If
* there are less tests than requested workers, there will be 1 worker used per test.
* there are less files than requested workers, there will be 1 worker used per file. 1 worker is
* used per file in order to support lifecycle hooks correctly.
*
* **Usage**
*
Expand Down
111 changes: 67 additions & 44 deletions src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,48 +62,76 @@ const runSuites = async (
reporter: BaseReporter,
options: ExecutionOptions,
config: Required<TestConfig>,
pool: Pool
maxWorkers: number
) => {
const { updateSnapshot } = options;
const trace = options.trace ?? config.trace;
const tasks: Promise<void>[] = [];
const suites = [...allSuites];
while (suites.length != 0) {
const suite = suites.shift();
if (!suite) break;
tasks.push(
...suite.tests.map(async (test) => {
if (!filteredTestIds.has(test.id)) {
return;

const groupTestsByFile = (suites: Suite[]): TestCase[][] => {
const fileTests = new Map<string, TestCase[]>();
const visit = (suites: Suite[]) => {
for (const suite of suites) {
for (const test of suite.tests) {
if (!filteredTestIds.has(test.id)) continue;
const file = test.filePath() ?? "";
if (!fileTests.has(file)) fileTests.set(file, []);
fileTests.get(file)!.push(test);
}
visit(suite.suites);
}
};
visit(suites);
return Array.from(fileTests.values());
};

const files = groupTestsByFile(allSuites);
const runNextFile = async (): Promise<void> => {
while (files.length != 0) {
const tests = files.shift();
if (!tests) break;
const filePool = createWorkerPool(1);
try {
for (const test of tests) {
for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) {
const testResult = await runTestWorker(
test,
test.sourcePath()!,
{
timeout: getTimeout(),
updateSnapshot,
shellReadyTimeout: getShellReadyTimeout(),
},
trace,
filePool,
reporter,
i,
config.traceFolder
);
test.results.push(testResult);
reporter.endTest(test, testResult);
if (
testResult.status == "skipped" ||
testResult.status == test.expectedStatus
)
break;
}
}
for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) {
const testResult = await runTestWorker(
test,
test.sourcePath()!,
{
timeout: getTimeout(),
updateSnapshot,
shellReadyTimeout: getShellReadyTimeout(),
},
trace,
pool,
reporter,
i,
config.traceFolder
);
test.results.push(testResult);
reporter.endTest(test, testResult);
if (
testResult.status == "skipped" ||
testResult.status == test.expectedStatus
)
break;
await filePool.exec("afterAllWorker", []);
} finally {
try {
await filePool.terminate(true);
} catch {
/* empty */
}
})
);
suites.push(...suite.suites);
}
return Promise.all(tasks);
}
}
};

await Promise.all(
Array.from({ length: Math.min(maxWorkers, files.length) }, () =>
runNextFile()
)
);
};

const checkRuntimeVersion = () => {
Expand Down Expand Up @@ -202,7 +230,6 @@ export const run = async (options: ExecutionOptions) => {
const config = await loadConfig();
const rootSuite = await getRootSuite(config);
const reporter = new ListReporter();
const pool = createWorkerPool(config.workers);

const suites = [rootSuite];
while (suites.length != 0) {
Expand Down Expand Up @@ -276,13 +303,9 @@ export const run = async (options: ExecutionOptions) => {
reporter,
options,
config,
pool
config.workers
);
try {
await pool.terminate(true);
} catch {
/* empty */
}

const staleSnapshots = await cleanSnapshots(allTests, options);
const failures = reporter.end(rootSuite, {
obsolete: options.updateSnapshot ? 0 : staleSnapshots,
Expand Down
56 changes: 52 additions & 4 deletions src/runner/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type WorkerExecutionOptions = {
};

const importSet = new Set<string>();
const activeSuites: Suite[] = [];
const beforeAllExecuted = new Set<Suite>();

const runTest = async (
testId: string,
Expand All @@ -42,14 +44,36 @@ const runTest = async (
shellReadyTimeout: number
) => {
process.setSourceMapsEnabled(true);
globalThis.suite = testSuite;
globalThis.suite = Suite.from(testSuite);
globalThis.tests = globalThis.tests ?? {};
globalThis.__expectState = { updateSnapshot };
if (!importSet.has(importPath)) {
await import(importPath);
importSet.add(importPath);
}
const test = globalThis.tests[testId];

const ancestry = test.suite.parentSuites();
const { exit, enter } = Suite.computeTransition(activeSuites, ancestry);

const exitHooks = exit.flatMap((s) => s.afterAllHooks);
for (const hook of exitHooks) {
await Promise.resolve(hook());
}
activeSuites.length -= exit.length;

const enterHooks = enter
.filter((s) => !beforeAllExecuted.has(s))
.flatMap((s) => {
beforeAllExecuted.add(s);
return s.beforeAllHooks;
});

for (const hook of enterHooks) {
await Promise.resolve(hook());
}
activeSuites.push(...enter);

const { shell, rows, columns, env, program } = test.suite.options ?? {};
const traceEmitter = new EventEmitter();
traceEmitter.on("data", (data: string, time: number) =>
Expand Down Expand Up @@ -129,13 +153,28 @@ const runTest = async (
]);
}

const suites = test.suite.parentSuites();
try {
for (const s of suites) {
for (const hook of s.beforeEachHooks) {
await Promise.resolve(hook({ terminal }));
}
}

await Promise.resolve(test.testFunction({ terminal }));
} finally {
try {
terminal.kill();
} catch {
// terminal can pre-terminate if program is provided
for (const s of suites) {
for (const hook of s.afterEachHooks) {
await Promise.resolve(hook({ terminal }));
}
}
} finally {
try {
terminal.kill();
} catch {
// terminal can pre-terminate if program is provided
}
}
}
};
Expand Down Expand Up @@ -388,6 +427,14 @@ const testWorker = async (
}
};

const afterAllWorker = async (): Promise<void> => {
const hooks = [...activeSuites].reverse().flatMap((s) => s.afterAllHooks);
for (const hook of hooks) {
await Promise.resolve(hook());
}
activeSuites.length = 0;
};

if (!workerpool.isMainThread) {
process.on("uncaughtException", () => {
// prevent worker crashes from unhandled native errors
Expand All @@ -397,5 +444,6 @@ if (!workerpool.isMainThread) {
});
workerpool.worker({
testWorker: testWorker,
afterAllWorker: afterAllWorker,
});
}
42 changes: 41 additions & 1 deletion src/test/suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { glob } from "glob";

import { TestOptions } from "./option.js";
import { TestConfig, ProjectConfig } from "../config/config.js";
import type { TestCase } from "./testcase.js";
import type { TestCase, TestFunction, HookFunction } from "./testcase.js";

type SuiteType = "file" | "describe" | "project" | "root";

export class Suite {
suites: Suite[] = [];
tests: TestCase[] = [];
source?: string;
beforeAllHooks: HookFunction[] = [];
afterAllHooks: HookFunction[] = [];
beforeEachHooks: TestFunction[] = [];
afterEachHooks: TestFunction[] = [];

constructor(
readonly title: string,
Expand All @@ -21,6 +25,32 @@ export class Suite {
public parentSuite?: Suite
) {}

static from(data: Suite): Suite {
const suite = new Suite(data.title, data.type, data.options);
if (data.parentSuite) {
suite.parentSuite = Suite.from(data.parentSuite);
}
return suite;
}

static computeTransition(
activeSuites: Suite[],
targetSuites: Suite[]
): { exit: Suite[]; enter: Suite[] } {
let commonPrefix = 0;
while (
commonPrefix < activeSuites.length &&
commonPrefix < targetSuites.length &&
activeSuites[commonPrefix] === targetSuites[commonPrefix]
) {
commonPrefix++;
}
return {
exit: activeSuites.slice(commonPrefix).reverse(),
enter: targetSuites.slice(commonPrefix),
};
}

allTests(): TestCase[] {
const suitesIterable = [...this.suites];
const tests = [];
Expand All @@ -32,6 +62,16 @@ export class Suite {
return tests;
}

parentSuites(): Suite[] {
const suites: Suite[] = [this];
let current = this.parentSuite;
while (current != null) {
suites.push(current);
current = current.parentSuite;
}
return suites.reverse();
}

titlePath(): string[] {
const titles = [];
let currentSuite = this.parentSuite;
Expand Down
Loading
Loading