@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 +10 -1
- package/dist/src/TestBundle.d.ts.map +1 -1
- package/dist/src/TestBundle.js +9 -12
- package/dist/src/TestOverviewReporter.d.ts +23 -0
- package/dist/src/TestOverviewReporter.d.ts.map +1 -0
- package/dist/src/TestOverviewReporter.js +66 -0
- package/package.json +15 -6
- package/src/Cli.test.ts +90 -0
- package/src/Platform.test.ts +32 -0
- package/src/TestBundle.ts +11 -15
- package/src/TestOverviewReporter.test.ts +219 -0
- package/src/TestOverviewReporter.ts +86 -0
- package/src/Time.test.ts +67 -0
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,
|
|
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"}
|
package/dist/src/TestBundle.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
318
|
-
const viteOutput = outputs
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
55
|
-
"@evolu/oxlint-config": "0.
|
|
56
|
-
"@evolu/typescript-config": "0.1.
|
|
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.
|
|
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.
|
|
109
|
+
"node": ">=24.20.0"
|
|
101
110
|
},
|
|
102
111
|
"publishConfig": {
|
|
103
112
|
"access": "public"
|
package/src/Cli.test.ts
ADDED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
442
|
-
const viteOutput = outputs
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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(
|
|
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;
|
package/src/Time.test.ts
ADDED
|
@@ -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
|
+
});
|