@avi2dg/checks 0.42.0 → 0.43.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.43.0
6
+
7
+ Released 2026-10-17.
8
+
9
+ ### Features
10
+
11
+ - **testing:** run checks-test files in parallel on opt-in [#158](https://github.com/avi2d/checks/pull/158)
12
+
5
13
  ## 0.42.0
6
14
 
7
15
  Released 2026-10-14.
@@ -20,12 +20,13 @@ It fails unless the repository holds this shape, and names the file and the path
20
20
  Bun ignores both directories in the default suite.
21
21
  A live tier with test files requires `test:live` set to `checks-test --tier=live`.
22
22
  A pixel tier with test files requires `test:pixel` set to `checks-test --tier=pixel`.
23
+ Either tier script may add `--parallel=<workers>`.
23
24
  - A test runs in-process or in a process of its own.
24
25
  A test outside the `e2e`, `live` and `pixel` levels runs in-process, quarantined or not, so it may not import `node:child_process`, `net`, `http`, `https`, `http2`, `tls` or `dgram`.
25
26
  It may not import `$`, `spawn`, `spawnSync`, `connect`, `serve` or `listen` from `bun`, may not touch `Bun.$` or `Bun.spawn`, and may not call `fetch`.
26
27
  A test at the `e2e`, `live` or `pixel` level may do all of it, under `tests/quarantine/` as well.
27
28
  Helpers in `tests/lib/**` answer to the same rule, since an in-process test reaches them.
28
- - `scripts.test` is exactly `checks-test`, which runs `bun test --randomize` as [checks-test](checks-test.md) says.
29
+ - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize` as [checks-test](checks-test.md) says.
29
30
  - `scripts.lint` runs this check, itself or through `checks-lint` called by its bare bin name.
30
31
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the same value.
31
32
  `[test].pathIgnorePatterns` is the preset's `["**/tests/quarantine/**", "**/tests/live/**", "**/tests/pixel/**", "repos/**"]`, which the check pins itself, so the kit's own repository, whose bunfig is the preset, cannot drift it either.
@@ -78,7 +79,7 @@ It checks the directory it runs in, or the directory it is given.
78
79
  ```
79
80
  test-layout: 4 violation(s)
80
81
  src/a.test.ts: a test file must live at tests/<level>/**/*.test.ts, or tests/quarantine/<level>/**/*.test.ts while quarantined, with unit, e2e, live, or pixel as the level; move it to tests/unit/a.test.ts
81
- package.json: scripts.test must be exactly "checks-test", which runs bun test --randomize and judges its skips, found "bun test"
82
+ package.json: scripts.test must be "checks-test", optionally with --parallel=<workers>, which runs bun test --randomize and judges its skips, found "bun test"
82
83
  package.json: scripts.lint must run the layout check: add "checks-lint"
83
84
  bunfig.toml: bunfig.toml is missing; bun has no bunfig extends, so copy node_modules/@avi2dg/checks/bunfig.toml
84
85
  ```
@@ -47,7 +47,7 @@ A skipped test or a todo without `skipReason` fails in every environment.
47
47
  It does not read a skip list from `package.json`.
48
48
  It counts a run as `ci` when `CI` is true and as `local` otherwise.
49
49
 
50
- The command takes no arguments for the default suite.
50
+ The command takes no arguments for the default suite, except the worker count of [Parallel runs](#parallel-runs).
51
51
  Use `checks-test --tier=live` or `checks-test --tier=pixel` for a named test tier.
52
52
  A tier run clears Bun's ignored paths, runs only `./tests/live` or `./tests/pixel`, and still checks each skip.
53
53
 
@@ -72,10 +72,36 @@ Add the matching package scripts when either directory contains tests:
72
72
  Bun ignores both directories during the default run.
73
73
  `checks-test-layout` requires each tier script when its directory contains a test file.
74
74
 
75
+ ## Parallel runs
76
+
77
+ A repository runs its test files in parallel by passing a worker count in its `test` script:
78
+
79
+ ```json
80
+ {
81
+ "scripts": {
82
+ "test": "checks-test --parallel=4"
83
+ }
84
+ }
85
+ ```
86
+
87
+ `checks-test --parallel=<workers>` runs `bun test --randomize --parallel=<workers>`, so Bun hands each test file to one of that many worker processes.
88
+ Each file then runs in a fresh global, so a file cannot lean on state another file left behind.
89
+ The count is a whole number from 1 up, and without the option the suite runs serially in one process.
90
+ Tests still run in random order, and `test.only` still fails the run.
91
+ Bun merges every worker's results into the one JUnit report, so each skipped test and each todo is judged as in a serial run.
92
+ A tier script takes the same option, as in `checks-test --tier=live --parallel=4`.
93
+
94
+ A file whose worker crashes, for example when a test calls `process.exit`, fails the run.
95
+ Bun reports no result from that file, so its skips are not judged and its declarations are not reported as stale.
96
+
97
+ `checks-test` takes no timings file, because Bun ignores the order a timings file sets whenever `--randomize` is on.
98
+ A timings option waits for Bun to honour timings under random order.
99
+
75
100
  ## Arguments
76
101
 
77
- The default command takes no arguments.
78
- Its named tier options are `--tier=live` and `--tier=pixel`.
102
+ The default command takes no arguments except `--parallel=<workers>`, as [Parallel runs](#parallel-runs) says.
103
+ Its named tier options are `--tier=live` and `--tier=pixel`, and a tier run takes `--parallel=<workers>` too.
104
+ Each option appears at most once.
79
105
  It refuses test filters because every test excluded by a filter would look skipped.
80
106
 
81
107
  ## Exit codes
@@ -83,8 +109,8 @@ It refuses test filters because every test excluded by a filter would look skipp
83
109
  | Code | When |
84
110
  | --- | --- |
85
111
  | 0 | every test passed and every skip has a matching site reason |
86
- | 1 | a test failed, a skip lacks a site reason or a declaration is stale in CI |
87
- | 2 | a declaration cannot be read, `CI` is not a boolean or Bun wrote no report |
112
+ | 1 | a test failed, a parallel worker crashed, a skip lacks a site reason or a declaration is stale in CI |
113
+ | 2 | an argument is not one it takes, a declaration cannot be read, `CI` is not a boolean or Bun wrote no report |
88
114
 
89
115
  ## Sample output
90
116
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -75,7 +75,7 @@ const runOnce = Effect.fn("runOnce")(function* (seed: number, outfile: string) {
75
75
  if (passed) return yield* new ReportError({ message: `bun test passed with seed ${seed} but wrote no report` });
76
76
  return { seed, passed, tests: 0, failed: [] } satisfies Run;
77
77
  }
78
- const results = yield* parseReport(yield* fs.readFileString(outfile));
78
+ const { results } = yield* parseReport(yield* fs.readFileString(outfile));
79
79
  return { seed, passed, tests: results.length, failed: results.filter((result) => result.outcome === "failed") } satisfies Run;
80
80
  });
81
81
 
@@ -4,6 +4,7 @@ import { git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, TEST_ENTRY_POINT } from "../core/gates.ts";
6
6
  import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
7
+ import { testOptions, type TestOptions } from "./test.ts";
7
8
 
8
9
  export type Violation = {
9
10
  readonly file: string;
@@ -61,6 +62,7 @@ export const LAYOUT_CHECK_MARK = "src/testing/test-layout.ts";
61
62
  export const LAYOUT_CHECK_BIN = "checks-test-layout";
62
63
  const OWN_ENTRY_POINT = `src/${ENTRY_POINT.script}`;
63
64
  const TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun src/${TEST_ENTRY_POINT.script}`];
65
+ const PARALLEL_OPTION = "optionally with --parallel=<workers>";
64
66
 
65
67
  export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError", {
66
68
  message: Schema.String,
@@ -229,6 +231,12 @@ export const isolationViolations = Effect.fn("isolationViolations")(function* (f
229
231
  return violations;
230
232
  });
231
233
 
234
+ function entryOptions(script: unknown): TestOptions | undefined {
235
+ if (typeof script !== "string") return undefined;
236
+ const entry = TEST_SCRIPTS.find((candidate) => script === candidate || script.startsWith(`${candidate} `));
237
+ return entry === undefined ? undefined : testOptions(script.slice(entry.length).split(" ").filter((arg) => arg !== ""));
238
+ }
239
+
232
240
  function tierScriptViolation(scripts: unknown, files: readonly string[], tier: (typeof TEST_TIERS)[number]): Violation | undefined {
233
241
  const file = "package.json";
234
242
  const key = `test:${tier}`;
@@ -236,11 +244,11 @@ function tierScriptViolation(scripts: unknown, files: readonly string[], tier: (
236
244
  const expected = `${TEST_ENTRY_POINT.bin} --tier=${tier}`;
237
245
  const hasTests = files.some((candidate) => candidate.startsWith(`tests/${tier}/`) && TEST_FILE.test(candidate));
238
246
  if (!hasTests && found === undefined) return undefined;
239
- if (found === expected) return undefined;
247
+ if (entryOptions(found)?.tier === tier) return undefined;
240
248
  return {
241
249
  file,
242
250
  line: undefined,
243
- message: `${key} must be "${expected}"${hasTests ? " when its test tier has files" : " when declared"}, found ${JSON.stringify(found ?? null)}`,
251
+ message: `${key} must be "${expected}", ${PARALLEL_OPTION},${hasTests ? " when its test tier has files" : " when declared"}, found ${JSON.stringify(found ?? null)}`,
244
252
  };
245
253
  }
246
254
 
@@ -250,11 +258,12 @@ export function scriptViolations(manifest: unknown, files: readonly string[] = [
250
258
  const test = isRecord(scripts) ? scripts["test"] : undefined;
251
259
  const lint = isRecord(scripts) ? scripts["lint"] : undefined;
252
260
  const violations: Violation[] = [];
253
- if (typeof test !== "string" || !TEST_SCRIPTS.includes(test)) {
261
+ const options = entryOptions(test);
262
+ if (options === undefined || options.tier !== undefined) {
254
263
  violations.push({
255
264
  file,
256
265
  line: undefined,
257
- message: `scripts.test must be exactly "${TEST_ENTRY_POINT.bin}", which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
266
+ message: `scripts.test must be "${TEST_ENTRY_POINT.bin}", ${PARALLEL_OPTION}, which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
258
267
  });
259
268
  }
260
269
  if (typeof lint !== "string" || !runsLayoutCheck(lint)) {
@@ -9,6 +9,11 @@ export type TestResult = {
9
9
  readonly outcome: Outcome;
10
10
  };
11
11
 
12
+ type Report = {
13
+ readonly results: readonly TestResult[];
14
+ readonly crashedFiles: readonly string[];
15
+ };
16
+
12
17
  export class ReportError extends Schema.TaggedError<ReportError>()("ReportError", {
13
18
  message: Schema.String,
14
19
  }) {}
@@ -36,6 +41,7 @@ const decodeSuite = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.Stri
36
41
  const decodeCase = Schema.decodeUnknownEffect(
37
42
  Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }),
38
43
  );
44
+ const decodeCrash = Schema.decodeUnknownEffect(Schema.Struct({ file: Schema.String }));
39
45
 
40
46
  function unescape(text: string): string {
41
47
  return text.replace(ENTITY, (_, named: string | undefined, decimal: string | undefined, hex: string | undefined) => {
@@ -91,20 +97,28 @@ function outcomeOf(testcase: Element): Outcome {
91
97
  return testcase.children.some((child) => child.tag === "failure" || child.tag === "error") ? "failed" : "passed";
92
98
  }
93
99
 
100
+ // bun's parallel coordinator replaces every result of a file whose worker crashed with one failure that has no line.
101
+ function crashed(testcase: Element): boolean {
102
+ return testcase.attributes["line"] === undefined && outcomeOf(testcase) === "failed";
103
+ }
104
+
94
105
  const collect = Effect.fnUntraced(function* (
95
106
  suite: Element,
96
107
  describes: readonly string[],
97
- results: TestResult[],
108
+ report: { readonly results: TestResult[]; readonly crashedFiles: string[] },
98
109
  ): Effect.fn.Return<void, ReportError> {
99
110
  const unreadable = (tag: string) => (cause: { readonly message: string }) =>
100
111
  new ReportError({ message: `the junit report holds a <${tag}> bun never writes: ${cause.message}` });
101
112
  for (const child of suite.children) {
102
113
  if (child.tag === "testsuite") {
103
114
  const { name } = yield* decodeSuite(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
104
- yield* collect(child, [...describes, name], results);
115
+ yield* collect(child, [...describes, name], report);
116
+ } else if (child.tag === "testcase" && crashed(child)) {
117
+ const { file } = yield* decodeCrash(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
118
+ report.crashedFiles.push(file);
105
119
  } else if (child.tag === "testcase") {
106
120
  const { name, file, line } = yield* decodeCase(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
107
- results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
121
+ report.results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
108
122
  }
109
123
  }
110
124
  });
@@ -113,8 +127,9 @@ const collect = Effect.fnUntraced(function* (
113
127
  export const parseReport = Effect.fn("parseReport")(function* (xml: string) {
114
128
  const root = yield* parseElements(xml);
115
129
  const results: TestResult[] = [];
130
+ const crashedFiles: string[] = [];
116
131
  for (const fileSuite of root.children) {
117
- if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], results);
132
+ if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], { results, crashedFiles });
118
133
  }
119
- return results;
134
+ return { results, crashedFiles } satisfies Report;
120
135
  });
@@ -20,12 +20,35 @@ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGate
20
20
  message: Schema.String,
21
21
  }) {}
22
22
 
23
+ export type TestOptions = {
24
+ readonly tier?: TestTier;
25
+ readonly workers?: number;
26
+ };
27
+
23
28
  const NAME = TEST_ENTRY_POINT.bin;
24
- const USAGE = `usage: ${NAME} takes no arguments except --tier=live or --tier=pixel`;
29
+ const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel] [--parallel=<workers>], and it refuses test filters`;
25
30
  const TIERS = new Map<string, TestTier>([
26
31
  ["--tier=live", "live"],
27
32
  ["--tier=pixel", "pixel"],
28
33
  ]);
34
+ const PARALLEL = /^--parallel=([1-9][0-9]*)$/;
35
+
36
+ function optionOf(arg: string): TestOptions | undefined {
37
+ const tier = TIERS.get(arg);
38
+ if (tier !== undefined) return { tier };
39
+ const workers = PARALLEL.exec(arg)?.[1];
40
+ return workers === undefined ? undefined : { workers: Number(workers) };
41
+ }
42
+
43
+ export function testOptions(args: readonly string[]): TestOptions | undefined {
44
+ let options: TestOptions = {};
45
+ for (const arg of args) {
46
+ const option = optionOf(arg);
47
+ if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
48
+ options = { ...options, ...option };
49
+ }
50
+ return options;
51
+ }
29
52
 
30
53
  function matches(declaration: SkipDeclaration, result: TestResult): boolean {
31
54
  const { name } = declaration;
@@ -113,10 +136,11 @@ const environmentOf = Config.Boolean("CI").pipe(
113
136
  Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
114
137
  );
115
138
 
116
- const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTier | undefined) {
139
+ const runSuite = Effect.fn("runSuite")(function* (outfile: string, { tier, workers }: TestOptions) {
117
140
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
118
141
  const tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
119
- const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
142
+ const parallelArgs = workers === undefined ? [] : [`--parallel=${workers}`];
143
+ const args = ["test", "--randomize", ...parallelArgs, ...tierArgs, ...reporterArgs(outfile)];
120
144
  return yield* spawner.exitCode(
121
145
  ChildProcess.make(process.execPath, args, {
122
146
  stdin: "ignore",
@@ -129,15 +153,14 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTie
129
153
  });
130
154
 
131
155
  const testEntry = Effect.gen(function* () {
132
- const args = process.argv.slice(2);
133
- const tier = args.length === 0 ? undefined : args.length === 1 ? TIERS.get(args[0] ?? "") : undefined;
134
- if (args.length > 0 && tier === undefined) return yield* new Usage({ message: USAGE });
156
+ const options = testOptions(process.argv.slice(2));
157
+ if (options === undefined) return yield* new Usage({ message: USAGE });
135
158
  const fs = yield* FileSystem.FileSystem;
136
- const declarations = yield* readSkipDeclarations(process.cwd(), tier);
159
+ const declarations = yield* readSkipDeclarations(process.cwd(), options.tier);
137
160
  const environment = yield* environmentOf;
138
161
 
139
162
  const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
140
- const suitePassed = (yield* runSuite(outfile, tier)) === ChildProcessSpawner.ExitCode(0);
163
+ const suitePassed = (yield* runSuite(outfile, options)) === ChildProcessSpawner.ExitCode(0);
141
164
  if (!(yield* fs.exists(outfile))) {
142
165
  if (!suitePassed) {
143
166
  yield* Console.error(`${NAME}: bun test failed before it wrote a report, so no skip was judged`);
@@ -146,9 +169,12 @@ const testEntry = Effect.gen(function* () {
146
169
  return yield* new ReportError({ message: `bun test passed but wrote no report to ${outfile}` });
147
170
  }
148
171
 
149
- const verdict = judgeSkips(yield* parseReport(yield* fs.readFileString(outfile)), declarations, environment);
172
+ const { results, crashedFiles } = yield* parseReport(yield* fs.readFileString(outfile));
173
+ for (const file of crashedFiles) yield* Console.error(`${NAME}: the worker running ${file} crashed, so its skips were not judged`);
174
+ const judged = declarations.filter((declaration) => !crashedFiles.includes(declaration.file));
175
+ const verdict = judgeSkips(results, judged, environment);
150
176
  yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
151
- return suitePassed && passes(verdict);
177
+ return suitePassed && crashedFiles.length === 0 && passes(verdict);
152
178
  }).pipe(Effect.scoped);
153
179
 
154
180
  if (import.meta.main) runMain(NAME, testEntry);