@evolu/nodejs 3.0.0-next.3 → 3.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/dist/src/Cli.d.ts +29 -0
- package/dist/src/Cli.d.ts.map +1 -0
- package/dist/src/Cli.js +49 -0
- package/dist/src/Platform.d.ts +15 -0
- package/dist/src/Platform.d.ts.map +1 -0
- package/dist/src/Platform.js +9 -0
- package/dist/src/Sqlite.js +2 -2
- package/dist/src/Task.d.ts +69 -24
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +148 -52
- package/dist/src/TestBundle.d.ts +113 -0
- package/dist/src/TestBundle.d.ts.map +1 -0
- package/dist/src/TestBundle.js +503 -0
- package/dist/src/Time.d.ts +28 -0
- package/dist/src/Time.d.ts.map +1 -0
- package/dist/src/Time.js +25 -0
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +7 -8
- package/dist/src/index.d.ts +13 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -1
- package/dist/src/local-first/Relay.d.ts +13 -20
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +36 -43
- package/package.json +30 -6
- package/src/Cli.ts +78 -0
- package/src/Platform.ts +20 -0
- package/src/Sqlite.ts +2 -2
- package/src/Task.ts +172 -63
- package/src/TestBundle.ts +674 -0
- package/src/Time.ts +55 -0
- package/src/Worker.ts +22 -20
- package/src/index.ts +14 -6
- package/src/local-first/Relay.ts +48 -53
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js utilities for testing production bundles.
|
|
3
|
+
*
|
|
4
|
+
* These utilities use the dedicated `@evolu/nodejs/TestBundle` entry point so
|
|
5
|
+
* normal `@evolu/nodejs` imports do not evaluate the test toolchain, while
|
|
6
|
+
* bundle tests do not evaluate unrelated Evolu Node.js adapters.
|
|
7
|
+
*
|
|
8
|
+
* The {@link testBundle} function loads its optional Webpack and Vite peer
|
|
9
|
+
* dependencies only when called.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
allSettled,
|
|
16
|
+
assert,
|
|
17
|
+
assertType,
|
|
18
|
+
instanceOf,
|
|
19
|
+
createRun,
|
|
20
|
+
durationToMillis,
|
|
21
|
+
escapeRegExp,
|
|
22
|
+
filterArray,
|
|
23
|
+
isErr,
|
|
24
|
+
mapArray,
|
|
25
|
+
type NonEmptyReadonlyArray,
|
|
26
|
+
type PositiveDuration,
|
|
27
|
+
type ReadonlyRecord,
|
|
28
|
+
type Result,
|
|
29
|
+
safelyStringifyUnknownValue,
|
|
30
|
+
String as StringType,
|
|
31
|
+
type Task,
|
|
32
|
+
timeout,
|
|
33
|
+
TimeoutError,
|
|
34
|
+
tryAsync,
|
|
35
|
+
} from "@evolu/common";
|
|
36
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
39
|
+
import { pathToFileURL } from "node:url";
|
|
40
|
+
import { promisify } from "node:util";
|
|
41
|
+
import {
|
|
42
|
+
isMainThread,
|
|
43
|
+
parentPort,
|
|
44
|
+
Worker,
|
|
45
|
+
workerData,
|
|
46
|
+
} from "node:worker_threads";
|
|
47
|
+
import { brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
48
|
+
import { availableParallelism } from "./Platform.ts";
|
|
49
|
+
|
|
50
|
+
/** The input shared by every test bundler adapter. */
|
|
51
|
+
interface TestBundlerOptions {
|
|
52
|
+
/** The original JavaScript or TypeScript fixture. */
|
|
53
|
+
readonly entryPath: string;
|
|
54
|
+
/** A temporary directory owned by the current bundler invocation. */
|
|
55
|
+
readonly outputDirectory: string;
|
|
56
|
+
/** Exact package import aliases shared across bundlers. */
|
|
57
|
+
readonly aliases: ReadonlyRecord<string, string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The production code emitted by a test bundler adapter. */
|
|
61
|
+
interface TestBundlerOutput {
|
|
62
|
+
readonly code: string;
|
|
63
|
+
readonly version: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A production bundler adapter used by {@link testBundle}. */
|
|
67
|
+
interface TestBundler {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
readonly bundle: (options: TestBundlerOptions) => Promise<TestBundlerOutput>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A failure correlated with the bundler that produced it. */
|
|
73
|
+
interface TestBundlerFailure {
|
|
74
|
+
readonly caseName: string;
|
|
75
|
+
readonly bundler: string;
|
|
76
|
+
readonly error: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The measured sizes of one bundle. */
|
|
80
|
+
export interface TestBundleSize {
|
|
81
|
+
readonly rawSizeInBytes: number;
|
|
82
|
+
/** The Brotli-compressed size using quality 11. */
|
|
83
|
+
readonly brotliSizeInBytes: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Bundle sizes keyed by bundler name and version. */
|
|
87
|
+
export type TestBundleCaseResult = ReadonlyRecord<string, TestBundleSize>;
|
|
88
|
+
|
|
89
|
+
/** Case results keyed by case name for size snapshots. */
|
|
90
|
+
export type TestBundleResult = ReadonlyRecord<string, TestBundleCaseResult>;
|
|
91
|
+
|
|
92
|
+
/** The measured output supplied to {@link TestBundleCase.verify}. */
|
|
93
|
+
export interface TestBundle extends TestBundleSize {
|
|
94
|
+
/** The bundler name and version, for example `"vite@8.1.5"`. */
|
|
95
|
+
readonly bundler: string;
|
|
96
|
+
readonly code: string;
|
|
97
|
+
/** The copied artifact, or `null` when no output directory was requested. */
|
|
98
|
+
readonly outputPath: string | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One named bundle test case. */
|
|
102
|
+
export interface TestBundleCase {
|
|
103
|
+
/**
|
|
104
|
+
* A JavaScript or erasable-TypeScript entry.
|
|
105
|
+
*
|
|
106
|
+
* TypeScript dependencies are supported. TSX and TypeScript syntax that
|
|
107
|
+
* requires JavaScript generation are not supported by Webpack.
|
|
108
|
+
*/
|
|
109
|
+
readonly entryPath: string;
|
|
110
|
+
/** Verifies the default export returned by each emitted bundle. */
|
|
111
|
+
readonly verify: (value: unknown, bundle: TestBundle) => void | Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Options for producing and verifying equivalent bundles. */
|
|
115
|
+
export interface TestBundleOptions {
|
|
116
|
+
/** Named cases bundled with every supported bundler. */
|
|
117
|
+
readonly cases: ReadonlyRecord<string, TestBundleCase>;
|
|
118
|
+
/**
|
|
119
|
+
* Package aliases resolved for every case and bundler. Targets must be
|
|
120
|
+
* absolute paths to modules using JavaScript syntax.
|
|
121
|
+
*/
|
|
122
|
+
readonly aliases?: ReadonlyRecord<string, string>;
|
|
123
|
+
/**
|
|
124
|
+
* Copies emitted bundles into this directory for later inspection. Relative
|
|
125
|
+
* paths are resolved from the current working directory.
|
|
126
|
+
*/
|
|
127
|
+
readonly outputDirectory?: string;
|
|
128
|
+
/** Maximum duration for producing each bundle. Defaults to `"30s"`. */
|
|
129
|
+
readonly bundlingTimeout?: PositiveDuration;
|
|
130
|
+
/** Maximum duration for executing each emitted bundle. Defaults to `"5s"`. */
|
|
131
|
+
readonly timeout?: PositiveDuration;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface TestBundleJobOutput {
|
|
135
|
+
readonly caseName: string;
|
|
136
|
+
readonly bundle: TestBundle;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Bundles, executes, measures, and verifies named cases with every supported
|
|
141
|
+
* bundler.
|
|
142
|
+
*
|
|
143
|
+
* The fixture must default-export either a value, a promise, or a function that
|
|
144
|
+
* returns one. The resolved value must be structured-cloneable so it can cross
|
|
145
|
+
* the worker boundary. All asynchronous work started by the fixture must be
|
|
146
|
+
* awaited by that returned promise; detached work scheduled after it settles is
|
|
147
|
+
* outside the execution contract. Verification runs outside the measured
|
|
148
|
+
* bundle. Each emitted bundle runs in an isolated worker so evaluation errors,
|
|
149
|
+
* rejected promises, uncaught errors and unhandled rejections observed before
|
|
150
|
+
* completion, and timeouts fail the test without affecting the test process.
|
|
151
|
+
* Cases and bundlers form one flattened job list that runs concurrently,
|
|
152
|
+
* bounded by the CPU parallelism available to the process. If multiple jobs
|
|
153
|
+
* fail, all failures are reported together with their case and bundler names.
|
|
154
|
+
* The returned record contains only raw and Brotli byte counts grouped by case
|
|
155
|
+
* and versioned bundler name, so it can be compared directly with an inline
|
|
156
|
+
* snapshot.
|
|
157
|
+
*
|
|
158
|
+
* ### Example
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
162
|
+
* import { tmpdir } from "node:os";
|
|
163
|
+
* import { join } from "node:path";
|
|
164
|
+
* import { testBundle } from "@evolu/nodejs/TestBundle";
|
|
165
|
+
*
|
|
166
|
+
* const directory = await mkdtemp(join(tmpdir(), "evolu-test-bundle-"));
|
|
167
|
+
* try {
|
|
168
|
+
* const entryPath = join(directory, "entry.ts");
|
|
169
|
+
* await writeFile(entryPath, "export default { answer: 42 };\n");
|
|
170
|
+
* const result = await testBundle({
|
|
171
|
+
* cases: {
|
|
172
|
+
* example: {
|
|
173
|
+
* entryPath,
|
|
174
|
+
* verify: (value) => {
|
|
175
|
+
* expect(value).toEqual({ answer: 42 });
|
|
176
|
+
* },
|
|
177
|
+
* },
|
|
178
|
+
* },
|
|
179
|
+
* });
|
|
180
|
+
* for (const size of Object.values(result.example)) {
|
|
181
|
+
* expect(size.brotliSizeInBytes).toBeGreaterThan(0);
|
|
182
|
+
* }
|
|
183
|
+
* } finally {
|
|
184
|
+
* await rm(directory, { recursive: true });
|
|
185
|
+
* }
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export const testBundle = async ({
|
|
189
|
+
cases,
|
|
190
|
+
aliases = {},
|
|
191
|
+
outputDirectory,
|
|
192
|
+
bundlingTimeout = "30s",
|
|
193
|
+
timeout: executionTimeout = "5s",
|
|
194
|
+
}: TestBundleOptions): Promise<TestBundleResult> => {
|
|
195
|
+
const caseEntries = Object.entries(cases);
|
|
196
|
+
assert(caseEntries.length > 0, "Bundle tests require at least one case.");
|
|
197
|
+
|
|
198
|
+
for (const [name, path] of Object.entries(aliases)) {
|
|
199
|
+
assert(
|
|
200
|
+
isAbsolute(path),
|
|
201
|
+
`Bundle alias "${name}" target must be an absolute path.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await using disposer = new AsyncDisposableStack();
|
|
206
|
+
const temporaryDirectory = disposer.adopt(
|
|
207
|
+
await mkdtemp(join(tmpdir(), "evolu-bundle-")),
|
|
208
|
+
async (temporaryDirectory) => {
|
|
209
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const jobs = caseEntries.flatMap(([caseName, testCase]) =>
|
|
214
|
+
testBundlers.map((bundler) => ({ caseName, testCase, bundler })),
|
|
215
|
+
);
|
|
216
|
+
await using run = createRun();
|
|
217
|
+
|
|
218
|
+
const results = await run.ok(
|
|
219
|
+
allSettled(
|
|
220
|
+
jobs,
|
|
221
|
+
({ caseName, testCase, bundler }, jobIndex) =>
|
|
222
|
+
async (run) =>
|
|
223
|
+
tryAsync(
|
|
224
|
+
async () => {
|
|
225
|
+
const sourceEntryPath = resolve(testCase.entryPath);
|
|
226
|
+
const bundlerDirectory = join(
|
|
227
|
+
temporaryDirectory,
|
|
228
|
+
`${jobIndex}-${bundler.name}`,
|
|
229
|
+
);
|
|
230
|
+
await mkdir(bundlerDirectory, { recursive: true });
|
|
231
|
+
|
|
232
|
+
const bundlingResult = await run(
|
|
233
|
+
timeout(
|
|
234
|
+
runTestBundler(bundler.name, {
|
|
235
|
+
entryPath: sourceEntryPath,
|
|
236
|
+
outputDirectory: bundlerDirectory,
|
|
237
|
+
aliases,
|
|
238
|
+
}),
|
|
239
|
+
bundlingTimeout,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
if (!bundlingResult.ok) {
|
|
243
|
+
if (TimeoutError.is(bundlingResult.error)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Bundle production timed out after ${durationToMillis(bundlingTimeout)} ms.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
throw bundlingResult.error;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const output = bundlingResult.value;
|
|
252
|
+
const executablePath = join(bundlerDirectory, "bundle.mjs");
|
|
253
|
+
await writeFile(executablePath, output.code);
|
|
254
|
+
|
|
255
|
+
let persistedOutputPath: string | null = null;
|
|
256
|
+
if (outputDirectory) {
|
|
257
|
+
persistedOutputPath = join(
|
|
258
|
+
outputDirectory,
|
|
259
|
+
`${encodeURIComponent(caseName)}.${bundler.name}.mjs`,
|
|
260
|
+
);
|
|
261
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
262
|
+
await writeFile(persistedOutputPath, output.code);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const bundle: TestBundle = {
|
|
266
|
+
bundler: `${bundler.name}@${output.version}`,
|
|
267
|
+
code: output.code,
|
|
268
|
+
outputPath: persistedOutputPath,
|
|
269
|
+
rawSizeInBytes: Buffer.byteLength(output.code),
|
|
270
|
+
brotliSizeInBytes: brotliCompressSync(output.code, {
|
|
271
|
+
params: {
|
|
272
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 11,
|
|
273
|
+
},
|
|
274
|
+
}).byteLength,
|
|
275
|
+
};
|
|
276
|
+
const executionResult = await run(
|
|
277
|
+
timeout(runTestBundle(executablePath), executionTimeout),
|
|
278
|
+
);
|
|
279
|
+
if (!executionResult.ok) {
|
|
280
|
+
if (TimeoutError.is(executionResult.error)) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Bundle execution timed out after ${durationToMillis(executionTimeout)} ms.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
throw executionResult.error;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
await testCase.verify(executionResult.value, bundle);
|
|
289
|
+
return { caseName, bundle } satisfies TestBundleJobOutput;
|
|
290
|
+
},
|
|
291
|
+
(error) => {
|
|
292
|
+
run.signal.throwIfAborted();
|
|
293
|
+
return {
|
|
294
|
+
caseName,
|
|
295
|
+
bundler: bundler.name,
|
|
296
|
+
error,
|
|
297
|
+
} satisfies TestBundlerFailure;
|
|
298
|
+
},
|
|
299
|
+
),
|
|
300
|
+
{ concurrency: availableParallelism() },
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const failures = filterArray(results, isErr);
|
|
305
|
+
if (failures.length > 0) {
|
|
306
|
+
const errors = mapArray(
|
|
307
|
+
failures,
|
|
308
|
+
({ error: { caseName, bundler, error } }) => {
|
|
309
|
+
const message =
|
|
310
|
+
error instanceof Error
|
|
311
|
+
? error.message
|
|
312
|
+
: safelyStringifyUnknownValue(error);
|
|
313
|
+
return Object.assign(
|
|
314
|
+
new Error(`${caseName} / ${bundler}: ${message}`, { cause: error }),
|
|
315
|
+
{ caseName, bundler },
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
throw new AggregateError(
|
|
320
|
+
errors,
|
|
321
|
+
[
|
|
322
|
+
"Bundle tests failed.",
|
|
323
|
+
...mapArray(errors, (error) => `- ${error.message}`),
|
|
324
|
+
].join("\n"),
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const bundleEntriesByCase = new Map<
|
|
329
|
+
string,
|
|
330
|
+
Array<readonly [string, TestBundleSize]>
|
|
331
|
+
>();
|
|
332
|
+
for (const [caseName] of caseEntries) bundleEntriesByCase.set(caseName, []);
|
|
333
|
+
|
|
334
|
+
for (const result of results) {
|
|
335
|
+
assert(result.ok, "Expected every bundle test to succeed.");
|
|
336
|
+
const { caseName, bundle } = result.value;
|
|
337
|
+
const entries = bundleEntriesByCase.get(caseName);
|
|
338
|
+
assert(entries, `Missing bundle test case "${caseName}".`);
|
|
339
|
+
entries.push([
|
|
340
|
+
bundle.bundler,
|
|
341
|
+
{
|
|
342
|
+
rawSizeInBytes: bundle.rawSizeInBytes,
|
|
343
|
+
brotliSizeInBytes: bundle.brotliSizeInBytes,
|
|
344
|
+
},
|
|
345
|
+
]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return Object.fromEntries(
|
|
349
|
+
Array.from(bundleEntriesByCase, ([caseName, entries]) => [
|
|
350
|
+
caseName,
|
|
351
|
+
Object.fromEntries(entries),
|
|
352
|
+
]),
|
|
353
|
+
);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** A Webpack production adapter for {@link testBundle}. */
|
|
357
|
+
const testWebpackBundler: TestBundler = {
|
|
358
|
+
name: "webpack",
|
|
359
|
+
bundle: async ({ entryPath, outputDirectory, aliases }) => {
|
|
360
|
+
const { default: webpack } = await import("webpack");
|
|
361
|
+
const filename = "webpack.js";
|
|
362
|
+
await using disposer = new AsyncDisposableStack();
|
|
363
|
+
const compiler = disposer.adopt(
|
|
364
|
+
webpack({
|
|
365
|
+
mode: "production",
|
|
366
|
+
target: ["web", "es2020"],
|
|
367
|
+
entry: entryPath,
|
|
368
|
+
experiments: { outputModule: true, typescript: true },
|
|
369
|
+
output: {
|
|
370
|
+
path: outputDirectory,
|
|
371
|
+
filename,
|
|
372
|
+
module: true,
|
|
373
|
+
library: { type: "module" },
|
|
374
|
+
},
|
|
375
|
+
resolve: {
|
|
376
|
+
alias: Object.fromEntries(
|
|
377
|
+
Object.entries(aliases).map(([name, path]) => [`${name}$`, path]),
|
|
378
|
+
),
|
|
379
|
+
},
|
|
380
|
+
optimization: {
|
|
381
|
+
usedExports: true,
|
|
382
|
+
sideEffects: true,
|
|
383
|
+
minimize: true,
|
|
384
|
+
},
|
|
385
|
+
stats: "errors-only",
|
|
386
|
+
}),
|
|
387
|
+
async (compiler) => {
|
|
388
|
+
await promisify(compiler.close.bind(compiler))();
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const stats = await promisify(compiler.run.bind(compiler))();
|
|
393
|
+
|
|
394
|
+
if (stats?.hasErrors()) throw new Error(stats.toString("errors-only"));
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
code: await readFile(join(outputDirectory, filename), "utf8"),
|
|
398
|
+
version: webpack.version,
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/** A Vite production adapter for {@link testBundle}. */
|
|
404
|
+
const testViteBundler: TestBundler = {
|
|
405
|
+
name: "vite",
|
|
406
|
+
bundle: async ({ entryPath, aliases }) => {
|
|
407
|
+
const vite = await import("vite");
|
|
408
|
+
const output = await vite.build({
|
|
409
|
+
root: dirname(entryPath),
|
|
410
|
+
configFile: false,
|
|
411
|
+
envFile: false,
|
|
412
|
+
logLevel: "silent",
|
|
413
|
+
resolve: {
|
|
414
|
+
alias: Object.entries(aliases).map(([name, replacement]) => ({
|
|
415
|
+
find: new RegExp(`^${escapeRegExp(name)}$`),
|
|
416
|
+
replacement,
|
|
417
|
+
})),
|
|
418
|
+
},
|
|
419
|
+
build: {
|
|
420
|
+
write: false,
|
|
421
|
+
minify: true,
|
|
422
|
+
target: "es2020",
|
|
423
|
+
lib: {
|
|
424
|
+
entry: entryPath,
|
|
425
|
+
formats: ["es"],
|
|
426
|
+
fileName: () => "vite.js",
|
|
427
|
+
},
|
|
428
|
+
rolldownOptions: {
|
|
429
|
+
cwd: dirname(entryPath),
|
|
430
|
+
output: { codeSplitting: false, comments: false, minify: true },
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
type ViteOutput = Extract<
|
|
436
|
+
Awaited<ReturnType<typeof vite.build>>,
|
|
437
|
+
{ readonly output: unknown }
|
|
438
|
+
>;
|
|
439
|
+
assert(Array.isArray(output), "Vite did not return build outputs.");
|
|
440
|
+
const outputs: ReadonlyArray<ViteOutput> = output;
|
|
441
|
+
assert(outputs.length === 1, "Vite did not return one build output.");
|
|
442
|
+
const viteOutput = outputs[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[0];
|
|
449
|
+
assert(chunk, "Vite did not emit a JavaScript chunk.");
|
|
450
|
+
assertType(StringType, chunk.code);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
code: chunk.code,
|
|
454
|
+
version: vite.version,
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const testBundlers: NonEmptyReadonlyArray<TestBundler> = [
|
|
460
|
+
testWebpackBundler,
|
|
461
|
+
testViteBundler,
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
type TestBundlerWorkerMessage = Result<
|
|
465
|
+
TestBundlerOutput,
|
|
466
|
+
TestBundleWorkerError
|
|
467
|
+
>;
|
|
468
|
+
|
|
469
|
+
interface TestBundlerWorkerData {
|
|
470
|
+
readonly type: typeof testBundlerWorkerType;
|
|
471
|
+
readonly bundlerName: string;
|
|
472
|
+
readonly options: TestBundlerOptions;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const testBundlerWorkerType = "evolu.test-bundler";
|
|
476
|
+
|
|
477
|
+
const isTestBundlerWorkerData = (
|
|
478
|
+
value: unknown,
|
|
479
|
+
): value is TestBundlerWorkerData =>
|
|
480
|
+
typeof value === "object" &&
|
|
481
|
+
value !== null &&
|
|
482
|
+
"type" in value &&
|
|
483
|
+
value.type === testBundlerWorkerType;
|
|
484
|
+
|
|
485
|
+
const runTestBundlerWorker = async ({
|
|
486
|
+
bundlerName,
|
|
487
|
+
options,
|
|
488
|
+
}: TestBundlerWorkerData): Promise<void> => {
|
|
489
|
+
try {
|
|
490
|
+
const bundler = testBundlers.find(({ name }) => name === bundlerName);
|
|
491
|
+
assert(bundler, `Unknown test bundler "${bundlerName}".`);
|
|
492
|
+
const value = await bundler.bundle(options);
|
|
493
|
+
parentPort?.postMessage({
|
|
494
|
+
ok: true,
|
|
495
|
+
value,
|
|
496
|
+
} satisfies TestBundlerWorkerMessage);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
const normalized =
|
|
499
|
+
error instanceof Error ? error : new Error(String(error));
|
|
500
|
+
parentPort?.postMessage({
|
|
501
|
+
ok: false,
|
|
502
|
+
error: {
|
|
503
|
+
name: normalized.name,
|
|
504
|
+
message: normalized.message,
|
|
505
|
+
stack: normalized.stack ?? `${normalized.name}: ${normalized.message}`,
|
|
506
|
+
},
|
|
507
|
+
} satisfies TestBundlerWorkerMessage);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
if (!isMainThread && isTestBundlerWorkerData(workerData)) {
|
|
512
|
+
void runTestBundlerWorker(workerData);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const runTestBundler =
|
|
516
|
+
(
|
|
517
|
+
bundlerName: string,
|
|
518
|
+
options: TestBundlerOptions,
|
|
519
|
+
): Task<TestBundlerOutput, Error> =>
|
|
520
|
+
async (run) =>
|
|
521
|
+
tryAsync(
|
|
522
|
+
async () => {
|
|
523
|
+
await using worker = new Worker(new URL(import.meta.url), {
|
|
524
|
+
execArgv: process.execArgv.filter(
|
|
525
|
+
(argument, index, execArgv) =>
|
|
526
|
+
argument !== "--input-type" &&
|
|
527
|
+
argument !== "--eval" &&
|
|
528
|
+
argument !== "-e" &&
|
|
529
|
+
argument !== "--print" &&
|
|
530
|
+
argument !== "-p" &&
|
|
531
|
+
!argument.startsWith("--input-type=") &&
|
|
532
|
+
!argument.startsWith("--eval=") &&
|
|
533
|
+
!argument.startsWith("--print=") &&
|
|
534
|
+
!["--input-type", "--eval", "-e", "--print", "-p"].includes(
|
|
535
|
+
execArgv[index - 1] ?? "",
|
|
536
|
+
),
|
|
537
|
+
),
|
|
538
|
+
workerData: {
|
|
539
|
+
type: testBundlerWorkerType,
|
|
540
|
+
bundlerName,
|
|
541
|
+
options,
|
|
542
|
+
} satisfies TestBundlerWorkerData,
|
|
543
|
+
});
|
|
544
|
+
using _ = run.onAbort(() => {
|
|
545
|
+
void worker.terminate();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return await new Promise<TestBundlerOutput>((resolve, reject) => {
|
|
549
|
+
worker.once("message", (message: TestBundlerWorkerMessage) => {
|
|
550
|
+
if (message.ok) {
|
|
551
|
+
resolve(message.value);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
reject(testBundleWorkerErrorToError(message.error));
|
|
555
|
+
});
|
|
556
|
+
worker.once("error", (error) => {
|
|
557
|
+
assertType(ErrorType, error);
|
|
558
|
+
reject(error);
|
|
559
|
+
});
|
|
560
|
+
worker.once("exit", (code) => {
|
|
561
|
+
reject(
|
|
562
|
+
new Error(
|
|
563
|
+
`Test bundler worker exited with code ${code} before returning a value.`,
|
|
564
|
+
),
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
},
|
|
569
|
+
(error) => {
|
|
570
|
+
run.signal.throwIfAborted();
|
|
571
|
+
assertType(ErrorType, error);
|
|
572
|
+
return error;
|
|
573
|
+
},
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
type TestBundleWorkerMessage = Result<unknown, TestBundleWorkerError>;
|
|
577
|
+
|
|
578
|
+
interface TestBundleWorkerError {
|
|
579
|
+
readonly name: string;
|
|
580
|
+
readonly message: string;
|
|
581
|
+
readonly stack: string;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const ErrorType = /*#__PURE__*/ instanceOf(globalThis.Error);
|
|
585
|
+
|
|
586
|
+
// TODO: Replace the evaluated CommonJS worker with an ESM module worker.
|
|
587
|
+
const testBundleWorkerSource = String.raw`
|
|
588
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
589
|
+
|
|
590
|
+
let completed = false;
|
|
591
|
+
|
|
592
|
+
const fail = (error) => {
|
|
593
|
+
if (completed) return;
|
|
594
|
+
completed = true;
|
|
595
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
596
|
+
parentPort.postMessage({
|
|
597
|
+
ok: false,
|
|
598
|
+
error: {
|
|
599
|
+
name: normalized.name,
|
|
600
|
+
message: normalized.message,
|
|
601
|
+
stack: normalized.stack ?? normalized.name + ": " + normalized.message,
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
process.on("unhandledRejection", fail);
|
|
607
|
+
|
|
608
|
+
(async () => {
|
|
609
|
+
const module = await import(workerData);
|
|
610
|
+
if (!("default" in module)) {
|
|
611
|
+
throw new Error("A bundle test fixture must have a default export.");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const exported = module.default;
|
|
615
|
+
const value = await (typeof exported === "function" ? exported() : exported);
|
|
616
|
+
|
|
617
|
+
// Give unhandledRejection one event-loop turn to report detached rejections.
|
|
618
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
619
|
+
if (completed) return;
|
|
620
|
+
// Keep completed false until cloning succeeds so DataCloneError reaches fail.
|
|
621
|
+
parentPort.postMessage({ ok: true, value });
|
|
622
|
+
completed = true;
|
|
623
|
+
})().catch(fail);
|
|
624
|
+
`;
|
|
625
|
+
|
|
626
|
+
const testBundleWorkerErrorToError = (
|
|
627
|
+
workerError: TestBundleWorkerError,
|
|
628
|
+
): Error => {
|
|
629
|
+
const error = new Error(workerError.message);
|
|
630
|
+
error.name = workerError.name;
|
|
631
|
+
error.stack = workerError.stack;
|
|
632
|
+
return error;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const runTestBundle =
|
|
636
|
+
(bundlePath: string): Task<unknown, Error> =>
|
|
637
|
+
async (run) =>
|
|
638
|
+
tryAsync(
|
|
639
|
+
async () => {
|
|
640
|
+
await using worker = new Worker(testBundleWorkerSource, {
|
|
641
|
+
eval: true,
|
|
642
|
+
workerData: pathToFileURL(bundlePath).href,
|
|
643
|
+
});
|
|
644
|
+
using _ = run.onAbort(() => {
|
|
645
|
+
void worker.terminate();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return await new Promise<unknown>((resolve, reject) => {
|
|
649
|
+
worker.once("message", (workerMessage: TestBundleWorkerMessage) => {
|
|
650
|
+
if (workerMessage.ok) {
|
|
651
|
+
resolve(workerMessage.value);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
reject(testBundleWorkerErrorToError(workerMessage.error));
|
|
655
|
+
});
|
|
656
|
+
worker.once("error", (error) => {
|
|
657
|
+
assertType(ErrorType, error);
|
|
658
|
+
reject(error);
|
|
659
|
+
});
|
|
660
|
+
worker.once("exit", (code) => {
|
|
661
|
+
reject(
|
|
662
|
+
new Error(
|
|
663
|
+
`Bundle worker exited with code ${code} before returning a value.`,
|
|
664
|
+
),
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
},
|
|
669
|
+
(error) => {
|
|
670
|
+
run.signal.throwIfAborted();
|
|
671
|
+
assertType(ErrorType, error);
|
|
672
|
+
return error;
|
|
673
|
+
},
|
|
674
|
+
);
|