@evolu/vitest 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Evolu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # Evolu Vitest
2
+
3
+ Vitest assertions and test utilities for Evolu.
4
+
5
+ ## Documentation
6
+
7
+ For detailed information and usage examples, please visit
8
+ [evolu.dev](https://www.evolu.dev).
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Vitest utilities for testing TypeScript examples embedded in JSDoc and
3
+ * Markdown.
4
+ *
5
+ * These utilities use the dedicated `@evolu/vitest/TestJSDoc` entry point so
6
+ * normal `@evolu/vitest` imports do not evaluate Node.js test tooling.
7
+ *
8
+ * @module
9
+ */
10
+ import { type ReadonlyRecord } from "@evolu/common";
11
+ /** Options for compiling and executing TypeScript documentation examples. */
12
+ export interface TestJSDocExamplesOptions {
13
+ /** Source files or glob patterns resolved from `cwd`. */
14
+ readonly include: string | ReadonlyArray<string>;
15
+ /** Package imports redirected to absolute TypeScript entry paths. */
16
+ readonly aliases?: ReadonlyRecord<string, string>;
17
+ /** Directory used for globbing and package resolution. */
18
+ readonly cwd?: string;
19
+ /** TypeScript compiler package. Defaults to `"typescript"`. */
20
+ readonly typescriptPackage?: string;
21
+ }
22
+ /**
23
+ * Compiles and executes every TypeScript example in the included JSDoc comments
24
+ * and Markdown files.
25
+ *
26
+ * Examples are compiled together as isolated TypeScript modules. Examples
27
+ * without compilation errors are then run concurrently, bounded by the CPU
28
+ * parallelism available to the process. Compilation and execution failures are
29
+ * reported together. Vitest's `assert`, `expect`, and `expectTypeOf`, along
30
+ * with Evolu's `expectOk` and `expectErr`, are injected into each module.
31
+ *
32
+ * Write every TypeScript fence as a standalone, deterministic example and
33
+ * import its dependencies. Use `expectTypeOf` to prove static contracts and an
34
+ * appropriate runtime assertion such as `expect`, `expectOk`, or `expectErr`
35
+ * instead of describing expected behavior only in comments.
36
+ *
37
+ * Package aliases are useful for examples documenting an entry point that is
38
+ * not exported yet. Package subpaths can be aliased independently. Each alias
39
+ * target must be an absolute TypeScript module path exposing the named exports
40
+ * used by the example.
41
+ *
42
+ * ### Example
43
+ *
44
+ * ```ts
45
+ * import { testJSDocExamples } from "@evolu/vitest/TestJSDoc";
46
+ * import { mkdtemp, rm, writeFile } from "node:fs/promises";
47
+ * import { tmpdir } from "node:os";
48
+ * import { join } from "node:path";
49
+ *
50
+ * const directory = await mkdtemp(join(tmpdir(), "evolu-jsdoc-example-"));
51
+ * try {
52
+ * const sourcePath = join(directory, "Example.ts");
53
+ * await writeFile(
54
+ * sourcePath,
55
+ * [
56
+ * "/**",
57
+ * " * ``" + "`ts",
58
+ * " * expect(1 + 1).toBe(2);",
59
+ * " * ``" + "`",
60
+ * " *" + "/",
61
+ * "export {};",
62
+ * ].join("\n"),
63
+ * );
64
+ * await testJSDocExamples({
65
+ * cwd: process.cwd(),
66
+ * include: sourcePath,
67
+ * typescriptPackage: "@typescript/native",
68
+ * });
69
+ * } finally {
70
+ * await rm(directory, { force: true, recursive: true });
71
+ * }
72
+ * ```
73
+ */
74
+ export declare const testJSDocExamples: ({ include, aliases, cwd, typescriptPackage, }: TestJSDocExamplesOptions) => Promise<void>;
75
+ //# sourceMappingURL=TestJSDoc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TestJSDoc.d.ts","sourceRoot":"","sources":["../../src/TestJSDoc.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAOL,KAAK,cAAc,EAGpB,MAAM,eAAe,CAAC;AAkBvB,6EAA6E;AAC7E,MAAM,WAAW,wBAAwB;IACvC,yDAAyD;IACzD,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IACjD,qEAAqE;IACrE,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClD,0DAA0D;IAC1D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACrC;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,iBAAiB,kDAK3B,wBAAwB,KAAG,OAAO,CAAC,IAAI,CAsIzC,CAAC"}
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Vitest utilities for testing TypeScript examples embedded in JSDoc and
3
+ * Markdown.
4
+ *
5
+ * These utilities use the dedicated `@evolu/vitest/TestJSDoc` entry point so
6
+ * normal `@evolu/vitest` imports do not evaluate Node.js test tooling.
7
+ *
8
+ * @module
9
+ */
10
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
11
+ if (value !== null && value !== void 0) {
12
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
13
+ var dispose, inner;
14
+ if (async) {
15
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
16
+ dispose = value[Symbol.asyncDispose];
17
+ }
18
+ if (dispose === void 0) {
19
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
20
+ dispose = value[Symbol.dispose];
21
+ if (async) inner = dispose;
22
+ }
23
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
24
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
25
+ env.stack.push({ value: value, dispose: dispose, async: async });
26
+ }
27
+ else if (async) {
28
+ env.stack.push({ async: true });
29
+ }
30
+ return value;
31
+ };
32
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
33
+ return function (env) {
34
+ function fail(e) {
35
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
36
+ env.hasError = true;
37
+ }
38
+ var r, s = 0;
39
+ function next() {
40
+ while (r = env.stack.pop()) {
41
+ try {
42
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
43
+ if (r.dispose) {
44
+ var result = r.dispose.call(r.value);
45
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
46
+ }
47
+ else s |= 1;
48
+ }
49
+ catch (e) {
50
+ fail(e);
51
+ }
52
+ }
53
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
54
+ if (env.hasError) throw env.error;
55
+ }
56
+ return next();
57
+ };
58
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
59
+ var e = new Error(message);
60
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
61
+ });
62
+ import { allSettled, createRun, filterArray, isErr, mapArray, PositiveInt, safelyStringifyUnknownValue, tryAsync, } from "@evolu/common";
63
+ import assert from "node:assert/strict";
64
+ import { spawn } from "node:child_process";
65
+ import { globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
66
+ import { findPackageJSON } from "node:module";
67
+ import { availableParallelism } from "node:os";
68
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
69
+ import { fileURLToPath, pathToFileURL } from "node:url";
70
+ import { stripVTControlCharacters } from "node:util";
71
+ const jsdocPattern = /\/\*\*[\s\S]*?\*\//g;
72
+ const fencePattern = /(?:```|~~~)([^\n]*)\n([\s\S]*?)(?:(```|~~~)|$)/g;
73
+ const markdownFilePattern = /\.mdx?$/i;
74
+ const packageNamePattern = /^(?:@[a-z\d][a-z\d._~-]*\/)?[a-z\d][a-z\d._~-]*$/i;
75
+ const packageSubpathSegmentPattern = /^[a-z\d][a-z\d._~-]*$/i;
76
+ const polyfillsPath = fileURLToPath(import.meta.resolve("@evolu/common/polyfills"));
77
+ /**
78
+ * Compiles and executes every TypeScript example in the included JSDoc comments
79
+ * and Markdown files.
80
+ *
81
+ * Examples are compiled together as isolated TypeScript modules. Examples
82
+ * without compilation errors are then run concurrently, bounded by the CPU
83
+ * parallelism available to the process. Compilation and execution failures are
84
+ * reported together. Vitest's `assert`, `expect`, and `expectTypeOf`, along
85
+ * with Evolu's `expectOk` and `expectErr`, are injected into each module.
86
+ *
87
+ * Write every TypeScript fence as a standalone, deterministic example and
88
+ * import its dependencies. Use `expectTypeOf` to prove static contracts and an
89
+ * appropriate runtime assertion such as `expect`, `expectOk`, or `expectErr`
90
+ * instead of describing expected behavior only in comments.
91
+ *
92
+ * Package aliases are useful for examples documenting an entry point that is
93
+ * not exported yet. Package subpaths can be aliased independently. Each alias
94
+ * target must be an absolute TypeScript module path exposing the named exports
95
+ * used by the example.
96
+ *
97
+ * ### Example
98
+ *
99
+ * ```ts
100
+ * import { testJSDocExamples } from "@evolu/vitest/TestJSDoc";
101
+ * import { mkdtemp, rm, writeFile } from "node:fs/promises";
102
+ * import { tmpdir } from "node:os";
103
+ * import { join } from "node:path";
104
+ *
105
+ * const directory = await mkdtemp(join(tmpdir(), "evolu-jsdoc-example-"));
106
+ * try {
107
+ * const sourcePath = join(directory, "Example.ts");
108
+ * await writeFile(
109
+ * sourcePath,
110
+ * [
111
+ * "/**",
112
+ * " * ``" + "`ts",
113
+ * " * expect(1 + 1).toBe(2);",
114
+ * " * ``" + "`",
115
+ * " *" + "/",
116
+ * "export {};",
117
+ * ].join("\n"),
118
+ * );
119
+ * await testJSDocExamples({
120
+ * cwd: process.cwd(),
121
+ * include: sourcePath,
122
+ * typescriptPackage: "@typescript/native",
123
+ * });
124
+ * } finally {
125
+ * await rm(directory, { force: true, recursive: true });
126
+ * }
127
+ * ```
128
+ */
129
+ export const testJSDocExamples = async ({ include, aliases = {}, cwd = process.cwd(), typescriptPackage = "typescript", }) => {
130
+ const workingDirectory = realpathSync(resolve(cwd));
131
+ const patterns = typeof include === "string" ? [include] : include;
132
+ assert(patterns.length > 0, "Documentation example tests require included files.");
133
+ const filePaths = Array.from(new Set(globSync(patterns, { cwd: workingDirectory }).map((filePath) => resolve(workingDirectory, filePath)))).sort();
134
+ assert(filePaths.length > 0, "No files matched the documentation example patterns.");
135
+ const examples = filePaths.flatMap((filePath) => extractDocumentationExamples(readFileSync(filePath, "utf8"), filePath));
136
+ assert(examples.length > 0, "No TypeScript documentation examples were found.");
137
+ const compilerPath = resolveTypeScriptCompiler(workingDirectory, typescriptPackage);
138
+ const temporaryRoot = join(workingDirectory, "tmp");
139
+ mkdirSync(temporaryRoot, { recursive: true });
140
+ const temporaryDirectory = mkdtempSync(join(temporaryRoot, "evolu-jsdoc-"));
141
+ try {
142
+ writeFileSync(join(temporaryDirectory, "package.json"), JSON.stringify({ type: "module" }));
143
+ createPackageAliases(temporaryDirectory, aliases);
144
+ const generatedExamples = examples.map((example, index) => {
145
+ const generatedPath = join(temporaryDirectory, `example-${index}.ts`);
146
+ writeFileSync(generatedPath, transformJSDocExample(example, generatedPath));
147
+ return { ...example, generatedPath };
148
+ });
149
+ // TODO: Lint generated examples with Oxc after the repository migration.
150
+ // Treat top-level declarations as example output and ignore `using`
151
+ // bindings whose disposal is their purpose.
152
+ const compilerConfigPath = join(temporaryDirectory, "tsconfig.json");
153
+ writeFileSync(compilerConfigPath, JSON.stringify({
154
+ compilerOptions: {
155
+ allowImportingTsExtensions: true,
156
+ erasableSyntaxOnly: true,
157
+ esModuleInterop: true,
158
+ exactOptionalPropertyTypes: true,
159
+ lib: ["DOM", "ESNext"],
160
+ module: "NodeNext",
161
+ moduleResolution: "NodeNext",
162
+ noEmit: true,
163
+ skipLibCheck: true,
164
+ strict: true,
165
+ target: "ES2022",
166
+ types: ["node"],
167
+ verbatimModuleSyntax: true,
168
+ },
169
+ files: generatedExamples.map(({ generatedPath }) => generatedPath),
170
+ }));
171
+ let compilationError;
172
+ try {
173
+ await runProcess(process.execPath, [compilerPath, "--project", compilerConfigPath], workingDirectory, "Documentation example TypeScript compilation", [
174
+ "Generated example sources:",
175
+ ...generatedExamples.map(({ filePath, generatedPath, line }) => `- ${relative(temporaryDirectory, generatedPath)}: ${relative(workingDirectory, filePath)}:${line}`),
176
+ ].join("\n"));
177
+ }
178
+ catch (error) {
179
+ compilationError =
180
+ error instanceof Error
181
+ ? error
182
+ : new Error(safelyStringifyUnknownValue(error));
183
+ }
184
+ const runnableExamples = compilationError === undefined
185
+ ? generatedExamples
186
+ : getExamplesWithoutCompilationErrors(generatedExamples, compilationError, workingDirectory);
187
+ let executionError;
188
+ try {
189
+ await runJSDocExamples(runnableExamples, workingDirectory);
190
+ }
191
+ catch (error) {
192
+ executionError =
193
+ error instanceof Error
194
+ ? error
195
+ : new Error(safelyStringifyUnknownValue(error));
196
+ }
197
+ if (compilationError !== undefined && executionError !== undefined) {
198
+ const executionErrors = executionError instanceof AggregateError
199
+ ? executionError.errors
200
+ : [executionError];
201
+ throw new AggregateError([compilationError, ...executionErrors], `${compilationError.message}\n${executionError.message}`);
202
+ }
203
+ if (compilationError !== undefined)
204
+ throw compilationError;
205
+ if (executionError !== undefined)
206
+ throw executionError;
207
+ }
208
+ finally {
209
+ rmSync(temporaryDirectory, { force: true, recursive: true });
210
+ }
211
+ };
212
+ const getExamplesWithoutCompilationErrors = (examples, compilationError, workingDirectory) => {
213
+ const compilationErrorMessage = stripVTControlCharacters(compilationError.message);
214
+ const examplesWithErrors = examples.filter(({ generatedPath }) => [generatedPath, relative(workingDirectory, generatedPath)].some((path) => [`${path}(`, `${path}:`].some((segment) => compilationErrorMessage.includes(segment))));
215
+ if (examplesWithErrors.length === 0)
216
+ return [];
217
+ return examples.filter((example) => !examplesWithErrors.includes(example));
218
+ };
219
+ const extractDocumentationExamples = (source, filePath) => {
220
+ if (markdownFilePattern.test(filePath)) {
221
+ return extractFencedExamples(source, source, filePath, 0, false);
222
+ }
223
+ const examples = [];
224
+ for (const jsdoc of source.matchAll(jsdocPattern)) {
225
+ examples.push(...extractFencedExamples(source, jsdoc[0], filePath, jsdoc.index, true));
226
+ }
227
+ return examples;
228
+ };
229
+ const extractFencedExamples = (source, fencedSource, filePath, offset, stripJSDocPrefixes) => {
230
+ const examples = [];
231
+ for (const fence of fencedSource.matchAll(fencePattern)) {
232
+ const metadata = fence[1].trim().toLowerCase().split(/\s+/);
233
+ if (!["ts", "typescript"].includes(metadata[0])) {
234
+ continue;
235
+ }
236
+ const line = getLineNumber(source, offset + fence.index);
237
+ const closingFence = fence[3];
238
+ assert(closingFence !== undefined, `${filePath}:${line} has an unclosed TypeScript example fence.`);
239
+ const exampleSource = (stripJSDocPrefixes ? fence[2].replace(/^[ \t]*\* ?/gm, "") : fence[2]).trim();
240
+ assert(exampleSource.length > 0, `${filePath}:${line} has an empty TypeScript example.`);
241
+ examples.push({ filePath, line, source: exampleSource });
242
+ }
243
+ return examples;
244
+ };
245
+ const getLineNumber = (source, offset) => {
246
+ let line = 1;
247
+ for (let index = 0; index < offset; index++) {
248
+ if (source.charCodeAt(index) === 10)
249
+ line++;
250
+ }
251
+ return line;
252
+ };
253
+ const transformJSDocExample = (example, generatedPath) => [
254
+ `import { installPolyfills } from ${JSON.stringify(pathToImportSpecifier(generatedPath, polyfillsPath))};`,
255
+ 'import { expectErr, expectOk } from "@evolu/vitest";',
256
+ 'import { assert, expect, expectTypeOf } from "vitest";',
257
+ "installPolyfills();",
258
+ example.source,
259
+ ].join("\n\n");
260
+ const resolveTypeScriptCompiler = (workingDirectory, typescriptPackage) => {
261
+ const packagePath = findPackageJSON(typescriptPackage, pathToFileURL(join(workingDirectory, "package.json")));
262
+ assert(packagePath !== undefined, `Cannot resolve ${typescriptPackage} from ${workingDirectory}.`);
263
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
264
+ const compiler = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin.tsc;
265
+ assert(compiler !== undefined, `${typescriptPackage} does not expose a tsc executable.`);
266
+ return resolve(dirname(packagePath), compiler);
267
+ };
268
+ const runJSDocExamples = async (examples, workingDirectory) => {
269
+ const env_1 = { stack: [], error: void 0, hasError: false };
270
+ try {
271
+ const run = __addDisposableResource(env_1, createRun(), true);
272
+ const results = await run.ok(allSettled(examples, (example) => async (run) => tryAsync(() => runProcess(process.execPath, [example.generatedPath], workingDirectory, "Node.js execution"), (error) => {
273
+ run.signal.throwIfAborted();
274
+ return { error, example };
275
+ }), { concurrency: PositiveInt.orThrow(availableParallelism()) }));
276
+ const failures = filterArray(results, isErr);
277
+ if (failures.length === 0)
278
+ return;
279
+ const errors = mapArray(failures, ({ error: { error, example: { filePath, line }, }, }) => {
280
+ const message = error instanceof Error
281
+ ? error.message
282
+ : safelyStringifyUnknownValue(error);
283
+ return new Error(`${relative(workingDirectory, filePath)}:${line}: ${message}`, { cause: error });
284
+ });
285
+ throw new AggregateError(errors, [
286
+ "JSDoc example execution failed.",
287
+ ...mapArray(errors, (error) => `- ${error.message}`),
288
+ ].join("\n"));
289
+ }
290
+ catch (e_1) {
291
+ env_1.error = e_1;
292
+ env_1.hasError = true;
293
+ }
294
+ finally {
295
+ const result_1 = __disposeResources(env_1);
296
+ if (result_1)
297
+ await result_1;
298
+ }
299
+ };
300
+ const createPackageAliases = (temporaryDirectory, aliases) => {
301
+ const aliasesByPackageName = new Map();
302
+ for (const [name, target] of Object.entries(aliases)) {
303
+ assert(isAbsolute(target), `Package alias ${name} must be absolute.`);
304
+ const segments = name.split("/");
305
+ const packageSegmentCount = name.startsWith("@") ? 2 : 1;
306
+ const packageName = segments.slice(0, packageSegmentCount).join("/");
307
+ const subpathSegments = segments.slice(packageSegmentCount);
308
+ assert(packageNamePattern.test(packageName) &&
309
+ subpathSegments.every((segment) => packageSubpathSegmentPattern.test(segment)), `Invalid package alias: ${name}.`);
310
+ const subpath = subpathSegments.join("/");
311
+ const packageAliases = aliasesByPackageName.get(packageName) ?? [];
312
+ packageAliases.push({ subpath, targetPath: realpathSync(target) });
313
+ aliasesByPackageName.set(packageName, packageAliases);
314
+ }
315
+ for (const [packageName, packageAliases] of aliasesByPackageName) {
316
+ const packageSegments = packageName.split("/");
317
+ const packageDirectory = join(temporaryDirectory, "node_modules", ...packageSegments);
318
+ mkdirSync(packageDirectory, { recursive: true });
319
+ const exportsBySubpath = {};
320
+ for (const { subpath, targetPath } of packageAliases) {
321
+ const entryPathWithoutExtension = subpath || "index";
322
+ const exportName = subpath ? `./${subpath}` : ".";
323
+ exportsBySubpath[exportName] = {
324
+ types: `./${entryPathWithoutExtension}.ts`,
325
+ default: `./${entryPathWithoutExtension}.js`,
326
+ };
327
+ for (const extension of ["ts", "js"]) {
328
+ const entryPath = join(packageDirectory, `${entryPathWithoutExtension}.${extension}`);
329
+ mkdirSync(dirname(entryPath), { recursive: true });
330
+ writeFileSync(entryPath, `export * from ${JSON.stringify(pathToImportSpecifier(entryPath, targetPath))};\n`);
331
+ }
332
+ }
333
+ writeFileSync(join(packageDirectory, "package.json"), JSON.stringify({
334
+ name: packageName,
335
+ type: "module",
336
+ exports: exportsBySubpath,
337
+ }));
338
+ }
339
+ };
340
+ const pathToImportSpecifier = (from, to) => {
341
+ const path = relative(dirname(from), to).split(sep).join("/");
342
+ return path.startsWith(".") ? path : `./${path}`;
343
+ };
344
+ const runProcess = (command, args, cwd, operation, details) => new Promise((resolve, reject) => {
345
+ const child = spawn(command, args, {
346
+ cwd,
347
+ stdio: ["ignore", "pipe", "pipe"],
348
+ });
349
+ const stdout = [];
350
+ const stderr = [];
351
+ child.stdout.on("data", (chunk) => {
352
+ stdout.push(chunk);
353
+ });
354
+ child.stderr.on("data", (chunk) => {
355
+ stderr.push(chunk);
356
+ });
357
+ child.once("error", (error) => {
358
+ reject(new Error([`${operation} failed.`, details].filter(Boolean).join("\n"), {
359
+ cause: error,
360
+ }));
361
+ });
362
+ child.once("close", (code, signal) => {
363
+ if (code === 0) {
364
+ resolve();
365
+ return;
366
+ }
367
+ reject(new Error([
368
+ `${operation} failed${signal === null ? ` with exit code ${String(code)}` : ` from signal ${signal}`}.`,
369
+ details,
370
+ Buffer.concat(stdout).toString("utf8"),
371
+ Buffer.concat(stderr).toString("utf8"),
372
+ ]
373
+ .filter(Boolean)
374
+ .join("\n")));
375
+ });
376
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Vitest assertions for Evolu.
3
+ *
4
+ * @module
5
+ */
6
+ import type { Err, Ok, Result } from "@evolu/common";
7
+ /**
8
+ * Expects an {@link Ok} Result whose value matches the expected value using
9
+ * Vitest's `toEqual`, and narrows the Result.
10
+ *
11
+ * Use `toBe` on the narrowed value when reference identity also matters.
12
+ */
13
+ export declare const expectOk: <R extends Result<unknown, unknown>>(result: R, expectedValue: unknown) => asserts result is Extract<R, Ok<unknown>>;
14
+ /**
15
+ * Expects an {@link Err} Result whose error matches the expected error using
16
+ * Vitest's `toEqual`, and narrows the Result.
17
+ *
18
+ * Use `toBe` on the narrowed error when reference identity also matters.
19
+ */
20
+ export declare const expectErr: <R extends Result<unknown, unknown>>(result: R, expectedError: unknown) => asserts result is Extract<R, Err<unknown>>;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAGrD;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,EAAE,CAAC,CAAC,SAAS,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,EACxD,MAAM,EAAE,CAAC,EACT,aAAa,EAAE,OAAO,KACnB,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAE5C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,SAAS,EAAE,CAAC,CAAC,SAAS,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,EACzD,MAAM,EAAE,CAAC,EACT,aAAa,EAAE,OAAO,KACnB,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,CAE7C,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Vitest assertions for Evolu.
3
+ *
4
+ * @module
5
+ */
6
+ import { expect } from "vitest";
7
+ /**
8
+ * Expects an {@link Ok} Result whose value matches the expected value using
9
+ * Vitest's `toEqual`, and narrows the Result.
10
+ *
11
+ * Use `toBe` on the narrowed value when reference identity also matters.
12
+ */
13
+ export const expectOk = (result, expectedValue) => {
14
+ expect(result).toEqual({ ok: true, value: expectedValue });
15
+ };
16
+ /**
17
+ * Expects an {@link Err} Result whose error matches the expected error using
18
+ * Vitest's `toEqual`, and narrows the Result.
19
+ *
20
+ * Use `toBe` on the narrowed error when reference identity also matters.
21
+ */
22
+ export const expectErr = (result, expectedError) => {
23
+ expect(result).toEqual({ ok: false, error: expectedError });
24
+ };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@evolu/vitest",
3
+ "version": "1.0.0",
4
+ "description": "Vitest assertions and test utilities for Evolu",
5
+ "keywords": [
6
+ "evolu",
7
+ "vitest"
8
+ ],
9
+ "author": "Daniel Steigerwald <daniel@steigerwald.cz>",
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/evoluhq/evolu.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/evoluhq/evolu/issues"
17
+ },
18
+ "homepage": "https://evolu.dev",
19
+ "type": "module",
20
+ "types": "./dist/src/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/src/index.d.ts",
24
+ "import": "./dist/src/index.js"
25
+ },
26
+ "./TestJSDoc": {
27
+ "types": "./dist/src/TestJSDoc.d.ts",
28
+ "import": "./dist/src/TestJSDoc.js"
29
+ }
30
+ },
31
+ "typesVersions": {
32
+ "*": {
33
+ "TestJSDoc": [
34
+ "./dist/src/TestJSDoc.d.ts"
35
+ ]
36
+ }
37
+ },
38
+ "files": [
39
+ "dist/src/**",
40
+ "src/**",
41
+ "README.md"
42
+ ],
43
+ "devDependencies": {
44
+ "@typescript/native": "npm:typescript@^7.0.2",
45
+ "vitest": "^4.1.2",
46
+ "@evolu/common": "8.0.0",
47
+ "@evolu/typescript-config": "0.0.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@evolu/common": "^8.0.0-next.0",
51
+ "vitest": "^4.1.2"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "engines": {
57
+ "node": ">=24.0.0"
58
+ },
59
+ "sideEffects": false,
60
+ "scripts": {
61
+ "build": "tsc --build tsconfig.build.json",
62
+ "format": "prettier --write \"src/*.{ts,md}\""
63
+ }
64
+ }
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Vitest utilities for testing TypeScript examples embedded in JSDoc and
3
+ * Markdown.
4
+ *
5
+ * These utilities use the dedicated `@evolu/vitest/TestJSDoc` entry point so
6
+ * normal `@evolu/vitest` imports do not evaluate Node.js test tooling.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import {
12
+ allSettled,
13
+ createRun,
14
+ filterArray,
15
+ isErr,
16
+ mapArray,
17
+ PositiveInt,
18
+ type ReadonlyRecord,
19
+ safelyStringifyUnknownValue,
20
+ tryAsync,
21
+ } from "@evolu/common";
22
+ import assert from "node:assert/strict";
23
+ import { spawn } from "node:child_process";
24
+ import {
25
+ globSync,
26
+ mkdirSync,
27
+ mkdtempSync,
28
+ readFileSync,
29
+ realpathSync,
30
+ rmSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { findPackageJSON } from "node:module";
34
+ import { availableParallelism } from "node:os";
35
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
36
+ import { fileURLToPath, pathToFileURL } from "node:url";
37
+ import { stripVTControlCharacters } from "node:util";
38
+
39
+ /** Options for compiling and executing TypeScript documentation examples. */
40
+ export interface TestJSDocExamplesOptions {
41
+ /** Source files or glob patterns resolved from `cwd`. */
42
+ readonly include: string | ReadonlyArray<string>;
43
+ /** Package imports redirected to absolute TypeScript entry paths. */
44
+ readonly aliases?: ReadonlyRecord<string, string>;
45
+ /** Directory used for globbing and package resolution. */
46
+ readonly cwd?: string;
47
+ /** TypeScript compiler package. Defaults to `"typescript"`. */
48
+ readonly typescriptPackage?: string;
49
+ }
50
+
51
+ interface JSDocExample {
52
+ readonly filePath: string;
53
+ readonly line: number;
54
+ readonly source: string;
55
+ }
56
+
57
+ interface GeneratedJSDocExample extends JSDocExample {
58
+ readonly generatedPath: string;
59
+ }
60
+
61
+ interface JSDocExampleFailure {
62
+ readonly error: unknown;
63
+ readonly example: GeneratedJSDocExample;
64
+ }
65
+
66
+ interface TypeScriptPackage {
67
+ readonly bin: string | { readonly tsc?: string };
68
+ }
69
+
70
+ const jsdocPattern = /\/\*\*[\s\S]*?\*\//g;
71
+ const fencePattern = /(?:```|~~~)([^\n]*)\n([\s\S]*?)(?:(```|~~~)|$)/g;
72
+ const markdownFilePattern = /\.mdx?$/i;
73
+ const packageNamePattern = /^(?:@[a-z\d][a-z\d._~-]*\/)?[a-z\d][a-z\d._~-]*$/i;
74
+ const packageSubpathSegmentPattern = /^[a-z\d][a-z\d._~-]*$/i;
75
+ const polyfillsPath = fileURLToPath(
76
+ import.meta.resolve("@evolu/common/polyfills"),
77
+ );
78
+
79
+ /**
80
+ * Compiles and executes every TypeScript example in the included JSDoc comments
81
+ * and Markdown files.
82
+ *
83
+ * Examples are compiled together as isolated TypeScript modules. Examples
84
+ * without compilation errors are then run concurrently, bounded by the CPU
85
+ * parallelism available to the process. Compilation and execution failures are
86
+ * reported together. Vitest's `assert`, `expect`, and `expectTypeOf`, along
87
+ * with Evolu's `expectOk` and `expectErr`, are injected into each module.
88
+ *
89
+ * Write every TypeScript fence as a standalone, deterministic example and
90
+ * import its dependencies. Use `expectTypeOf` to prove static contracts and an
91
+ * appropriate runtime assertion such as `expect`, `expectOk`, or `expectErr`
92
+ * instead of describing expected behavior only in comments.
93
+ *
94
+ * Package aliases are useful for examples documenting an entry point that is
95
+ * not exported yet. Package subpaths can be aliased independently. Each alias
96
+ * target must be an absolute TypeScript module path exposing the named exports
97
+ * used by the example.
98
+ *
99
+ * ### Example
100
+ *
101
+ * ```ts
102
+ * import { testJSDocExamples } from "@evolu/vitest/TestJSDoc";
103
+ * import { mkdtemp, rm, writeFile } from "node:fs/promises";
104
+ * import { tmpdir } from "node:os";
105
+ * import { join } from "node:path";
106
+ *
107
+ * const directory = await mkdtemp(join(tmpdir(), "evolu-jsdoc-example-"));
108
+ * try {
109
+ * const sourcePath = join(directory, "Example.ts");
110
+ * await writeFile(
111
+ * sourcePath,
112
+ * [
113
+ * "/**",
114
+ * " * ``" + "`ts",
115
+ * " * expect(1 + 1).toBe(2);",
116
+ * " * ``" + "`",
117
+ * " *" + "/",
118
+ * "export {};",
119
+ * ].join("\n"),
120
+ * );
121
+ * await testJSDocExamples({
122
+ * cwd: process.cwd(),
123
+ * include: sourcePath,
124
+ * typescriptPackage: "@typescript/native",
125
+ * });
126
+ * } finally {
127
+ * await rm(directory, { force: true, recursive: true });
128
+ * }
129
+ * ```
130
+ */
131
+ export const testJSDocExamples = async ({
132
+ include,
133
+ aliases = {},
134
+ cwd = process.cwd(),
135
+ typescriptPackage = "typescript",
136
+ }: TestJSDocExamplesOptions): Promise<void> => {
137
+ const workingDirectory = realpathSync(resolve(cwd));
138
+ const patterns = typeof include === "string" ? [include] : include;
139
+ assert(
140
+ patterns.length > 0,
141
+ "Documentation example tests require included files.",
142
+ );
143
+
144
+ const filePaths = Array.from(
145
+ new Set(
146
+ globSync(patterns, { cwd: workingDirectory }).map((filePath) =>
147
+ resolve(workingDirectory, filePath),
148
+ ),
149
+ ),
150
+ ).sort();
151
+ assert(
152
+ filePaths.length > 0,
153
+ "No files matched the documentation example patterns.",
154
+ );
155
+
156
+ const examples = filePaths.flatMap((filePath) =>
157
+ extractDocumentationExamples(readFileSync(filePath, "utf8"), filePath),
158
+ );
159
+ assert(
160
+ examples.length > 0,
161
+ "No TypeScript documentation examples were found.",
162
+ );
163
+
164
+ const compilerPath = resolveTypeScriptCompiler(
165
+ workingDirectory,
166
+ typescriptPackage,
167
+ );
168
+ const temporaryRoot = join(workingDirectory, "tmp");
169
+ mkdirSync(temporaryRoot, { recursive: true });
170
+
171
+ const temporaryDirectory = mkdtempSync(join(temporaryRoot, "evolu-jsdoc-"));
172
+
173
+ try {
174
+ writeFileSync(
175
+ join(temporaryDirectory, "package.json"),
176
+ JSON.stringify({ type: "module" }),
177
+ );
178
+ createPackageAliases(temporaryDirectory, aliases);
179
+
180
+ const generatedExamples = examples.map((example, index) => {
181
+ const generatedPath = join(temporaryDirectory, `example-${index}.ts`);
182
+ writeFileSync(
183
+ generatedPath,
184
+ transformJSDocExample(example, generatedPath),
185
+ );
186
+ return { ...example, generatedPath } satisfies GeneratedJSDocExample;
187
+ });
188
+ // TODO: Lint generated examples with Oxc after the repository migration.
189
+ // Treat top-level declarations as example output and ignore `using`
190
+ // bindings whose disposal is their purpose.
191
+ const compilerConfigPath = join(temporaryDirectory, "tsconfig.json");
192
+ writeFileSync(
193
+ compilerConfigPath,
194
+ JSON.stringify({
195
+ compilerOptions: {
196
+ allowImportingTsExtensions: true,
197
+ erasableSyntaxOnly: true,
198
+ esModuleInterop: true,
199
+ exactOptionalPropertyTypes: true,
200
+ lib: ["DOM", "ESNext"],
201
+ module: "NodeNext",
202
+ moduleResolution: "NodeNext",
203
+ noEmit: true,
204
+ skipLibCheck: true,
205
+ strict: true,
206
+ target: "ES2022",
207
+ types: ["node"],
208
+ verbatimModuleSyntax: true,
209
+ },
210
+ files: generatedExamples.map(({ generatedPath }) => generatedPath),
211
+ }),
212
+ );
213
+
214
+ let compilationError: Error | undefined;
215
+ try {
216
+ await runProcess(
217
+ process.execPath,
218
+ [compilerPath, "--project", compilerConfigPath],
219
+ workingDirectory,
220
+ "Documentation example TypeScript compilation",
221
+ [
222
+ "Generated example sources:",
223
+ ...generatedExamples.map(
224
+ ({ filePath, generatedPath, line }) =>
225
+ `- ${relative(temporaryDirectory, generatedPath)}: ${relative(workingDirectory, filePath)}:${line}`,
226
+ ),
227
+ ].join("\n"),
228
+ );
229
+ } catch (error) {
230
+ compilationError =
231
+ error instanceof Error
232
+ ? error
233
+ : new Error(safelyStringifyUnknownValue(error));
234
+ }
235
+
236
+ const runnableExamples =
237
+ compilationError === undefined
238
+ ? generatedExamples
239
+ : getExamplesWithoutCompilationErrors(
240
+ generatedExamples,
241
+ compilationError,
242
+ workingDirectory,
243
+ );
244
+
245
+ let executionError: Error | undefined;
246
+ try {
247
+ await runJSDocExamples(runnableExamples, workingDirectory);
248
+ } catch (error) {
249
+ executionError =
250
+ error instanceof Error
251
+ ? error
252
+ : new Error(safelyStringifyUnknownValue(error));
253
+ }
254
+
255
+ if (compilationError !== undefined && executionError !== undefined) {
256
+ const executionErrors: ReadonlyArray<unknown> =
257
+ executionError instanceof AggregateError
258
+ ? (executionError.errors as ReadonlyArray<unknown>)
259
+ : [executionError];
260
+ throw new AggregateError(
261
+ [compilationError, ...executionErrors],
262
+ `${compilationError.message}\n${executionError.message}`,
263
+ );
264
+ }
265
+ if (compilationError !== undefined) throw compilationError;
266
+ if (executionError !== undefined) throw executionError;
267
+ } finally {
268
+ rmSync(temporaryDirectory, { force: true, recursive: true });
269
+ }
270
+ };
271
+
272
+ const getExamplesWithoutCompilationErrors = (
273
+ examples: ReadonlyArray<GeneratedJSDocExample>,
274
+ compilationError: Error,
275
+ workingDirectory: string,
276
+ ): ReadonlyArray<GeneratedJSDocExample> => {
277
+ const compilationErrorMessage = stripVTControlCharacters(
278
+ compilationError.message,
279
+ );
280
+ const examplesWithErrors = examples.filter(({ generatedPath }) =>
281
+ [generatedPath, relative(workingDirectory, generatedPath)].some((path) =>
282
+ [`${path}(`, `${path}:`].some((segment) =>
283
+ compilationErrorMessage.includes(segment),
284
+ ),
285
+ ),
286
+ );
287
+
288
+ if (examplesWithErrors.length === 0) return [];
289
+
290
+ return examples.filter((example) => !examplesWithErrors.includes(example));
291
+ };
292
+
293
+ const extractDocumentationExamples = (
294
+ source: string,
295
+ filePath: string,
296
+ ): ReadonlyArray<JSDocExample> => {
297
+ if (markdownFilePattern.test(filePath)) {
298
+ return extractFencedExamples(source, source, filePath, 0, false);
299
+ }
300
+
301
+ const examples: Array<JSDocExample> = [];
302
+ for (const jsdoc of source.matchAll(jsdocPattern)) {
303
+ examples.push(
304
+ ...extractFencedExamples(source, jsdoc[0], filePath, jsdoc.index, true),
305
+ );
306
+ }
307
+
308
+ return examples;
309
+ };
310
+
311
+ const extractFencedExamples = (
312
+ source: string,
313
+ fencedSource: string,
314
+ filePath: string,
315
+ offset: number,
316
+ stripJSDocPrefixes: boolean,
317
+ ): ReadonlyArray<JSDocExample> => {
318
+ const examples: Array<JSDocExample> = [];
319
+
320
+ for (const fence of fencedSource.matchAll(fencePattern)) {
321
+ const metadata = fence[1].trim().toLowerCase().split(/\s+/);
322
+ if (!["ts", "typescript"].includes(metadata[0])) {
323
+ continue;
324
+ }
325
+
326
+ const line = getLineNumber(source, offset + fence.index);
327
+ const closingFence = fence[3] as string | undefined;
328
+ assert(
329
+ closingFence !== undefined,
330
+ `${filePath}:${line} has an unclosed TypeScript example fence.`,
331
+ );
332
+ const exampleSource = (
333
+ stripJSDocPrefixes ? fence[2].replace(/^[ \t]*\* ?/gm, "") : fence[2]
334
+ ).trim();
335
+ assert(
336
+ exampleSource.length > 0,
337
+ `${filePath}:${line} has an empty TypeScript example.`,
338
+ );
339
+ examples.push({ filePath, line, source: exampleSource });
340
+ }
341
+
342
+ return examples;
343
+ };
344
+
345
+ const getLineNumber = (source: string, offset: number): number => {
346
+ let line = 1;
347
+ for (let index = 0; index < offset; index++) {
348
+ if (source.charCodeAt(index) === 10) line++;
349
+ }
350
+ return line;
351
+ };
352
+
353
+ const transformJSDocExample = (
354
+ example: JSDocExample,
355
+ generatedPath: string,
356
+ ): string =>
357
+ [
358
+ `import { installPolyfills } from ${JSON.stringify(pathToImportSpecifier(generatedPath, polyfillsPath))};`,
359
+ 'import { expectErr, expectOk } from "@evolu/vitest";',
360
+ 'import { assert, expect, expectTypeOf } from "vitest";',
361
+ "installPolyfills();",
362
+ example.source,
363
+ ].join("\n\n");
364
+
365
+ const resolveTypeScriptCompiler = (
366
+ workingDirectory: string,
367
+ typescriptPackage: string,
368
+ ): string => {
369
+ const packagePath = findPackageJSON(
370
+ typescriptPackage,
371
+ pathToFileURL(join(workingDirectory, "package.json")),
372
+ );
373
+ assert(
374
+ packagePath !== undefined,
375
+ `Cannot resolve ${typescriptPackage} from ${workingDirectory}.`,
376
+ );
377
+ const packageJson = JSON.parse(
378
+ readFileSync(packagePath, "utf8"),
379
+ ) as TypeScriptPackage;
380
+ const compiler =
381
+ typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin.tsc;
382
+ assert(
383
+ compiler !== undefined,
384
+ `${typescriptPackage} does not expose a tsc executable.`,
385
+ );
386
+ return resolve(dirname(packagePath), compiler);
387
+ };
388
+
389
+ const runJSDocExamples = async (
390
+ examples: ReadonlyArray<GeneratedJSDocExample>,
391
+ workingDirectory: string,
392
+ ): Promise<void> => {
393
+ await using run = createRun();
394
+ const results = await run.ok(
395
+ allSettled(
396
+ examples,
397
+ (example) => async (run) =>
398
+ tryAsync(
399
+ () =>
400
+ runProcess(
401
+ process.execPath,
402
+ [example.generatedPath],
403
+ workingDirectory,
404
+ "Node.js execution",
405
+ ),
406
+ (error) => {
407
+ run.signal.throwIfAborted();
408
+ return { error, example } satisfies JSDocExampleFailure;
409
+ },
410
+ ),
411
+ { concurrency: PositiveInt.orThrow(availableParallelism()) },
412
+ ),
413
+ );
414
+
415
+ const failures = filterArray(results, isErr);
416
+ if (failures.length === 0) return;
417
+
418
+ const errors = mapArray(
419
+ failures,
420
+ ({
421
+ error: {
422
+ error,
423
+ example: { filePath, line },
424
+ },
425
+ }) => {
426
+ const message =
427
+ error instanceof Error
428
+ ? error.message
429
+ : safelyStringifyUnknownValue(error);
430
+ return new Error(
431
+ `${relative(workingDirectory, filePath)}:${line}: ${message}`,
432
+ { cause: error },
433
+ );
434
+ },
435
+ );
436
+ throw new AggregateError(
437
+ errors,
438
+ [
439
+ "JSDoc example execution failed.",
440
+ ...mapArray(errors, (error) => `- ${error.message}`),
441
+ ].join("\n"),
442
+ );
443
+ };
444
+
445
+ const createPackageAliases = (
446
+ temporaryDirectory: string,
447
+ aliases: ReadonlyRecord<string, string>,
448
+ ): void => {
449
+ const aliasesByPackageName = new Map<
450
+ string,
451
+ Array<{
452
+ readonly subpath: string;
453
+ readonly targetPath: string;
454
+ }>
455
+ >();
456
+
457
+ for (const [name, target] of Object.entries(aliases)) {
458
+ assert(isAbsolute(target), `Package alias ${name} must be absolute.`);
459
+ const segments = name.split("/");
460
+ const packageSegmentCount = name.startsWith("@") ? 2 : 1;
461
+ const packageName = segments.slice(0, packageSegmentCount).join("/");
462
+ const subpathSegments = segments.slice(packageSegmentCount);
463
+ assert(
464
+ packageNamePattern.test(packageName) &&
465
+ subpathSegments.every((segment) =>
466
+ packageSubpathSegmentPattern.test(segment),
467
+ ),
468
+ `Invalid package alias: ${name}.`,
469
+ );
470
+ const subpath = subpathSegments.join("/");
471
+ const packageAliases = aliasesByPackageName.get(packageName) ?? [];
472
+ packageAliases.push({ subpath, targetPath: realpathSync(target) });
473
+ aliasesByPackageName.set(packageName, packageAliases);
474
+ }
475
+
476
+ for (const [packageName, packageAliases] of aliasesByPackageName) {
477
+ const packageSegments = packageName.split("/");
478
+ const packageDirectory = join(
479
+ temporaryDirectory,
480
+ "node_modules",
481
+ ...packageSegments,
482
+ );
483
+ mkdirSync(packageDirectory, { recursive: true });
484
+ const exportsBySubpath: Record<
485
+ string,
486
+ { readonly types: string; readonly default: string }
487
+ > = {};
488
+
489
+ for (const { subpath, targetPath } of packageAliases) {
490
+ const entryPathWithoutExtension = subpath || "index";
491
+ const exportName = subpath ? `./${subpath}` : ".";
492
+ exportsBySubpath[exportName] = {
493
+ types: `./${entryPathWithoutExtension}.ts`,
494
+ default: `./${entryPathWithoutExtension}.js`,
495
+ };
496
+
497
+ for (const extension of ["ts", "js"]) {
498
+ const entryPath = join(
499
+ packageDirectory,
500
+ `${entryPathWithoutExtension}.${extension}`,
501
+ );
502
+ mkdirSync(dirname(entryPath), { recursive: true });
503
+ writeFileSync(
504
+ entryPath,
505
+ `export * from ${JSON.stringify(pathToImportSpecifier(entryPath, targetPath))};\n`,
506
+ );
507
+ }
508
+ }
509
+
510
+ writeFileSync(
511
+ join(packageDirectory, "package.json"),
512
+ JSON.stringify({
513
+ name: packageName,
514
+ type: "module",
515
+ exports: exportsBySubpath,
516
+ }),
517
+ );
518
+ }
519
+ };
520
+
521
+ const pathToImportSpecifier = (from: string, to: string): string => {
522
+ const path = relative(dirname(from), to).split(sep).join("/");
523
+ return path.startsWith(".") ? path : `./${path}`;
524
+ };
525
+
526
+ const runProcess = (
527
+ command: string,
528
+ args: ReadonlyArray<string>,
529
+ cwd: string,
530
+ operation: string,
531
+ details?: string,
532
+ ): Promise<void> =>
533
+ new Promise((resolve, reject) => {
534
+ const child = spawn(command, args, {
535
+ cwd,
536
+ stdio: ["ignore", "pipe", "pipe"],
537
+ });
538
+ const stdout: Array<Buffer> = [];
539
+ const stderr: Array<Buffer> = [];
540
+ child.stdout.on("data", (chunk: Buffer) => {
541
+ stdout.push(chunk);
542
+ });
543
+ child.stderr.on("data", (chunk: Buffer) => {
544
+ stderr.push(chunk);
545
+ });
546
+ child.once("error", (error) => {
547
+ reject(
548
+ new Error(
549
+ [`${operation} failed.`, details].filter(Boolean).join("\n"),
550
+ {
551
+ cause: error,
552
+ },
553
+ ),
554
+ );
555
+ });
556
+ child.once("close", (code, signal) => {
557
+ if (code === 0) {
558
+ resolve();
559
+ return;
560
+ }
561
+ reject(
562
+ new Error(
563
+ [
564
+ `${operation} failed${signal === null ? ` with exit code ${String(code)}` : ` from signal ${signal}`}.`,
565
+ details,
566
+ Buffer.concat(stdout).toString("utf8"),
567
+ Buffer.concat(stderr).toString("utf8"),
568
+ ]
569
+ .filter(Boolean)
570
+ .join("\n"),
571
+ ),
572
+ );
573
+ });
574
+ });
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Vitest assertions for Evolu.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type { Err, Ok, Result } from "@evolu/common";
8
+ import { expect } from "vitest";
9
+
10
+ /**
11
+ * Expects an {@link Ok} Result whose value matches the expected value using
12
+ * Vitest's `toEqual`, and narrows the Result.
13
+ *
14
+ * Use `toBe` on the narrowed value when reference identity also matters.
15
+ */
16
+ export const expectOk: <R extends Result<unknown, unknown>>(
17
+ result: R,
18
+ expectedValue: unknown,
19
+ ) => asserts result is Extract<R, Ok<unknown>> = (result, expectedValue) => {
20
+ expect(result).toEqual({ ok: true, value: expectedValue });
21
+ };
22
+
23
+ /**
24
+ * Expects an {@link Err} Result whose error matches the expected error using
25
+ * Vitest's `toEqual`, and narrows the Result.
26
+ *
27
+ * Use `toBe` on the narrowed error when reference identity also matters.
28
+ */
29
+ export const expectErr: <R extends Result<unknown, unknown>>(
30
+ result: R,
31
+ expectedError: unknown,
32
+ ) => asserts result is Extract<R, Err<unknown>> = (result, expectedError) => {
33
+ expect(result).toEqual({ ok: false, error: expectedError });
34
+ };