@evolu/nodejs 3.1.0 → 4.0.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/Fs.d.ts +63 -0
- package/dist/src/Fs.d.ts.map +1 -0
- package/dist/src/Fs.js +217 -0
- package/dist/src/Task.d.ts +51 -38
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +2 -2
- package/dist/src/TestBundle.d.ts.map +1 -1
- package/dist/src/TestBundle.js +9 -12
- package/dist/src/TestJSDoc.d.ts +4 -1
- package/dist/src/TestJSDoc.d.ts.map +1 -1
- package/dist/src/TestJSDoc.js +1 -0
- 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/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/local-first/Relay.d.ts +15 -27
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +10 -25
- package/package.json +17 -8
- package/src/Cli.test.ts +90 -0
- package/src/Fs.test.ts +1019 -0
- package/src/Fs.ts +300 -0
- package/src/Platform.test.ts +32 -0
- package/src/Task.ts +62 -45
- package/src/TestBundle.ts +11 -15
- package/src/TestJSDoc.ts +7 -1
- package/src/TestOverviewReporter.test.ts +219 -0
- package/src/TestOverviewReporter.ts +86 -0
- package/src/Time.test.ts +67 -0
- package/src/index.ts +1 -0
- package/src/local-first/Relay.ts +15 -26
|
@@ -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
|
+
});
|
package/src/index.ts
CHANGED
package/src/local-first/Relay.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Name,
|
|
9
9
|
ok,
|
|
10
10
|
OwnerId,
|
|
11
|
+
Port,
|
|
11
12
|
type RandomDep,
|
|
12
13
|
type Task,
|
|
13
14
|
type TimingSafeEqualDep,
|
|
@@ -33,8 +34,11 @@ import { createTimingSafeEqual } from "../Crypto.ts";
|
|
|
33
34
|
import { createBetterSqliteDriver } from "../Sqlite.ts";
|
|
34
35
|
|
|
35
36
|
export interface NodeJsRelayConfig extends RelayConfig {
|
|
36
|
-
/**
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The HTTP server's {@link Port}. Zero requests an automatically assigned
|
|
39
|
+
* port.
|
|
40
|
+
*/
|
|
41
|
+
readonly port?: Port;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export type RelayDeps = CreateSqliteDriverDep & RandomDep & TimingSafeEqualDep;
|
|
@@ -55,37 +59,22 @@ export const createRelayDeps = (): RelayDeps => ({
|
|
|
55
59
|
* ### Example
|
|
56
60
|
*
|
|
57
61
|
* ```ts
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
62
|
+
* import { assertType, Port, type Task } from "@evolu/common";
|
|
63
|
+
* import type { Relay } from "@evolu/common/local-first";
|
|
64
|
+
* import { createRelay, type RelayDeps } from "@evolu/nodejs";
|
|
61
65
|
*
|
|
62
|
-
* const
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* }),
|
|
66
|
+
* const main = createRelay({
|
|
67
|
+
* port: Port.orThrow(4000),
|
|
68
|
+
* isOwnerWithinQuota: (_ownerId, requiredBytes) =>
|
|
69
|
+
* requiredBytes <= 1024 * 1024,
|
|
67
70
|
* });
|
|
68
71
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* await runMain(deps)(
|
|
72
|
-
* createRelay({
|
|
73
|
-
* port: 4000,
|
|
74
|
-
*
|
|
75
|
-
* // Note: Relay requires URL in format ws://host:port?ownerId=<ownerId>
|
|
76
|
-
* // isOwnerAllowed: (_ownerId, { signal: _signal }) => true,
|
|
77
|
-
*
|
|
78
|
-
* isOwnerWithinQuota: (_ownerId, requiredBytes) => {
|
|
79
|
-
* const maxBytes = 1024 * 1024; // 1MB
|
|
80
|
-
* return requiredBytes <= maxBytes;
|
|
81
|
-
* },
|
|
82
|
-
* }),
|
|
83
|
-
* );
|
|
72
|
+
* assertType<typeof main, Task<Relay, never, RelayDeps>>();
|
|
84
73
|
* ```
|
|
85
74
|
*/
|
|
86
75
|
export const createRelay =
|
|
87
76
|
({
|
|
88
|
-
port = 443,
|
|
77
|
+
port = Port.orThrow(443),
|
|
89
78
|
name = Name.orThrow("evolu-relay"),
|
|
90
79
|
isOwnerAllowed,
|
|
91
80
|
isOwnerWithinQuota,
|