@evolu/vitest 1.0.1 → 2.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 +1 -1
- package/dist/src/BrowserConfig.d.ts +11 -0
- package/dist/src/BrowserConfig.d.ts.map +1 -0
- package/dist/src/BrowserConfig.js +11 -0
- package/package.json +5 -16
- package/src/BrowserConfig.ts +30 -0
- package/dist/src/TestJSDoc.d.ts +0 -75
- package/dist/src/TestJSDoc.d.ts.map +0 -1
- package/dist/src/TestJSDoc.js +0 -376
- package/src/TestJSDoc.ts +0 -574
package/README.md
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare const allBrowserNames: readonly ["chromium", "firefox", "webkit"];
|
|
2
|
+
type BrowserName = (typeof allBrowserNames)[number];
|
|
3
|
+
interface BrowserInstance {
|
|
4
|
+
readonly browser: BrowserName;
|
|
5
|
+
}
|
|
6
|
+
export declare const createBrowserInstances: ({ coverage, mode, }: {
|
|
7
|
+
readonly coverage: boolean;
|
|
8
|
+
readonly mode: string;
|
|
9
|
+
}) => Array<BrowserInstance>;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=BrowserConfig.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrowserConfig.d.ts","sourceRoot":"","sources":["../../src/BrowserConfig.ts"],"names":[],"mappings":"AAAA,QAAA,MAAM,eAAe,YAAI,UAAU,EAAE,SAAS,EAAE,QAAQ,CAAU,CAAC;AAEnE,KAAK,WAAW,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpD,UAAU,eAAe;IACvB,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;CAC/B;AAaD,eAAO,MAAM,sBAAsB,wBAGhC;IACD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,KAAG,KAAK,CAAC,eAAe,CAIU,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const allBrowserNames = ["chromium", "firefox", "webkit"];
|
|
2
|
+
// V8 coverage only works with Chromium.
|
|
3
|
+
const chromiumBrowserNames = ["chromium"];
|
|
4
|
+
const browserNamesByMode = {
|
|
5
|
+
firefox: ["firefox"],
|
|
6
|
+
webkit: ["webkit"],
|
|
7
|
+
"firefox-webkit": ["firefox", "webkit"],
|
|
8
|
+
};
|
|
9
|
+
export const createBrowserInstances = ({ coverage, mode, }) => (coverage
|
|
10
|
+
? chromiumBrowserNames
|
|
11
|
+
: (browserNamesByMode[mode] ?? allBrowserNames)).map((browser) => ({ browser }));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/vitest",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Vitest assertions
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Vitest assertions for Evolu",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"evolu",
|
|
7
7
|
"vitest"
|
|
@@ -22,17 +22,6 @@
|
|
|
22
22
|
".": {
|
|
23
23
|
"types": "./dist/src/index.d.ts",
|
|
24
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
25
|
}
|
|
37
26
|
},
|
|
38
27
|
"files": [
|
|
@@ -41,10 +30,10 @@
|
|
|
41
30
|
"README.md"
|
|
42
31
|
],
|
|
43
32
|
"devDependencies": {
|
|
33
|
+
"@evolu/common": "8.6.0",
|
|
34
|
+
"@evolu/typescript-config": "0.1.0",
|
|
44
35
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
45
|
-
"vitest": "^4.1.
|
|
46
|
-
"@evolu/common": "8.1.0",
|
|
47
|
-
"@evolu/typescript-config": "0.0.2"
|
|
36
|
+
"vitest": "^4.1.11"
|
|
48
37
|
},
|
|
49
38
|
"peerDependencies": {
|
|
50
39
|
"@evolu/common": "^8.0.0",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const allBrowserNames = ["chromium", "firefox", "webkit"] as const;
|
|
2
|
+
|
|
3
|
+
type BrowserName = (typeof allBrowserNames)[number];
|
|
4
|
+
|
|
5
|
+
interface BrowserInstance {
|
|
6
|
+
readonly browser: BrowserName;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// V8 coverage only works with Chromium.
|
|
10
|
+
const chromiumBrowserNames: ReadonlyArray<BrowserName> = ["chromium"];
|
|
11
|
+
|
|
12
|
+
const browserNamesByMode: Readonly<
|
|
13
|
+
Record<string, ReadonlyArray<BrowserName> | undefined>
|
|
14
|
+
> = {
|
|
15
|
+
firefox: ["firefox"],
|
|
16
|
+
webkit: ["webkit"],
|
|
17
|
+
"firefox-webkit": ["firefox", "webkit"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const createBrowserInstances = ({
|
|
21
|
+
coverage,
|
|
22
|
+
mode,
|
|
23
|
+
}: {
|
|
24
|
+
readonly coverage: boolean;
|
|
25
|
+
readonly mode: string;
|
|
26
|
+
}): Array<BrowserInstance> =>
|
|
27
|
+
(coverage
|
|
28
|
+
? chromiumBrowserNames
|
|
29
|
+
: (browserNamesByMode[mode] ?? allBrowserNames)
|
|
30
|
+
).map((browser) => ({ browser }));
|
package/dist/src/TestJSDoc.d.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
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"}
|
package/dist/src/TestJSDoc.js
DELETED
|
@@ -1,376 +0,0 @@
|
|
|
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
|
-
});
|
package/src/TestJSDoc.ts
DELETED
|
@@ -1,574 +0,0 @@
|
|
|
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
|
-
});
|