@uniflowed/test 0.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ // @flow
2
+ //
3
+ // Collecting `describe` / `it` into a tree, and running it.
4
+ //
5
+ // A test file registers by being imported: `describe` runs its body
6
+ // immediately to collect children, `it` records a case. Nothing executes until
7
+ // the runner walks the tree afterwards, which is what makes `.only` decidable
8
+ // — a file's `.only` can appear after the tests it excludes.
9
+ //
10
+ // The rules, all of them the ones a person already expects:
11
+ //
12
+ // * `beforeEach` runs outermost-first and `afterEach` innermost-first, so a
13
+ // suite's set-up wraps its children's.
14
+ // * `beforeAll` runs once before the first test in its suite that actually
15
+ // runs, and `afterAll` after the last one — a suite whose tests are all
16
+ // skipped never runs either, because there is nothing to set up for.
17
+ // * An `afterEach` runs even when the test failed, and its own failure is
18
+ // reported rather than swallowed.
19
+ // * `.only` anywhere in the file restricts the file to marked cases and their
20
+ // ancestors; everything else is reported skipped, never silently dropped.
21
+
22
+ import { firstUserSite } from "./frames.js";
23
+
24
+ /** The placeholders `it.each` substitutes a row into. */
25
+ const ROW_TOKEN = /%[sjdi]/g;
26
+
27
+ /** What a test or hook body may return. */
28
+ export type Body = () => mixed | Promise<mixed>;
29
+
30
+ /** The suffix written on a registration call. */
31
+ export type Modifier = "none" | "only" | "skip" | "todo";
32
+
33
+ /** One registered test case. */
34
+ export type Case = {|
35
+ +kind: "test",
36
+ +name: string,
37
+ +body: Body | null,
38
+ +modifier: Modifier,
39
+ +timeoutMs: number | null,
40
+ +line: number,
41
+ +column: number,
42
+ |};
43
+
44
+ /** One `describe` and everything inside it. */
45
+ export type Suite = {|
46
+ +kind: "suite",
47
+ +name: string,
48
+ +modifier: Modifier,
49
+ +children: Array<Suite | Case>,
50
+ +beforeAll: Array<Body>,
51
+ +afterAll: Array<Body>,
52
+ +beforeEach: Array<Body>,
53
+ +afterEach: Array<Body>,
54
+ +line: number,
55
+ +column: number,
56
+ |};
57
+
58
+ function suite(name: string, modifier: Modifier, line: number, column: number): Suite {
59
+ return {
60
+ kind: "suite",
61
+ name,
62
+ modifier,
63
+ children: [],
64
+ beforeAll: [],
65
+ afterAll: [],
66
+ beforeEach: [],
67
+ afterEach: [],
68
+ line,
69
+ column,
70
+ };
71
+ }
72
+
73
+ /** The root suite of the file currently being collected. */
74
+ let root: Suite = suite("", "none", 0, 0);
75
+
76
+ /** The suite `describe`/`it` calls attach to right now. */
77
+ let current: Suite = root;
78
+
79
+ /**
80
+ * Start collecting a new file, discarding anything from the last one.
81
+ *
82
+ * The worker calls this before each import, so one file's registrations can
83
+ * never leak into another's — which is the bug every "runner reuses a process"
84
+ * design has to avoid.
85
+ */
86
+ export function reset(): void {
87
+ root = suite("", "none", 0, 0);
88
+ current = root;
89
+ }
90
+
91
+ /** The tree collected since the last [`reset`]. */
92
+ export function collected(): Suite {
93
+ return root;
94
+ }
95
+
96
+ /**
97
+ * Where in the test file the call being registered was written.
98
+ *
99
+ * The stack is the only place this is available, and it is worth having: a
100
+ * failure that names a line is a line a person can jump to. When the stack is
101
+ * not in a shape we understand, the position is `0`, which every consumer
102
+ * treats as "unknown" rather than as line one.
103
+ */
104
+ function callSite(): {| +line: number, +column: number |} {
105
+ return firstUserSite(new Error("position").stack) ?? { line: 0, column: 0 };
106
+ }
107
+
108
+ function addSuite(name: string, body: Body, modifier: Modifier): void {
109
+ const position = callSite();
110
+ const child = suite(name, modifier, position.line, position.column);
111
+ current.children.push(child);
112
+ const parent = current;
113
+ current = child;
114
+ try {
115
+ body();
116
+ } finally {
117
+ current = parent;
118
+ }
119
+ }
120
+
121
+ function addCase(name: string, body: Body | null, modifier: Modifier, timeoutMs: number | null): void {
122
+ const position = callSite();
123
+ current.children.push({
124
+ kind: "test",
125
+ name,
126
+ body,
127
+ modifier,
128
+ timeoutMs,
129
+ line: position.line,
130
+ column: position.column,
131
+ });
132
+ }
133
+
134
+ /** Options a single test may carry. */
135
+ export type TestOptions = {| +timeout?: number |};
136
+
137
+ /**
138
+ * The `describe` API, and its modifiers.
139
+ *
140
+ * The modifiers are properties on a callable, which is the shape every runner
141
+ * has used since Jasmine and the one a person types without thinking. They are
142
+ * attached inside this builder rather than assigned at the module's top level,
143
+ * so importing this module still only *declares* things.
144
+ */
145
+ function suiteApi(): $FlowFixMe {
146
+ const api: $FlowFixMe = (name: string, body: Body) => {
147
+ addSuite(name, body, "none");
148
+ };
149
+ api.only = (name: string, body: Body) => {
150
+ addSuite(name, body, "only");
151
+ };
152
+ api.skip = (name: string, body: Body) => {
153
+ addSuite(name, body, "skip");
154
+ };
155
+ api.todo = (name: string, body?: Body) => {
156
+ addSuite(name, body ?? (() => {}), "todo");
157
+ };
158
+ api.each =
159
+ (table: $ReadOnlyArray<mixed>) =>
160
+ (name: string, body: (row: mixed) => mixed) => {
161
+ for (const row of table) {
162
+ addSuite(formatRow(name, row), () => body(row), "none");
163
+ }
164
+ };
165
+ return api;
166
+ }
167
+
168
+ /** The `it` API, and its modifiers. See [`suiteApi`] for the shape. */
169
+ function caseApi(): $FlowFixMe {
170
+ const api: $FlowFixMe = (name: string, body: Body, options?: TestOptions) => {
171
+ addCase(name, body, "none", options?.timeout ?? null);
172
+ };
173
+ api.only = (name: string, body: Body, options?: TestOptions) => {
174
+ addCase(name, body, "only", options?.timeout ?? null);
175
+ };
176
+ api.skip = (name: string, body?: Body) => {
177
+ addCase(name, body ?? null, "skip", null);
178
+ };
179
+ api.todo = (name: string, body?: Body) => {
180
+ addCase(name, body ?? null, "todo", null);
181
+ };
182
+ api.each =
183
+ (table: $ReadOnlyArray<mixed>) =>
184
+ (name: string, body: (row: mixed) => mixed, options?: TestOptions) => {
185
+ for (const row of table) {
186
+ addCase(formatRow(name, row), () => body(row), "none", options?.timeout ?? null);
187
+ }
188
+ };
189
+ return api;
190
+ }
191
+
192
+ /**
193
+ * Group tests, and scope hooks to them.
194
+ *
195
+ * `describe.only`, `describe.skip` and `describe.todo` apply the modifier to
196
+ * everything inside; `describe.each(table)` declares one suite per row.
197
+ */
198
+ export const describe: $FlowFixMe = suiteApi();
199
+
200
+ /**
201
+ * Register one test.
202
+ *
203
+ * `it.only`, `it.skip` and `it.todo` do what they say; `it.each(table)` runs
204
+ * the body once per row, with `%s` and `%j` in the name replaced by the row.
205
+ */
206
+ export const it: $FlowFixMe = caseApi();
207
+
208
+ /** `test` is `it`, for people who write it that way. */
209
+ export const test: $FlowFixMe = it;
210
+
211
+ /**
212
+ * Substitute a row into a name, the way every runner spells it: `%s` for the
213
+ * value, `%j` for its JSON.
214
+ */
215
+ function formatRow(name: string, row: mixed): string {
216
+ const values = Array.isArray(row) ? row : [row];
217
+ let index = 0;
218
+ return name.replace(ROW_TOKEN, (token) => {
219
+ const value = values[index];
220
+ index += 1;
221
+ return token === "%j" ? (JSON.stringify(value) ?? "undefined") : String(value);
222
+ });
223
+ }
224
+
225
+ /** Run once before the first test in this suite that runs. */
226
+ export function beforeAll(body: Body): void {
227
+ current.beforeAll.push(body);
228
+ }
229
+
230
+ /** Run once after the last test in this suite that ran. */
231
+ export function afterAll(body: Body): void {
232
+ current.afterAll.push(body);
233
+ }
234
+
235
+ /** Run before every test in this suite and its children. */
236
+ export function beforeEach(body: Body): void {
237
+ current.beforeEach.push(body);
238
+ }
239
+
240
+ /** Run after every test in this suite and its children, including failures. */
241
+ export function afterEach(body: Body): void {
242
+ current.afterEach.push(body);
243
+ }
@@ -0,0 +1,314 @@
1
+ // @flow
2
+ //
3
+ // Walking a collected suite tree and executing it.
4
+ //
5
+ // This is the half of the runner that knows what a test *is*; the half that
6
+ // knows about processes, scheduling and terminals is `uf_test` in Rust. The
7
+ // split is deliberate: everything here is pure with respect to the host — it
8
+ // takes a tree and an `emit` callback and returns nothing — so it can be
9
+ // driven by the worker, by a unit test, or by a future host that is not
10
+ // Node.js, without any of them re-deciding what `.only` means.
11
+
12
+ import { AssertionError } from "./expect.js";
13
+ import { firstUserSite, userFrames } from "./frames.js";
14
+ import { type Body, type Case, type Suite, collected } from "./registry.js";
15
+
16
+ /** How one case ended. */
17
+ export type Outcome =
18
+ | {| +status: "passed" |}
19
+ | {|
20
+ +status: "failed",
21
+ +message: string,
22
+ +stack: string | null,
23
+ +expected: string | null,
24
+ +received: string | null,
25
+ /** Where the failing assertion was written, when the stack says. */
26
+ +site: {| +line: number, +column: number |} | null,
27
+ |}
28
+ | {| +status: "skipped", +reason: "explicit" | "not-only" | "filtered" |}
29
+ | {| +status: "todo" |};
30
+
31
+ /** One finished case, as the runner reports it. */
32
+ export type Result = {|
33
+ +name: string,
34
+ +line: number,
35
+ +column: number,
36
+ +durationMicros: number,
37
+ +outcome: Outcome,
38
+ |};
39
+
40
+ /** How a run is configured. */
41
+ export type RunOptions = {|
42
+ /** Keep only cases whose full name contains this, reporting the rest skipped. */
43
+ +filter?: string | null,
44
+ /** Wall-clock budget for one case, in milliseconds. */
45
+ +timeoutMs?: number,
46
+ |};
47
+
48
+ /** Default budget for one case, matching what most runners use. */
49
+ export const DEFAULT_TIMEOUT_MS: number = 5000;
50
+
51
+ /** The separator between a suite's name and its child's. */
52
+ export const NAME_SEPARATOR: string = " > ";
53
+
54
+ function fullName(path: $ReadOnlyArray<string>): string {
55
+ return path.filter((part) => part !== "").join(NAME_SEPARATOR);
56
+ }
57
+
58
+ /**
59
+ * Whether the tree contains a case marked `.only`, directly or through a
60
+ * suite marked `.only`.
61
+ *
62
+ * `.only` is per file, and this is the question that makes it so.
63
+ */
64
+ function hasOnly(node: Suite | Case, inherited: boolean): boolean {
65
+ const marked = inherited || node.modifier === "only";
66
+ if (node.kind === "test") {
67
+ return marked;
68
+ }
69
+ return node.children.some((child) => hasOnly(child, marked));
70
+ }
71
+
72
+ /**
73
+ * Run `body` with a wall-clock budget.
74
+ *
75
+ * A body that never settles must not hang the whole run, and there is no way
76
+ * to interrupt JavaScript, so the budget is a race: the run continues and the
77
+ * case is reported as timed out. The abandoned work may still be running,
78
+ * which is why the worker is torn down between files.
79
+ */
80
+ async function withTimeout(body: Body, timeoutMs: number): Promise<void> {
81
+ let timer: TimeoutID | null = null;
82
+ const timeout = new Promise<empty>((_resolve, reject) => {
83
+ timer = setTimeout(() => {
84
+ reject(new Error(`timed out after ${timeoutMs}ms`));
85
+ }, timeoutMs);
86
+ });
87
+ try {
88
+ await Promise.race([Promise.resolve().then(body), timeout]);
89
+ } finally {
90
+ if (timer != null) {
91
+ clearTimeout(timer);
92
+ }
93
+ }
94
+ }
95
+
96
+ function failure(thrown: mixed): Outcome {
97
+ if (thrown instanceof AssertionError) {
98
+ const stack = userFrames(thrown.stack);
99
+ return {
100
+ status: "failed",
101
+ message: thrown.message,
102
+ stack,
103
+ expected: thrown.expected,
104
+ received: thrown.received,
105
+ site: firstUserSite(stack, false),
106
+ };
107
+ }
108
+ if (thrown instanceof Error) {
109
+ const stack = userFrames(thrown.stack);
110
+ return {
111
+ status: "failed",
112
+ message: `${thrown.name}: ${thrown.message}`,
113
+ stack,
114
+ expected: null,
115
+ received: null,
116
+ site: firstUserSite(stack, false),
117
+ };
118
+ }
119
+ return {
120
+ status: "failed",
121
+ message: `the test threw ${String(thrown)}`,
122
+ stack: null,
123
+ expected: null,
124
+ received: null,
125
+ site: null,
126
+ };
127
+ }
128
+
129
+ /** Everything one case needs from the suites above it. */
130
+ type Context = {|
131
+ +path: $ReadOnlyArray<string>,
132
+ +beforeEach: $ReadOnlyArray<Body>,
133
+ +afterEach: $ReadOnlyArray<Body>,
134
+ +skipped: boolean,
135
+ +onlyPath: boolean,
136
+ |};
137
+
138
+ /**
139
+ * Execute one case, hooks included.
140
+ *
141
+ * `afterEach` runs even when the body failed, and a hook's own failure is
142
+ * reported rather than replacing the body's — the first failure wins, because
143
+ * it is the one that explains the rest.
144
+ */
145
+ async function runCase(
146
+ test: Case,
147
+ context: Context,
148
+ options: RunOptions,
149
+ emit: (result: Result) => void,
150
+ ): Promise<boolean> {
151
+ const name = fullName([...context.path, test.name]);
152
+ const started = performance.now();
153
+ const report = (outcome: Outcome) => {
154
+ emit({
155
+ name,
156
+ line: test.line,
157
+ column: test.column,
158
+ durationMicros: Math.round((performance.now() - started) * 1000),
159
+ outcome,
160
+ });
161
+ };
162
+
163
+ if (test.modifier === "todo" || test.body == null) {
164
+ report({ status: "todo" });
165
+ return true;
166
+ }
167
+ if (context.skipped || test.modifier === "skip") {
168
+ report({ status: "skipped", reason: "explicit" });
169
+ return true;
170
+ }
171
+ if (!context.onlyPath) {
172
+ report({ status: "skipped", reason: "not-only" });
173
+ return true;
174
+ }
175
+ const filter = options.filter;
176
+ if (filter != null && filter !== "" && !name.includes(filter)) {
177
+ report({ status: "skipped", reason: "filtered" });
178
+ return true;
179
+ }
180
+
181
+ const timeoutMs = test.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
182
+ let outcome: Outcome = { status: "passed" };
183
+ try {
184
+ for (const hook of context.beforeEach) {
185
+ await withTimeout(hook, timeoutMs);
186
+ }
187
+ await withTimeout(test.body, timeoutMs);
188
+ } catch (thrown) {
189
+ outcome = failure(thrown);
190
+ }
191
+ // Teardown runs whatever happened above, and only reports its own failure
192
+ // when the body had not already failed.
193
+ for (const hook of context.afterEach) {
194
+ try {
195
+ await withTimeout(hook, timeoutMs);
196
+ } catch (thrown) {
197
+ if (outcome.status === "passed") {
198
+ outcome = failure(thrown);
199
+ }
200
+ }
201
+ }
202
+ report(outcome);
203
+ return outcome.status !== "failed";
204
+ }
205
+
206
+ /**
207
+ * Walk one suite, running what it contains.
208
+ *
209
+ * Returns whether everything under it passed, which is what `bail` reads.
210
+ */
211
+ async function runSuite(
212
+ node: Suite,
213
+ context: Context,
214
+ options: RunOptions,
215
+ onlyMode: boolean,
216
+ emit: (result: Result) => void,
217
+ state: {| bail: boolean |},
218
+ ): Promise<boolean> {
219
+ const skipped = context.skipped || node.modifier === "skip" || node.modifier === "todo";
220
+ const onlyPath = !onlyMode || context.onlyPath || node.modifier === "only";
221
+ const path = node.name === "" ? context.path : [...context.path, node.name];
222
+ const inner: Context = {
223
+ path,
224
+ beforeEach: [...context.beforeEach, ...node.beforeEach],
225
+ afterEach: [...node.afterEach, ...context.afterEach],
226
+ skipped,
227
+ onlyPath,
228
+ };
229
+
230
+ // `beforeAll` is deferred until a case in this suite actually runs, so a
231
+ // fully skipped suite never sets anything up. `afterAll` mirrors it.
232
+ let setUp = false;
233
+ const setUpOnce = async () => {
234
+ if (setUp) {
235
+ return;
236
+ }
237
+ setUp = true;
238
+ for (const hook of node.beforeAll) {
239
+ await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
240
+ }
241
+ };
242
+
243
+ let passed = true;
244
+ for (const child of node.children) {
245
+ if (state.bail) {
246
+ break;
247
+ }
248
+ if (child.kind === "test") {
249
+ const willRun =
250
+ !inner.skipped &&
251
+ child.modifier !== "skip" &&
252
+ child.modifier !== "todo" &&
253
+ child.body != null &&
254
+ (!onlyMode || inner.onlyPath || child.modifier === "only");
255
+ if (willRun) {
256
+ try {
257
+ await setUpOnce();
258
+ } catch (thrown) {
259
+ // A failed `beforeAll` fails the cases it was setting up for, named
260
+ // as such: reporting the hook alone would leave the tests silent.
261
+ emit({
262
+ name: fullName([...inner.path, child.name]),
263
+ line: child.line,
264
+ column: child.column,
265
+ durationMicros: 0,
266
+ outcome: failure(thrown),
267
+ });
268
+ passed = false;
269
+ continue;
270
+ }
271
+ }
272
+ const childContext: Context = {
273
+ ...inner,
274
+ onlyPath: !onlyMode || inner.onlyPath || child.modifier === "only",
275
+ };
276
+ const ok = await runCase(child, childContext, options, emit);
277
+ passed = passed && ok;
278
+ } else {
279
+ const ok = await runSuite(child, inner, options, onlyMode, emit, state);
280
+ passed = passed && ok;
281
+ }
282
+ }
283
+
284
+ if (setUp) {
285
+ for (const hook of node.afterAll) {
286
+ try {
287
+ await withTimeout(hook, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
288
+ } catch {
289
+ // A teardown failure cannot fail a test that already reported, and
290
+ // there is nothing left to attach it to; the file's own status carries
291
+ // it instead, which the worker reports.
292
+ passed = false;
293
+ }
294
+ }
295
+ }
296
+ return passed;
297
+ }
298
+
299
+ /**
300
+ * Run the tree collected since the last `reset`, reporting each case through
301
+ * `emit` as it finishes.
302
+ */
303
+ export async function run(options: RunOptions, emit: (result: Result) => void): Promise<void> {
304
+ const root = collected();
305
+ const onlyMode = hasOnly(root, false);
306
+ const context: Context = {
307
+ path: [],
308
+ beforeEach: [],
309
+ afterEach: [],
310
+ skipped: false,
311
+ onlyPath: !onlyMode,
312
+ };
313
+ await runSuite(root, context, options, onlyMode, emit, { bail: false });
314
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@uniflowed/test",
3
+ "version": "0.0.0-alpha.1",
4
+ "description": "The test API and worker for `uf test`: describe/it, a full matcher set, and the process uf fans test files out to.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/test"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./worker": "./worker.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "worker.js",
21
+ "internal"
22
+ ]
23
+ }
package/worker.js ADDED
@@ -0,0 +1,132 @@
1
+ // @flow
2
+ //
3
+ // The process `uf test` fans work out to.
4
+ //
5
+ // One worker per core, each running whole files one at a time: `uf` writes a
6
+ // request per line on stdin, the worker imports that file (through the host's
7
+ // Flow loader, so the module is transformed by the same `uf transform` the
8
+ // build uses), runs what it registered, and writes one event per line back.
9
+ //
10
+ // → {"file": "src/math.test.js", "filter": "adds", "timeoutMs": 5000}
11
+ // ← {"event": "test", "name": "math > adds", "status": "passed", …}
12
+ // ← {"event": "file", "status": "completed", "durationMicros": 1234}
13
+ //
14
+ // Two decisions worth stating. Results are streamed as they happen rather than
15
+ // batched at the end, so `uf test` can draw progress and `--bail` can stop a
16
+ // long run early. And a file that throws while being *imported* is a file
17
+ // result, not a test result: there were no tests to fail, and saying "0 tests"
18
+ // for a module that could not load would be a lie.
19
+ //
20
+ // This module runs on import by design — it is a process entry point, the way
21
+ // `@uniflowed/vite`'s loaders are.
22
+
23
+ import { createInterface } from "node:readline";
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ import { reset } from "./internal/registry.js";
27
+ import { run } from "./internal/run.js";
28
+
29
+ /** What `uf` sends for one file. */
30
+ type Request = {|
31
+ +file: string,
32
+ +filter?: string | null,
33
+ +timeoutMs?: number,
34
+ |};
35
+
36
+ function write(event: { +[string]: mixed }): void {
37
+ process.stdout.write(`${JSON.stringify(event)}\n`);
38
+ }
39
+
40
+ /**
41
+ * Import and run one file.
42
+ *
43
+ * The module is imported with a cache-busting query so a watch-mode rerun in
44
+ * the same worker sees the edited file rather than the one the module registry
45
+ * already holds.
46
+ */
47
+ async function runFile(request: Request, generation: number): Promise<void> {
48
+ const started = performance.now();
49
+ reset();
50
+
51
+ try {
52
+ await import(`${pathToFileURL(request.file).href}?uf-run=${generation}`);
53
+ } catch (thrown) {
54
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
55
+ write({
56
+ event: "file",
57
+ status: "load-failed",
58
+ message: `${error.name}: ${error.message}`,
59
+ stack: error.stack ?? null,
60
+ durationMicros: Math.round((performance.now() - started) * 1000),
61
+ });
62
+ return;
63
+ }
64
+
65
+ try {
66
+ await run({ filter: request.filter ?? null, timeoutMs: request.timeoutMs }, (result) => {
67
+ write({ event: "test", ...result.outcome, name: result.name, line: result.line, column: result.column, durationMicros: result.durationMicros });
68
+ });
69
+ write({
70
+ event: "file",
71
+ status: "completed",
72
+ durationMicros: Math.round((performance.now() - started) * 1000),
73
+ });
74
+ } catch (thrown) {
75
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
76
+ write({
77
+ event: "file",
78
+ status: "run-failed",
79
+ message: `${error.name}: ${error.message}`,
80
+ stack: error.stack ?? null,
81
+ durationMicros: Math.round((performance.now() - started) * 1000),
82
+ });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Serve requests until stdin closes.
88
+ *
89
+ * Requests are queued and served strictly in order: a worker runs one file at
90
+ * a time, because two files sharing a process would share globals and module
91
+ * state, and a test suite that passes alone but fails beside another is the
92
+ * worst failure a runner can produce.
93
+ */
94
+ function serve(): void {
95
+ let queue: Promise<void> = Promise.resolve();
96
+ let generation = 0;
97
+
98
+ createInterface({ input: process.stdin }).on("line", (line) => {
99
+ if (line.trim() === "") {
100
+ return;
101
+ }
102
+ let request: Request;
103
+ try {
104
+ request = JSON.parse(line);
105
+ } catch (error) {
106
+ write({ event: "file", status: "run-failed", message: `malformed request: ${String(error)}` });
107
+ return;
108
+ }
109
+ generation += 1;
110
+ const at = generation;
111
+ queue = queue.then(() => runFile(request, at));
112
+ });
113
+
114
+ process.stdin.on("close", () => {
115
+ queue.then(() => process.exit(0));
116
+ });
117
+ }
118
+
119
+ // Unhandled rejections would otherwise take the worker down mid-file with no
120
+ // explanation; reporting one as a file failure keeps the run honest.
121
+ process.on("unhandledRejection", (reason: mixed) => {
122
+ const error = reason instanceof Error ? reason : new Error(String(reason));
123
+ write({
124
+ event: "file",
125
+ status: "run-failed",
126
+ message: `unhandled rejection: ${error.message}`,
127
+ stack: error.stack ?? null,
128
+ });
129
+ process.exit(1);
130
+ });
131
+
132
+ serve();