@evolu/nodejs 3.1.0 → 3.2.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/README.md CHANGED
@@ -1,11 +1,20 @@
1
1
  # Evolu for Node.js
2
2
 
3
- This package provides Evolu for [Node.js](https://nodejs.org) 24+.
3
+ This package provides Evolu for [Node.js](https://nodejs.org) 24.20+.
4
4
 
5
5
  ## Documentation
6
6
 
7
7
  For detailed information and usage examples, please visit [evolu.dev](https://www.evolu.dev).
8
8
 
9
+ ## Test overview reporter
10
+
11
+ `@evolu/nodejs/TestOverviewReporter` lists Node.js test files slowest-first while
12
+ preserving native failure diagnostics, run totals, and coverage output:
13
+
14
+ ```sh
15
+ node --test --test-reporter=@evolu/nodejs/TestOverviewReporter
16
+ ```
17
+
9
18
  ## Community
10
19
 
11
20
  The Evolu community is on [GitHub Discussions](https://github.com/evoluhq/evolu/discussions), where you can ask questions and voice ideas.
@@ -1 +1 @@
1
- {"version":3,"file":"TestBundle.d.ts","sourceRoot":"","sources":["../../src/TestBundle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAYL,KAAK,gBAAgB,EACrB,KAAK,cAAc,EAQpB,MAAM,eAAe,CAAC;AA4CvB,wCAAwC;AACxC,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,mDAAmD;IACnD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;CACpC;AAED,sDAAsD;AACtD,MAAM,MAAM,oBAAoB,GAAG,cAAc,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAE1E,0DAA0D;AAC1D,MAAM,MAAM,gBAAgB,GAAG,cAAc,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC;AAE5E,qEAAqE;AACrE,MAAM,WAAW,UAAW,SAAQ,cAAc;IAChD,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,kCAAkC;AAClC,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/E;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACvD;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClD;;;OAGG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,eAAe,CAAC,EAAE,gBAAgB,CAAC;IAC5C,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,eAAO,MAAM,UAAU,qFAMpB,iBAAiB,KAAG,OAAO,CAAC,gBAAgB,CAgK9C,CAAC"}
1
+ {"version":3,"file":"TestBundle.d.ts","sourceRoot":"","sources":["../../src/TestBundle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAaL,KAAK,gBAAgB,EACrB,KAAK,cAAc,EASpB,MAAM,eAAe,CAAC;AA4CvB,wCAAwC;AACxC,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,mDAAmD;IACnD,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;CACpC;AAED,sDAAsD;AACtD,MAAM,MAAM,oBAAoB,GAAG,cAAc,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAE1E,0DAA0D;AAC1D,MAAM,MAAM,gBAAgB,GAAG,cAAc,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC;AAE5E,qEAAqE;AACrE,MAAM,WAAW,UAAW,SAAQ,cAAc;IAChD,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,kCAAkC;AAClC,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/E;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACvD;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClD;;;OAGG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,eAAe,CAAC,EAAE,gBAAgB,CAAC;IAC5C,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,eAAO,MAAM,UAAU,qFAMpB,iBAAiB,KAAG,OAAO,CAAC,gBAAgB,CA+J9C,CAAC"}
@@ -62,7 +62,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
62
62
  var e = new Error(message);
63
63
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
64
64
  });
65
- import { allSettled, assert, assertType, instanceOf, createRun, durationToMillis, escapeRegExp, filterArray, isErr, mapArray, safelyStringifyUnknownValue, String as StringType, timeout, TimeoutError, tryAsync, } from "@evolu/common";
65
+ import { allSettled, assert, assertLength, assertSame, assertType, instanceOf, createRun, durationToMillis, escapeRegExp, isErr, mapArray, partitionArray, safelyStringifyUnknownValue, String as StringType, timeout, TimeoutError, tryAsync, } from "@evolu/common";
66
66
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
67
67
  import { tmpdir } from "node:os";
68
68
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -186,7 +186,7 @@ export const testBundle = async ({ cases, aliases = {}, outputDirectory, bundlin
186
186
  error,
187
187
  };
188
188
  }), { concurrency: availableParallelism() }));
189
- const failures = filterArray(results, isErr);
189
+ const [failures, successes] = partitionArray(results, isErr);
190
190
  if (failures.length > 0) {
191
191
  const errors = mapArray(failures, ({ error: { caseName, bundler, error } }) => {
192
192
  const message = error instanceof Error
@@ -202,8 +202,7 @@ export const testBundle = async ({ cases, aliases = {}, outputDirectory, bundlin
202
202
  const bundleEntriesByCase = new Map();
203
203
  for (const [caseName] of caseEntries)
204
204
  bundleEntriesByCase.set(caseName, []);
205
- for (const result of results) {
206
- assert(result.ok, "Expected every bundle test to succeed.");
205
+ for (const result of successes) {
207
206
  const { caseName, bundle } = result.value;
208
207
  const entries = bundleEntriesByCase.get(caseName);
209
208
  assert(entries, `Missing bundle test case "${caseName}".`);
@@ -314,13 +313,11 @@ const testViteBundler = {
314
313
  });
315
314
  assert(Array.isArray(output), "Vite did not return build outputs.");
316
315
  const outputs = output;
317
- assert(outputs.length === 1, "Vite did not return one build output.");
318
- const viteOutput = outputs.at(0);
319
- assert(viteOutput, "Vite did not return a build output.");
320
- assert(viteOutput.output.length === 1, "Vite did not emit one JavaScript chunk.");
321
- const chunk = viteOutput.output.at(0);
322
- assert(chunk, "Vite did not emit a JavaScript chunk.");
323
- assert(chunk.type === "chunk", "Vite did not emit a JavaScript chunk.");
316
+ assertLength(outputs, 1);
317
+ const viteOutput = outputs[0];
318
+ assertLength(viteOutput.output, 1);
319
+ const chunk = viteOutput.output[0];
320
+ assertSame(chunk.type, "chunk");
324
321
  assertType(StringType, chunk.code);
325
322
  return {
326
323
  code: chunk.code,
@@ -415,7 +412,7 @@ const runTestBundler = (bundlerName, options) => async (run) => tryAsync(async (
415
412
  assertType(ErrorType, error);
416
413
  return error;
417
414
  });
418
- const ErrorType = /*#__PURE__*/ instanceOf(globalThis.Error);
415
+ const ErrorType = /*#__PURE__*/ instanceOf(Error);
419
416
  // TODO: Replace the evaluated CommonJS worker with an ESM module worker.
420
417
  const testBundleWorkerSource = String.raw `
421
418
  const { parentPort, workerData } = require("node:worker_threads");
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Overview reporting for Node.js tests.
3
+ *
4
+ * Use the dedicated `@evolu/nodejs/TestOverviewReporter` entry point directly
5
+ * with Node.js:
6
+ *
7
+ * ```sh
8
+ * node --test --test-reporter=@evolu/nodejs/TestOverviewReporter
9
+ * ```
10
+ *
11
+ * @module
12
+ */
13
+ import { type TestEvent } from "node:test/reporters";
14
+ /**
15
+ * Lists test files slowest-first and preserves Node.js failure diagnostics, run
16
+ * totals, and coverage output.
17
+ *
18
+ * Durations longer than 300 ms are highlighted when terminal colors are
19
+ * supported.
20
+ */
21
+ declare const testOverviewReporter: (source: AsyncIterable<TestEvent> | Iterable<TestEvent>) => AsyncGenerator<string | Uint8Array, void>;
22
+ export default testOverviewReporter;
23
+ //# sourceMappingURL=TestOverviewReporter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TestOverviewReporter.d.ts","sourceRoot":"","sources":["../../src/TestOverviewReporter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAa,KAAK,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAKhE;;;;;;GAMG;AACH,QAAA,MAAM,oBAAoB,WAChB,aAAa,CAAC,SAAS,CAAC,GAAG,QAAQ,CAAC,SAAS,CAAC,KACrD,cAAc,CAAC,MAAM,GAAG,UAAU,EAAE,IAAI,CAuD1C,CAAC;eAEa,oBAAoB"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Overview reporting for Node.js tests.
3
+ *
4
+ * Use the dedicated `@evolu/nodejs/TestOverviewReporter` entry point directly
5
+ * with Node.js:
6
+ *
7
+ * ```sh
8
+ * node --test --test-reporter=@evolu/nodejs/TestOverviewReporter
9
+ * ```
10
+ *
11
+ * @module
12
+ */
13
+ import { relative } from "node:path";
14
+ import { dot, spec } from "node:test/reporters";
15
+ import { styleText } from "node:util";
16
+ const slowTestThresholdMs = 300;
17
+ /**
18
+ * Lists test files slowest-first and preserves Node.js failure diagnostics, run
19
+ * totals, and coverage output.
20
+ *
21
+ * Durations longer than 300 ms are highlighted when terminal colors are
22
+ * supported.
23
+ */
24
+ const testOverviewReporter = async function* (source) {
25
+ const fileSummaries = [];
26
+ const dotOutput = [];
27
+ const specEvents = [];
28
+ let hasFailures = false;
29
+ const captureEvents = async function* () {
30
+ for await (const event of source) {
31
+ if (event.type === "test:fail")
32
+ hasFailures = true;
33
+ if (event.type === "test:summary" && event.data.file !== undefined) {
34
+ fileSummaries.push({ ...event.data, file: event.data.file });
35
+ }
36
+ if (event.type === "test:coverage" || event.type === "test:diagnostic") {
37
+ specEvents.push(event);
38
+ }
39
+ yield event;
40
+ }
41
+ };
42
+ for await (const output of dot(captureEvents()))
43
+ dotOutput.push(output);
44
+ fileSummaries.sort((a, b) => b.duration_ms - a.duration_ms);
45
+ if (hasFailures) {
46
+ for (const output of dotOutput)
47
+ yield output;
48
+ }
49
+ if (fileSummaries.length > 0) {
50
+ yield `${styleText("bold", "Test files:")}\n\n`;
51
+ for (const { counts, duration_ms, file, success } of fileSummaries) {
52
+ const testLabel = counts.tests === 1 ? "test" : "tests";
53
+ const status = styleText(success ? "green" : "red", success ? "✔" : "✖");
54
+ const tests = styleText("dim", `(${counts.tests} ${testLabel})`);
55
+ const duration = styleText(duration_ms > slowTestThresholdMs ? "yellow" : "green", `${Math.round(duration_ms)}ms`);
56
+ yield `${status} ${relative(process.cwd(), file)} ${tests} ${duration}\n`;
57
+ }
58
+ yield "\n";
59
+ }
60
+ const summaryReporter = spec();
61
+ for (const event of specEvents)
62
+ summaryReporter.write(event);
63
+ summaryReporter.end();
64
+ yield* summaryReporter;
65
+ };
66
+ export default testOverviewReporter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/nodejs",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Evolu for Node.js",
5
5
  "author": "Daniel Steigerwald <daniel@steigerwald.cz>",
6
6
  "license": "MIT",
@@ -29,6 +29,11 @@
29
29
  "types": "./dist/src/TestJSDoc.d.ts",
30
30
  "import": "./dist/src/TestJSDoc.js",
31
31
  "default": "./dist/src/TestJSDoc.js"
32
+ },
33
+ "./TestOverviewReporter": {
34
+ "types": "./dist/src/TestOverviewReporter.d.ts",
35
+ "import": "./dist/src/TestOverviewReporter.js",
36
+ "default": "./dist/src/TestOverviewReporter.js"
32
37
  }
33
38
  },
34
39
  "typesVersions": {
@@ -38,11 +43,15 @@
38
43
  ],
39
44
  "TestJSDoc": [
40
45
  "./dist/src/TestJSDoc.d.ts"
46
+ ],
47
+ "TestOverviewReporter": [
48
+ "./dist/src/TestOverviewReporter.d.ts"
41
49
  ]
42
50
  }
43
51
  },
44
52
  "files": [
45
53
  "dist/src/**",
54
+ "!dist/**/*.test.*",
46
55
  "src/**",
47
56
  "README.md"
48
57
  ],
@@ -51,9 +60,9 @@
51
60
  "ws": "^8.21.3"
52
61
  },
53
62
  "devDependencies": {
54
- "@evolu/common": "8.6.0",
55
- "@evolu/oxlint-config": "0.1.0",
56
- "@evolu/typescript-config": "0.1.0",
63
+ "@evolu/common": "8.8.0",
64
+ "@evolu/oxlint-config": "0.2.0",
65
+ "@evolu/typescript-config": "0.1.1",
57
66
  "@types/better-sqlite3": "^9.6.0",
58
67
  "@types/node": "^24.10.9",
59
68
  "@types/ws": "^8.18.1",
@@ -65,7 +74,7 @@
65
74
  },
66
75
  "peerDependencies": {
67
76
  "@evolu/common": "^8.0.0",
68
- "@evolu/oxlint-config": "^0.1.0",
77
+ "@evolu/oxlint-config": "^0.2.0",
69
78
  "@evolu/typescript-config": "^0.1.0",
70
79
  "oxlint": "^1.79.0",
71
80
  "oxlint-tsgolint": "7.0.2001",
@@ -97,7 +106,7 @@
97
106
  }
98
107
  },
99
108
  "engines": {
100
- "node": ">=24.0.0"
109
+ "node": ">=24.20.0"
101
110
  },
102
111
  "publishConfig": {
103
112
  "access": "public"
@@ -0,0 +1,90 @@
1
+ import {
2
+ assertEqual,
3
+ assertErr,
4
+ assertOk,
5
+ assertTrue,
6
+ testAbortError,
7
+ testAbortReason,
8
+ testCreateRun,
9
+ } from "@evolu/common";
10
+ import { test } from "node:test";
11
+ import { spawn } from "./Cli.ts";
12
+
13
+ test("spawn succeeds with inherited stdio and a configured cwd", async () => {
14
+ await using run = testCreateRun();
15
+ const scriptsDirectory = new URL("../../../scripts/", import.meta.url);
16
+ const result = await run(
17
+ spawn(
18
+ process.execPath,
19
+ ["-e", 'process.exit(process.cwd().endsWith("scripts") ? 0 : 1)'],
20
+ { cwd: scriptsDirectory },
21
+ ),
22
+ );
23
+
24
+ assertOk(result);
25
+ });
26
+
27
+ test("spawn returns a start error", async () => {
28
+ await using run = testCreateRun();
29
+ const result = await run(spawn("evolu-command-that-does-not-exist", []));
30
+
31
+ assertErr(result);
32
+ const { message } = result.error;
33
+ assertTrue(
34
+ message.startsWith("Failed to start evolu-command-that-does-not-exist:"),
35
+ );
36
+ assertEqual(result, {
37
+ ok: false,
38
+ error: {
39
+ type: "SpawnError",
40
+ command: "evolu-command-that-does-not-exist",
41
+ exitCode: null,
42
+ signal: null,
43
+ message,
44
+ },
45
+ });
46
+ });
47
+
48
+ test("spawn returns a non-zero exit error", async () => {
49
+ await using run = testCreateRun();
50
+ const result = await run(spawn(process.execPath, ["-e", "process.exit(7)"]));
51
+ const command = `${process.execPath} -e process.exit(7)`;
52
+
53
+ assertErr(result, {
54
+ type: "SpawnError",
55
+ command,
56
+ exitCode: 7,
57
+ signal: null,
58
+ message: `${command} exited with code 7.`,
59
+ });
60
+ });
61
+
62
+ test("spawn returns a signal exit error", async () => {
63
+ await using run = testCreateRun();
64
+ const result = await run(
65
+ spawn(process.execPath, ["-e", 'process.kill(process.pid, "SIGTERM")']),
66
+ );
67
+ const command = `${process.execPath} -e process.kill(process.pid, "SIGTERM")`;
68
+
69
+ assertErr(result, {
70
+ type: "SpawnError",
71
+ command,
72
+ exitCode: null,
73
+ signal: "SIGTERM",
74
+ message: `${command} exited from SIGTERM.`,
75
+ });
76
+ });
77
+
78
+ test("spawn aborts its child process with the Run", async () => {
79
+ await using run = testCreateRun();
80
+ const fiber = run.abortable(
81
+ spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"]),
82
+ );
83
+
84
+ await new Promise<void>((resolve) => {
85
+ setImmediate(resolve);
86
+ });
87
+ fiber.abort(testAbortReason);
88
+
89
+ assertErr(await fiber, testAbortError);
90
+ });
@@ -0,0 +1,32 @@
1
+ import {
2
+ assertEqual,
3
+ assertSame,
4
+ assertType,
5
+ PositiveInt,
6
+ } from "@evolu/common";
7
+ import { mock, test } from "node:test";
8
+ import type {
9
+ AvailableParallelism,
10
+ AvailableParallelismDep,
11
+ } from "./Platform.ts";
12
+
13
+ const nodeAvailableParallelism = mock.fn<() => number>();
14
+
15
+ mock.module("node:os", {
16
+ // @ts-expect-error -- Node.js 24.20 replaces the deprecated namedExports option with exports, which @types/node 24.13 does not declare yet.
17
+ exports: { availableParallelism: nodeAvailableParallelism },
18
+ });
19
+
20
+ const { availableParallelism } = await import("./Platform.ts");
21
+
22
+ test("availableParallelism returns the validated Node.js value", () => {
23
+ nodeAvailableParallelism.mock.mockImplementation(() => 128);
24
+
25
+ const parallelism = availableParallelism();
26
+ const deps = { availableParallelism } satisfies AvailableParallelismDep;
27
+
28
+ assertType<typeof availableParallelism, AvailableParallelism>();
29
+ assertType<typeof parallelism, PositiveInt>();
30
+ assertSame(deps.availableParallelism, availableParallelism);
31
+ assertEqual(parallelism, 128);
32
+ });
package/src/TestBundle.ts CHANGED
@@ -14,18 +14,20 @@
14
14
  import {
15
15
  allSettled,
16
16
  assert,
17
+ assertLength,
18
+ assertSame,
17
19
  assertType,
18
20
  instanceOf,
19
21
  createRun,
20
22
  durationToMillis,
21
23
  escapeRegExp,
22
- filterArray,
23
24
  isErr,
24
25
  mapArray,
25
26
  type NonEmptyReadonlyArray,
26
27
  type PositiveDuration,
27
28
  type ReadonlyRecord,
28
29
  type Result,
30
+ partitionArray,
29
31
  safelyStringifyUnknownValue,
30
32
  String as StringType,
31
33
  type Task,
@@ -301,7 +303,7 @@ export const testBundle = async ({
301
303
  ),
302
304
  );
303
305
 
304
- const failures = filterArray(results, isErr);
306
+ const [failures, successes] = partitionArray(results, isErr);
305
307
  if (failures.length > 0) {
306
308
  const errors = mapArray(
307
309
  failures,
@@ -331,8 +333,7 @@ export const testBundle = async ({
331
333
  >();
332
334
  for (const [caseName] of caseEntries) bundleEntriesByCase.set(caseName, []);
333
335
 
334
- for (const result of results) {
335
- assert(result.ok, "Expected every bundle test to succeed.");
336
+ for (const result of successes) {
336
337
  const { caseName, bundle } = result.value;
337
338
  const entries = bundleEntriesByCase.get(caseName);
338
339
  assert(entries, `Missing bundle test case "${caseName}".`);
@@ -438,16 +439,11 @@ const testViteBundler: TestBundler = {
438
439
  >;
439
440
  assert(Array.isArray(output), "Vite did not return build outputs.");
440
441
  const outputs: ReadonlyArray<ViteOutput> = output;
441
- assert(outputs.length === 1, "Vite did not return one build output.");
442
- const viteOutput = outputs.at(0);
443
- assert(viteOutput, "Vite did not return a build output.");
444
- assert(
445
- viteOutput.output.length === 1,
446
- "Vite did not emit one JavaScript chunk.",
447
- );
448
- const chunk = viteOutput.output.at(0);
449
- assert(chunk, "Vite did not emit a JavaScript chunk.");
450
- assert(chunk.type === "chunk", "Vite did not emit a JavaScript chunk.");
442
+ assertLength(outputs, 1);
443
+ const viteOutput = outputs[0];
444
+ assertLength(viteOutput.output, 1);
445
+ const chunk = viteOutput.output[0];
446
+ assertSame(chunk.type, "chunk");
451
447
  assertType(StringType, chunk.code);
452
448
 
453
449
  return {
@@ -582,7 +578,7 @@ interface TestBundleWorkerError {
582
578
  readonly stack: string;
583
579
  }
584
580
 
585
- const ErrorType = /*#__PURE__*/ instanceOf(globalThis.Error);
581
+ const ErrorType = /*#__PURE__*/ instanceOf(Error);
586
582
 
587
583
  // TODO: Replace the evaluated CommonJS worker with an ESM module worker.
588
584
  const testBundleWorkerSource = String.raw`
@@ -0,0 +1,219 @@
1
+ import { assertEqual, assertFalse, assertTrue } from "@evolu/common";
2
+ import { relative, resolve } from "node:path";
3
+ import { describe, it } from "node:test";
4
+ import type { TestEvent } from "node:test/reporters";
5
+ import { stripVTControlCharacters } from "node:util";
6
+
7
+ import testOverviewReporter from "./TestOverviewReporter.ts";
8
+
9
+ const createFileSummaryEvent = ({
10
+ durationMs,
11
+ file,
12
+ success,
13
+ tests,
14
+ }: {
15
+ readonly durationMs: number;
16
+ readonly file: string;
17
+ readonly success: boolean;
18
+ readonly tests: number;
19
+ }): TestEvent => ({
20
+ type: "test:summary",
21
+ data: {
22
+ counts: {
23
+ cancelled: 0,
24
+ passed: success ? tests : tests - 1,
25
+ skipped: 0,
26
+ suites: 0,
27
+ tests,
28
+ todo: 0,
29
+ topLevel: tests,
30
+ },
31
+ duration_ms: durationMs,
32
+ file,
33
+ success,
34
+ },
35
+ });
36
+
37
+ const collectReporterOutput = async (
38
+ events: ReadonlyArray<TestEvent>,
39
+ ): Promise<string> => {
40
+ let output = "";
41
+ for await (const chunk of testOverviewReporter(events)) {
42
+ output +=
43
+ typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
44
+ }
45
+ return stripVTControlCharacters(output);
46
+ };
47
+
48
+ describe("testOverviewReporter", () => {
49
+ it("reports test files slowest-first", async () => {
50
+ const fastFile = resolve("packages/common/src/Fast.test.ts");
51
+ const slowFile = resolve("packages/common/src/Slow.test.ts");
52
+ const output = await collectReporterOutput([
53
+ createFileSummaryEvent({
54
+ durationMs: 2.4,
55
+ file: fastFile,
56
+ success: true,
57
+ tests: 1,
58
+ }),
59
+ createFileSummaryEvent({
60
+ durationMs: 312.6,
61
+ file: slowFile,
62
+ success: true,
63
+ tests: 2,
64
+ }),
65
+ {
66
+ type: "test:summary",
67
+ data: {
68
+ counts: {
69
+ cancelled: 0,
70
+ passed: 3,
71
+ skipped: 0,
72
+ suites: 0,
73
+ tests: 3,
74
+ todo: 0,
75
+ topLevel: 3,
76
+ },
77
+ duration_ms: 15,
78
+ file: undefined,
79
+ success: true,
80
+ },
81
+ },
82
+ ]);
83
+
84
+ assertEqual(
85
+ output,
86
+ `Test files:
87
+
88
+ ✔ ${relative(process.cwd(), slowFile)} (2 tests) 313ms
89
+ ✔ ${relative(process.cwd(), fastFile)} (1 test) 2ms
90
+
91
+ `,
92
+ );
93
+ });
94
+
95
+ it("preserves Node.js failure diagnostics", async () => {
96
+ const cause = new Error("Expected Ada.");
97
+ const error = Object.assign(new Error("Test failed.", { cause }), {
98
+ cause,
99
+ });
100
+ const file = resolve("packages/common/src/Failing.test.ts");
101
+ const output = await collectReporterOutput([
102
+ {
103
+ type: "test:fail",
104
+ data: {
105
+ details: { duration_ms: 1, error, type: "test" },
106
+ file,
107
+ name: "fails",
108
+ nesting: 0,
109
+ testNumber: 1,
110
+ },
111
+ },
112
+ createFileSummaryEvent({
113
+ durationMs: 1,
114
+ file,
115
+ success: false,
116
+ tests: 1,
117
+ }),
118
+ ]);
119
+
120
+ assertTrue(output.includes("Failed tests:"));
121
+ assertTrue(output.includes("Test failed."));
122
+ assertTrue(output.includes(`✖ ${relative(process.cwd(), file)}`));
123
+ });
124
+
125
+ it("preserves failures without file summaries", async () => {
126
+ const cause = new Error("Module failed to load.");
127
+ const error = Object.assign(new Error("Test failed.", { cause }), {
128
+ cause,
129
+ });
130
+ const details: Extract<
131
+ TestEvent,
132
+ { readonly type: "test:fail" }
133
+ >["data"]["details"] = {
134
+ duration_ms: 1,
135
+ error,
136
+ type: "test",
137
+ };
138
+ const failure: TestEvent = {
139
+ type: "test:fail",
140
+ data: {
141
+ details,
142
+ file: resolve("packages/common/src/Failing.test.ts"),
143
+ name: "fails to load",
144
+ nesting: 0,
145
+ testNumber: 1,
146
+ },
147
+ };
148
+ const output = await collectReporterOutput([failure]);
149
+
150
+ assertTrue(output.includes("Failed tests:"));
151
+ assertTrue(output.includes("Module failed to load."));
152
+ assertFalse(output.includes("Test files:"));
153
+ });
154
+
155
+ it("uses Node.js summary and coverage formatting", async () => {
156
+ const output = await collectReporterOutput([
157
+ {
158
+ type: "test:diagnostic",
159
+ data: {
160
+ level: "info",
161
+ message: "skipped 1",
162
+ nesting: 0,
163
+ },
164
+ },
165
+ {
166
+ type: "test:diagnostic",
167
+ data: {
168
+ level: "error",
169
+ message: "Coverage threshold was not met.",
170
+ nesting: 0,
171
+ },
172
+ },
173
+ {
174
+ type: "test:coverage",
175
+ data: {
176
+ nesting: 0,
177
+ summary: {
178
+ files: [
179
+ {
180
+ branches: [],
181
+ coveredBranchCount: 1,
182
+ coveredFunctionCount: 1,
183
+ coveredLineCount: 1,
184
+ coveredBranchPercent: 100,
185
+ coveredFunctionPercent: 100,
186
+ coveredLinePercent: 100,
187
+ functions: [],
188
+ lines: [{ count: 1, line: 1 }],
189
+ path: resolve("packages/common/src/Foo.ts"),
190
+ totalBranchCount: 1,
191
+ totalFunctionCount: 1,
192
+ totalLineCount: 1,
193
+ },
194
+ ],
195
+ thresholds: { branch: 1, function: 1, line: 1 },
196
+ totals: {
197
+ coveredBranchCount: 1,
198
+ coveredFunctionCount: 1,
199
+ coveredLineCount: 1,
200
+ coveredBranchPercent: 100,
201
+ coveredFunctionPercent: 100,
202
+ coveredLinePercent: 100,
203
+ totalBranchCount: 1,
204
+ totalFunctionCount: 1,
205
+ totalLineCount: 1,
206
+ },
207
+ workingDirectory: process.cwd(),
208
+ },
209
+ },
210
+ },
211
+ ]);
212
+
213
+ assertTrue(output.includes("Coverage threshold was not met."));
214
+ assertTrue(output.includes("skipped 1"));
215
+ assertTrue(output.includes("start of coverage report"));
216
+ assertTrue(output.includes("Foo.ts"));
217
+ assertFalse(output.includes("Test files:"));
218
+ });
219
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Overview reporting for Node.js tests.
3
+ *
4
+ * Use the dedicated `@evolu/nodejs/TestOverviewReporter` entry point directly
5
+ * with Node.js:
6
+ *
7
+ * ```sh
8
+ * node --test --test-reporter=@evolu/nodejs/TestOverviewReporter
9
+ * ```
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { relative } from "node:path";
15
+ import { dot, spec, type TestEvent } from "node:test/reporters";
16
+ import { styleText } from "node:util";
17
+
18
+ const slowTestThresholdMs = 300;
19
+
20
+ /**
21
+ * Lists test files slowest-first and preserves Node.js failure diagnostics, run
22
+ * totals, and coverage output.
23
+ *
24
+ * Durations longer than 300 ms are highlighted when terminal colors are
25
+ * supported.
26
+ */
27
+ const testOverviewReporter = async function* (
28
+ source: AsyncIterable<TestEvent> | Iterable<TestEvent>,
29
+ ): AsyncGenerator<string | Uint8Array, void> {
30
+ const fileSummaries: Array<
31
+ Extract<TestEvent, { readonly type: "test:summary" }>["data"] & {
32
+ readonly file: string;
33
+ }
34
+ > = [];
35
+ const dotOutput: Array<string> = [];
36
+ const specEvents: Array<TestEvent> = [];
37
+ let hasFailures = false;
38
+
39
+ const captureEvents = async function* (): AsyncGenerator<TestEvent, void> {
40
+ for await (const event of source) {
41
+ if (event.type === "test:fail") hasFailures = true;
42
+
43
+ if (event.type === "test:summary" && event.data.file !== undefined) {
44
+ fileSummaries.push({ ...event.data, file: event.data.file });
45
+ }
46
+
47
+ if (event.type === "test:coverage" || event.type === "test:diagnostic") {
48
+ specEvents.push(event);
49
+ }
50
+
51
+ yield event;
52
+ }
53
+ };
54
+
55
+ for await (const output of dot(captureEvents())) dotOutput.push(output);
56
+
57
+ fileSummaries.sort((a, b) => b.duration_ms - a.duration_ms);
58
+
59
+ if (hasFailures) {
60
+ for (const output of dotOutput) yield output;
61
+ }
62
+
63
+ if (fileSummaries.length > 0) {
64
+ yield `${styleText("bold", "Test files:")}\n\n`;
65
+
66
+ for (const { counts, duration_ms, file, success } of fileSummaries) {
67
+ const testLabel = counts.tests === 1 ? "test" : "tests";
68
+ const status = styleText(success ? "green" : "red", success ? "✔" : "✖");
69
+ const tests = styleText("dim", `(${counts.tests} ${testLabel})`);
70
+ const duration = styleText(
71
+ duration_ms > slowTestThresholdMs ? "yellow" : "green",
72
+ `${Math.round(duration_ms)}ms`,
73
+ );
74
+ yield `${status} ${relative(process.cwd(), file)} ${tests} ${duration}\n`;
75
+ }
76
+
77
+ yield "\n";
78
+ }
79
+
80
+ const summaryReporter = spec();
81
+ for (const event of specEvents) summaryReporter.write(event);
82
+ summaryReporter.end();
83
+ yield* summaryReporter;
84
+ };
85
+
86
+ export default testOverviewReporter;
@@ -0,0 +1,67 @@
1
+ import {
2
+ assertEqual,
3
+ assertThrowsInstanceOf,
4
+ assertType,
5
+ Millis,
6
+ } from "@evolu/common";
7
+ import { afterEach, describe, it, mock } from "node:test";
8
+ import type { HrDuration, HrTime, NodejsTime } from "./Time.ts";
9
+ import {
10
+ createNodejsTime,
11
+ hrDurationBetween,
12
+ hrDurationToMillis,
13
+ millisToHrDuration,
14
+ } from "./Time.ts";
15
+
16
+ describe("NodejsTime", () => {
17
+ afterEach(() => {
18
+ mock.restoreAll();
19
+ });
20
+
21
+ it("createNodejsTime exposes the native high-resolution clock", () => {
22
+ mock.method(process.hrtime, "bigint", () => 123n);
23
+
24
+ const time: NodejsTime = createNodejsTime();
25
+ const now: HrTime = time.hrtime();
26
+
27
+ assertEqual(now, 123n);
28
+ });
29
+ });
30
+
31
+ describe("hrDurationBetween", () => {
32
+ it("returns elapsed nanoseconds", () => {
33
+ const result = hrDurationBetween(100n as HrTime, 125n as HrTime);
34
+
35
+ assertType<typeof result, HrDuration>();
36
+ assertEqual(result, 25n);
37
+ });
38
+
39
+ it("rejects an end time before the start time", () => {
40
+ assertEqual(
41
+ assertThrowsInstanceOf(
42
+ () => hrDurationBetween(125n as HrTime, 100n as HrTime),
43
+ Error,
44
+ ).message,
45
+ "High-resolution end time must not precede start time",
46
+ );
47
+ });
48
+ });
49
+
50
+ describe("hrDurationToMillis", () => {
51
+ it("rounds to the nearest millisecond", () => {
52
+ const result = hrDurationToMillis(1_499_999n as HrDuration);
53
+
54
+ assertType<typeof result, Millis>();
55
+ assertEqual(result, 1);
56
+ assertEqual(hrDurationToMillis(1_500_000n as HrDuration), 2);
57
+ });
58
+ });
59
+
60
+ describe("millisToHrDuration", () => {
61
+ it("converts milliseconds to nanoseconds", () => {
62
+ const result = millisToHrDuration(Millis.orThrow(2));
63
+
64
+ assertType<typeof result, HrDuration>();
65
+ assertEqual(result, 2_000_000n);
66
+ });
67
+ });