@evolu/nodejs 3.0.1 → 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.
@@ -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
+ });
package/src/Worker.ts CHANGED
@@ -14,6 +14,7 @@ export const createBroadcastChannel: CreateBroadcastChannel = <
14
14
 
15
15
  disposer.defer(() => {
16
16
  disposed = true;
17
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener -- This adapter owns and clears one handler.
17
18
  nativeBroadcastChannel.onmessage = null;
18
19
  nativeBroadcastChannel.close();
19
20
  });
@@ -31,6 +32,7 @@ export const createBroadcastChannel: CreateBroadcastChannel = <
31
32
  set onMessage(fn) {
32
33
  if (disposed) return;
33
34
  onMessageHandler = fn;
35
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener -- This adapter owns and clears one handler.
34
36
  nativeBroadcastChannel.onmessage = fn
35
37
  ? (event: MessageEvent<Output>) => {
36
38
  fn(event.data);
@@ -169,12 +169,11 @@ export const createRelay =
169
169
  // ignores abort. The daemon runs in the root Run, so aborting the
170
170
  // current Run does not make its Fiber wait for the service Promise to
171
171
  // settle.
172
- daemon(
173
- async (run) =>
174
- await tryAsync(
175
- () => isOwnerAllowed(ownerId, { signal: run.signal }),
176
- (error) => ({ type: "OwnerAuthorizationError", error }) as const,
177
- ),
172
+ daemon(async (run) =>
173
+ tryAsync(
174
+ () => isOwnerAllowed(ownerId, { signal: run.signal }),
175
+ (error) => ({ type: "OwnerAuthorizationError", error }) as const,
176
+ ),
178
177
  ),
179
178
  );
180
179
 
@@ -287,7 +286,10 @@ export const createRelay =
287
286
  await once(server, "listening");
288
287
 
289
288
  const address = server.address();
290
- assert(address && typeof address !== "string", "Expected TCP address");
289
+ assert(
290
+ address !== null && typeof address !== "string",
291
+ "Expected TCP address",
292
+ );
291
293
 
292
294
  const disposables = disposer.move();
293
295