Skip to content

Commit 4830953

Browse files
authored
feat: add lifecycle hooks (#55)
* feat: add lifecycle hooks Signed-off-by: Chapman Pendery <cpendery@microsoft.com> * fix: style Signed-off-by: Chapman Pendery <cpendery@microsoft.com> --------- Signed-off-by: Chapman Pendery <cpendery@microsoft.com>
1 parent 3559a03 commit 4830953

7 files changed

Lines changed: 318 additions & 51 deletions

File tree

src/config/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,8 @@ export declare type TestConfig = {
210210

211211
/**
212212
* The number of workers to use. Defaults to 50% of the logical cpu cores. If
213-
* there are less tests than requested workers, there will be 1 worker used per test.
213+
* there are less files than requested workers, there will be 1 worker used per file. 1 worker is
214+
* used per file in order to support lifecycle hooks correctly.
214215
*
215216
* **Usage**
216217
*

src/runner/runner.ts

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -62,48 +62,76 @@ const runSuites = async (
6262
reporter: BaseReporter,
6363
options: ExecutionOptions,
6464
config: Required<TestConfig>,
65-
pool: Pool
65+
maxWorkers: number
6666
) => {
6767
const { updateSnapshot } = options;
6868
const trace = options.trace ?? config.trace;
69-
const tasks: Promise<void>[] = [];
70-
const suites = [...allSuites];
71-
while (suites.length != 0) {
72-
const suite = suites.shift();
73-
if (!suite) break;
74-
tasks.push(
75-
...suite.tests.map(async (test) => {
76-
if (!filteredTestIds.has(test.id)) {
77-
return;
69+
70+
const groupTestsByFile = (suites: Suite[]): TestCase[][] => {
71+
const fileTests = new Map<string, TestCase[]>();
72+
const visit = (suites: Suite[]) => {
73+
for (const suite of suites) {
74+
for (const test of suite.tests) {
75+
if (!filteredTestIds.has(test.id)) continue;
76+
const file = test.filePath() ?? "";
77+
if (!fileTests.has(file)) fileTests.set(file, []);
78+
fileTests.get(file)!.push(test);
79+
}
80+
visit(suite.suites);
81+
}
82+
};
83+
visit(suites);
84+
return Array.from(fileTests.values());
85+
};
86+
87+
const files = groupTestsByFile(allSuites);
88+
const runNextFile = async (): Promise<void> => {
89+
while (files.length != 0) {
90+
const tests = files.shift();
91+
if (!tests) break;
92+
const filePool = createWorkerPool(1);
93+
try {
94+
for (const test of tests) {
95+
for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) {
96+
const testResult = await runTestWorker(
97+
test,
98+
test.sourcePath()!,
99+
{
100+
timeout: getTimeout(),
101+
updateSnapshot,
102+
shellReadyTimeout: getShellReadyTimeout(),
103+
},
104+
trace,
105+
filePool,
106+
reporter,
107+
i,
108+
config.traceFolder
109+
);
110+
test.results.push(testResult);
111+
reporter.endTest(test, testResult);
112+
if (
113+
testResult.status == "skipped" ||
114+
testResult.status == test.expectedStatus
115+
)
116+
break;
117+
}
78118
}
79-
for (let i = 0; i < Math.max(0, getRetries()) + 1; i++) {
80-
const testResult = await runTestWorker(
81-
test,
82-
test.sourcePath()!,
83-
{
84-
timeout: getTimeout(),
85-
updateSnapshot,
86-
shellReadyTimeout: getShellReadyTimeout(),
87-
},
88-
trace,
89-
pool,
90-
reporter,
91-
i,
92-
config.traceFolder
93-
);
94-
test.results.push(testResult);
95-
reporter.endTest(test, testResult);
96-
if (
97-
testResult.status == "skipped" ||
98-
testResult.status == test.expectedStatus
99-
)
100-
break;
119+
await filePool.exec("afterAllWorker", []);
120+
} finally {
121+
try {
122+
await filePool.terminate(true);
123+
} catch {
124+
/* empty */
101125
}
102-
})
103-
);
104-
suites.push(...suite.suites);
105-
}
106-
return Promise.all(tasks);
126+
}
127+
}
128+
};
129+
130+
await Promise.all(
131+
Array.from({ length: Math.min(maxWorkers, files.length) }, () =>
132+
runNextFile()
133+
)
134+
);
107135
};
108136

109137
const checkRuntimeVersion = () => {
@@ -202,7 +230,6 @@ export const run = async (options: ExecutionOptions) => {
202230
const config = await loadConfig();
203231
const rootSuite = await getRootSuite(config);
204232
const reporter = new ListReporter();
205-
const pool = createWorkerPool(config.workers);
206233

207234
const suites = [rootSuite];
208235
while (suites.length != 0) {
@@ -276,13 +303,9 @@ export const run = async (options: ExecutionOptions) => {
276303
reporter,
277304
options,
278305
config,
279-
pool
306+
config.workers
280307
);
281-
try {
282-
await pool.terminate(true);
283-
} catch {
284-
/* empty */
285-
}
308+
286309
const staleSnapshots = await cleanSnapshots(allTests, options);
287310
const failures = reporter.end(rootSuite, {
288311
obsolete: options.updateSnapshot ? 0 : staleSnapshots,

src/runner/worker.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ type WorkerExecutionOptions = {
3131
};
3232

3333
const importSet = new Set<string>();
34+
const activeSuites: Suite[] = [];
35+
const beforeAllExecuted = new Set<Suite>();
3436

3537
const runTest = async (
3638
testId: string,
@@ -42,14 +44,36 @@ const runTest = async (
4244
shellReadyTimeout: number
4345
) => {
4446
process.setSourceMapsEnabled(true);
45-
globalThis.suite = testSuite;
47+
globalThis.suite = Suite.from(testSuite);
4648
globalThis.tests = globalThis.tests ?? {};
4749
globalThis.__expectState = { updateSnapshot };
4850
if (!importSet.has(importPath)) {
4951
await import(importPath);
5052
importSet.add(importPath);
5153
}
5254
const test = globalThis.tests[testId];
55+
56+
const ancestry = test.suite.parentSuites();
57+
const { exit, enter } = Suite.computeTransition(activeSuites, ancestry);
58+
59+
const exitHooks = exit.flatMap((s) => s.afterAllHooks);
60+
for (const hook of exitHooks) {
61+
await Promise.resolve(hook());
62+
}
63+
activeSuites.length -= exit.length;
64+
65+
const enterHooks = enter
66+
.filter((s) => !beforeAllExecuted.has(s))
67+
.flatMap((s) => {
68+
beforeAllExecuted.add(s);
69+
return s.beforeAllHooks;
70+
});
71+
72+
for (const hook of enterHooks) {
73+
await Promise.resolve(hook());
74+
}
75+
activeSuites.push(...enter);
76+
5377
const { shell, rows, columns, env, program } = test.suite.options ?? {};
5478
const traceEmitter = new EventEmitter();
5579
traceEmitter.on("data", (data: string, time: number) =>
@@ -129,13 +153,28 @@ const runTest = async (
129153
]);
130154
}
131155

156+
const suites = test.suite.parentSuites();
132157
try {
158+
for (const s of suites) {
159+
for (const hook of s.beforeEachHooks) {
160+
await Promise.resolve(hook({ terminal }));
161+
}
162+
}
163+
133164
await Promise.resolve(test.testFunction({ terminal }));
134165
} finally {
135166
try {
136-
terminal.kill();
137-
} catch {
138-
// terminal can pre-terminate if program is provided
167+
for (const s of suites) {
168+
for (const hook of s.afterEachHooks) {
169+
await Promise.resolve(hook({ terminal }));
170+
}
171+
}
172+
} finally {
173+
try {
174+
terminal.kill();
175+
} catch {
176+
// terminal can pre-terminate if program is provided
177+
}
139178
}
140179
}
141180
};
@@ -388,6 +427,14 @@ const testWorker = async (
388427
}
389428
};
390429

430+
const afterAllWorker = async (): Promise<void> => {
431+
const hooks = [...activeSuites].reverse().flatMap((s) => s.afterAllHooks);
432+
for (const hook of hooks) {
433+
await Promise.resolve(hook());
434+
}
435+
activeSuites.length = 0;
436+
};
437+
391438
if (!workerpool.isMainThread) {
392439
process.on("uncaughtException", () => {
393440
// prevent worker crashes from unhandled native errors
@@ -397,5 +444,6 @@ if (!workerpool.isMainThread) {
397444
});
398445
workerpool.worker({
399446
testWorker: testWorker,
447+
afterAllWorker: afterAllWorker,
400448
});
401449
}

src/test/suite.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import { glob } from "glob";
55

66
import { TestOptions } from "./option.js";
77
import { TestConfig, ProjectConfig } from "../config/config.js";
8-
import type { TestCase } from "./testcase.js";
8+
import type { TestCase, TestFunction, HookFunction } from "./testcase.js";
99

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

1212
export class Suite {
1313
suites: Suite[] = [];
1414
tests: TestCase[] = [];
1515
source?: string;
16+
beforeAllHooks: HookFunction[] = [];
17+
afterAllHooks: HookFunction[] = [];
18+
beforeEachHooks: TestFunction[] = [];
19+
afterEachHooks: TestFunction[] = [];
1620

1721
constructor(
1822
readonly title: string,
@@ -21,6 +25,32 @@ export class Suite {
2125
public parentSuite?: Suite
2226
) {}
2327

28+
static from(data: Suite): Suite {
29+
const suite = new Suite(data.title, data.type, data.options);
30+
if (data.parentSuite) {
31+
suite.parentSuite = Suite.from(data.parentSuite);
32+
}
33+
return suite;
34+
}
35+
36+
static computeTransition(
37+
activeSuites: Suite[],
38+
targetSuites: Suite[]
39+
): { exit: Suite[]; enter: Suite[] } {
40+
let commonPrefix = 0;
41+
while (
42+
commonPrefix < activeSuites.length &&
43+
commonPrefix < targetSuites.length &&
44+
activeSuites[commonPrefix] === targetSuites[commonPrefix]
45+
) {
46+
commonPrefix++;
47+
}
48+
return {
49+
exit: activeSuites.slice(commonPrefix).reverse(),
50+
enter: targetSuites.slice(commonPrefix),
51+
};
52+
}
53+
2454
allTests(): TestCase[] {
2555
const suitesIterable = [...this.suites];
2656
const tests = [];
@@ -32,6 +62,16 @@ export class Suite {
3262
return tests;
3363
}
3464

65+
parentSuites(): Suite[] {
66+
const suites: Suite[] = [this];
67+
let current = this.parentSuite;
68+
while (current != null) {
69+
suites.push(current);
70+
current = current.parentSuite;
71+
}
72+
return suites.reverse();
73+
}
74+
3575
titlePath(): string[] {
3676
const titles = [];
3777
let currentSuite = this.parentSuite;

0 commit comments

Comments
 (0)