readme-assert 7.0.0 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -1
- package/readme.md +11 -2
- package/src/cli.js +47 -28
- package/src/comment-to-assert.js +29 -10
- package/src/extract.js +1 -1
- package/src/generate.js +4 -2
- package/src/run.js +59 -7
package/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "readme-assert",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "Run code blocks in your readme as tests",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"readme",
|
|
7
|
+
"test",
|
|
8
|
+
"testing",
|
|
9
|
+
"documentation",
|
|
10
|
+
"doctest",
|
|
11
|
+
"assert",
|
|
12
|
+
"markdown",
|
|
13
|
+
"code-blocks",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
5
16
|
"type": "module",
|
|
6
17
|
"exports": "./src/index.js",
|
|
7
18
|
"bin": {
|
package/readme.md
CHANGED
|
@@ -71,6 +71,15 @@ const b = () => {
|
|
|
71
71
|
b(); // throws /fail/
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
Or use `//=>` with an error name and optional message to match both:
|
|
75
|
+
|
|
76
|
+
```javascript test
|
|
77
|
+
const c = () => {
|
|
78
|
+
throw new TypeError("bad input");
|
|
79
|
+
};
|
|
80
|
+
c(); //=> TypeError: bad input
|
|
81
|
+
```
|
|
82
|
+
|
|
74
83
|
### console.log
|
|
75
84
|
|
|
76
85
|
Assert console output — the call is preserved and an assertion is added:
|
|
@@ -120,8 +129,8 @@ x; //=> 2
|
|
|
120
129
|
|
|
121
130
|
### Auto-discover mode
|
|
122
131
|
|
|
123
|
-
With `--auto`, any code block containing `//=>`, `// →`,
|
|
124
|
-
is treated as a test — no `test` tag needed.
|
|
132
|
+
With `--auto`, any code block containing `//=>`, `// →`, `// ->`,
|
|
133
|
+
`// throws`, or `// rejects` is treated as a test — no `test` tag needed.
|
|
125
134
|
|
|
126
135
|
### All mode
|
|
127
136
|
|
package/src/cli.js
CHANGED
|
@@ -3,20 +3,28 @@ import { parseArgs } from "node:util";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
6
|
+
let args;
|
|
7
|
+
try {
|
|
8
|
+
({ values: args } = parseArgs({
|
|
9
|
+
options: {
|
|
10
|
+
file: { type: "string", short: "f" },
|
|
11
|
+
main: { type: "string", short: "m" },
|
|
12
|
+
auto: { type: "boolean", short: "a", default: false },
|
|
13
|
+
all: { type: "boolean", short: "l", default: false },
|
|
14
|
+
require: { type: "string", short: "r", multiple: true },
|
|
15
|
+
import: { type: "string", short: "i", multiple: true },
|
|
16
|
+
"print-code": { type: "boolean", short: "p", default: false },
|
|
17
|
+
help: { type: "boolean", short: "h", default: false },
|
|
18
|
+
version: { type: "boolean", short: "v", default: false },
|
|
19
|
+
},
|
|
20
|
+
strict: true,
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
}));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error(err.message);
|
|
25
|
+
console.error("Run with --help to see supported options.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
if (args.help) {
|
|
22
30
|
console.log(`
|
|
@@ -68,20 +76,31 @@ const opts = {
|
|
|
68
76
|
import: args.import,
|
|
69
77
|
};
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
try {
|
|
80
|
+
if (args["print-code"]) {
|
|
81
|
+
const { processMarkdown } = await import("./run.js");
|
|
82
|
+
const units = await processMarkdown(filePath, opts);
|
|
83
|
+
for (const unit of units) {
|
|
84
|
+
console.log(`# --- ${unit.name} ---`);
|
|
85
|
+
console.log(unit.code);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const { run } = await import("./run.js");
|
|
89
|
+
// stream: true pipes each child's stdout to process.stdout live so
|
|
90
|
+
// long-running blocks don't look stalled.
|
|
91
|
+
const { exitCode, stderr, results } = await run(filePath, { ...opts, stream: true });
|
|
92
|
+
if (stderr) process.stderr.write(stderr);
|
|
93
|
+
if (exitCode === 0) {
|
|
94
|
+
console.log(`All assertions passed. (${results.length} blocks)`);
|
|
95
|
+
}
|
|
96
|
+
process.exitCode = exitCode;
|
|
77
97
|
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err?.code === "NO_TEST_BLOCKS") {
|
|
100
|
+
const relPath = path.relative(process.cwd(), filePath);
|
|
101
|
+
console.error(`No test code blocks found in ${relPath}`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
} else {
|
|
104
|
+
throw err;
|
|
85
105
|
}
|
|
86
|
-
process.exitCode = exitCode;
|
|
87
106
|
}
|
package/src/comment-to-assert.js
CHANGED
|
@@ -7,6 +7,7 @@ import MagicString from "magic-string";
|
|
|
7
7
|
* expr //=> value → assert.deepEqual(expr, value)
|
|
8
8
|
* expr // → value → assert.deepEqual(expr, value)
|
|
9
9
|
* expr // throws /pat/ → assert.throws(() => { expr }, /pat/)
|
|
10
|
+
* expr //=> Error: msg → assert.throws(() => { expr }, { message: "msg" })
|
|
10
11
|
* console.log(x) //=> v → console.log(x); assert.deepEqual(x, v)
|
|
11
12
|
* expr //=> resolves to v → assert.deepEqual(await expr, v)
|
|
12
13
|
* expr // rejects /pat/ → assert.rejects(() => expr, /pat/)
|
|
@@ -14,10 +15,10 @@ import MagicString from "magic-string";
|
|
|
14
15
|
* Uses oxc-parser for AST + comment extraction. Handles both JS and TS.
|
|
15
16
|
*
|
|
16
17
|
* @param {string} code - JavaScript or TypeScript source
|
|
17
|
-
* @param {{
|
|
18
|
-
* @returns {{ code: string
|
|
18
|
+
* @param {{ typescript?: boolean }} options
|
|
19
|
+
* @returns {{ code: string }}
|
|
19
20
|
*/
|
|
20
|
-
export function commentToAssert(code, {
|
|
21
|
+
export function commentToAssert(code, { typescript = false } = {}) {
|
|
21
22
|
const ext = typescript ? "test.ts" : "test.js";
|
|
22
23
|
const result = parseSync(ext, code);
|
|
23
24
|
const ast = result.program;
|
|
@@ -41,6 +42,8 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
|
|
|
41
42
|
const resolvesMatch = rest.match(/^resolves\s+(?:to\s+)?([\s\S]*)$/);
|
|
42
43
|
changed = true;
|
|
43
44
|
|
|
45
|
+
const errorMatch = rest.match(/^((?:[A-Z]\w+)?Error)(?::\s*(.*))?$/);
|
|
46
|
+
|
|
44
47
|
if (resolvesMatch) {
|
|
45
48
|
// expr //=> resolves to value → assert.deepEqual(await expr, value)
|
|
46
49
|
const expected = resolvesMatch[1].trim();
|
|
@@ -53,8 +56,27 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
|
|
|
53
56
|
comment.end,
|
|
54
57
|
`assert.deepEqual(await ${exprSource}, ${expected});`,
|
|
55
58
|
);
|
|
59
|
+
} else if (errorMatch) {
|
|
60
|
+
// expr //=> TypeError: msg → assert.throws(() => { expr }, { name, message })
|
|
61
|
+
const errorName = errorMatch[1];
|
|
62
|
+
const errorMessage = errorMatch[2]?.trim();
|
|
63
|
+
const exprSource = code.slice(
|
|
64
|
+
node.expression.start,
|
|
65
|
+
node.expression.end,
|
|
66
|
+
);
|
|
67
|
+
const props = [`name: "${errorName}"`];
|
|
68
|
+
if (errorMessage) {
|
|
69
|
+
props.push(`message: "${errorMessage}"`);
|
|
70
|
+
}
|
|
71
|
+
s.overwrite(
|
|
72
|
+
node.start,
|
|
73
|
+
comment.end,
|
|
74
|
+
`assert.throws(() => { ${exprSource}; }, { ${props.join(", ")} });`,
|
|
75
|
+
);
|
|
56
76
|
} else if (isConsoleCall(node.expression)) {
|
|
57
|
-
// console.log(expr) //=> value → keep log, add assertion after
|
|
77
|
+
// console.log(expr) //=> value → keep log, add assertion after.
|
|
78
|
+
// Stay on the same line so subsequent markdown line numbers are
|
|
79
|
+
// preserved for error reporting.
|
|
58
80
|
const arg = code.slice(
|
|
59
81
|
node.expression.arguments[0].start,
|
|
60
82
|
node.expression.arguments[0].end,
|
|
@@ -62,7 +84,7 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
|
|
|
62
84
|
s.overwrite(
|
|
63
85
|
node.expression.end,
|
|
64
86
|
comment.end,
|
|
65
|
-
|
|
87
|
+
`; assert.deepEqual(${arg}, ${rest});`,
|
|
66
88
|
);
|
|
67
89
|
} else {
|
|
68
90
|
// expr //=> value → assert.deepEqual(expr, value)
|
|
@@ -103,12 +125,9 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
|
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
if (!changed) return { code
|
|
128
|
+
if (!changed) return { code };
|
|
107
129
|
|
|
108
|
-
return {
|
|
109
|
-
code: s.toString(),
|
|
110
|
-
map: s.generateMap({ source: filename, hires: true }),
|
|
111
|
-
};
|
|
130
|
+
return { code: s.toString() };
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
/**
|
package/src/extract.js
CHANGED
|
@@ -17,7 +17,7 @@ export function extractBlocks(markdown, { auto = false, all = false } = {}) {
|
|
|
17
17
|
const fenceRe = /^(([ \t]*`{3,4})([^\n]*)([\s\S]+?)(^[ \t]*\2))/gm;
|
|
18
18
|
const supportedLangs = new Set(["javascript", "js", "typescript", "ts"]);
|
|
19
19
|
const tsLangs = new Set(["typescript", "ts"]);
|
|
20
|
-
const assertRe = /\/[/*]\s*(
|
|
20
|
+
const assertRe = /\/[/*]\s*(=>|→|->|throws|rejects)/;
|
|
21
21
|
|
|
22
22
|
let hasTypescript = false;
|
|
23
23
|
const blocks = [];
|
package/src/generate.js
CHANGED
|
@@ -79,8 +79,10 @@ function assembleUnit(blocks) {
|
|
|
79
79
|
assertLine = 'const { default: assert } = await import("node:assert/strict");';
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// imports go on line 0 too (they're already removed from bodyLines)
|
|
83
|
-
|
|
82
|
+
// imports go on line 0 too (they're already removed from bodyLines).
|
|
83
|
+
// Each piece is its own statement, so a single space between them is
|
|
84
|
+
// enough; joining with "; " produced a double semicolon like ";; ".
|
|
85
|
+
const header = [assertLine, ...imports].join(" ");
|
|
84
86
|
bodyLines[0] = header;
|
|
85
87
|
return bodyLines.join("\n") + "\n";
|
|
86
88
|
}
|
package/src/run.js
CHANGED
|
@@ -39,7 +39,9 @@ export async function processMarkdown(filePath, options = {}) {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
if (extracted.blocks.length === 0) {
|
|
42
|
-
|
|
42
|
+
const err = new Error(`No test code blocks found in ${filePath}`);
|
|
43
|
+
err.code = "NO_TEST_BLOCKS";
|
|
44
|
+
throw err;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
const { units } = generate(extracted);
|
|
@@ -50,7 +52,7 @@ export async function processMarkdown(filePath, options = {}) {
|
|
|
50
52
|
if (pkgPath) {
|
|
51
53
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
52
54
|
if (pkg.name) {
|
|
53
|
-
const mainEntry = options.main || pkg
|
|
55
|
+
const mainEntry = options.main || resolveMainEntry(pkg) || "./index.js";
|
|
54
56
|
packageName = pkg.name;
|
|
55
57
|
localPath = path.resolve(path.dirname(pkgPath), mainEntry);
|
|
56
58
|
}
|
|
@@ -65,7 +67,6 @@ export async function processMarkdown(filePath, options = {}) {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
const transformed = commentToAssert(code, {
|
|
68
|
-
filename: filePath,
|
|
69
70
|
typescript: unit.hasTypescript,
|
|
70
71
|
});
|
|
71
72
|
code = transformed.code;
|
|
@@ -91,8 +92,13 @@ export async function processMarkdown(filePath, options = {}) {
|
|
|
91
92
|
* Each code block (or group) is written to a temp file and executed
|
|
92
93
|
* sequentially. Stops on first failure.
|
|
93
94
|
*
|
|
95
|
+
* When `options.stream` is true, each child's stdout chunk is written
|
|
96
|
+
* to `process.stdout` as it arrives so long-running blocks don't look
|
|
97
|
+
* stalled. Captured stdout is still returned in the result for
|
|
98
|
+
* programmatic callers.
|
|
99
|
+
*
|
|
94
100
|
* @param {string} filePath
|
|
95
|
-
* @param {{ auto?: boolean, all?: boolean, main?: string }} options
|
|
101
|
+
* @param {{ auto?: boolean, all?: boolean, main?: string, stream?: boolean }} options
|
|
96
102
|
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string, results: Array }>}
|
|
97
103
|
*/
|
|
98
104
|
export async function run(filePath, options = {}) {
|
|
@@ -103,6 +109,7 @@ export async function run(filePath, options = {}) {
|
|
|
103
109
|
const results = [];
|
|
104
110
|
|
|
105
111
|
const useRequire = options.require?.length > 0;
|
|
112
|
+
const stream = options.stream ?? false;
|
|
106
113
|
|
|
107
114
|
for (const unit of units) {
|
|
108
115
|
let code = unit.code;
|
|
@@ -126,7 +133,7 @@ export async function run(filePath, options = {}) {
|
|
|
126
133
|
for (const r of options.require || []) nodeArgs.push("--require", r);
|
|
127
134
|
for (const i of options.import || []) nodeArgs.push("--import", i);
|
|
128
135
|
nodeArgs.push(tmpFile);
|
|
129
|
-
const result = await exec("node", nodeArgs, dir, filePath);
|
|
136
|
+
const result = await exec("node", nodeArgs, dir, filePath, stream);
|
|
130
137
|
allStdout += result.stdout;
|
|
131
138
|
allStderr += result.stderr;
|
|
132
139
|
results.push({ name: unit.name, ...result });
|
|
@@ -143,13 +150,16 @@ export async function run(filePath, options = {}) {
|
|
|
143
150
|
return { exitCode: 0, stdout: allStdout, stderr: allStderr, results };
|
|
144
151
|
}
|
|
145
152
|
|
|
146
|
-
function exec(cmd, args, cwd, mdPath) {
|
|
153
|
+
function exec(cmd, args, cwd, mdPath, stream) {
|
|
147
154
|
return new Promise((resolve) => {
|
|
148
155
|
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
149
156
|
let stdout = "";
|
|
150
157
|
let stderr = "";
|
|
151
158
|
|
|
152
|
-
child.stdout.on("data", (
|
|
159
|
+
child.stdout.on("data", (chunk) => {
|
|
160
|
+
if (stream) process.stdout.write(chunk);
|
|
161
|
+
stdout += chunk;
|
|
162
|
+
});
|
|
153
163
|
child.stderr.on("data", (d) => (stderr += d));
|
|
154
164
|
|
|
155
165
|
child.on("close", (exitCode) => {
|
|
@@ -266,3 +276,45 @@ function findPackageJson(dir) {
|
|
|
266
276
|
current = parent;
|
|
267
277
|
}
|
|
268
278
|
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve a package's main entry point from its package.json.
|
|
282
|
+
*
|
|
283
|
+
* Handles `main`, plus the various shapes of `exports`:
|
|
284
|
+
* - string: "exports": "./lib/main.js"
|
|
285
|
+
* - subpath map: "exports": { ".": "./lib/main.js" }
|
|
286
|
+
* - conditional: "exports": { "import": "./esm.js", "require": "./cjs.js" }
|
|
287
|
+
* - nested: "exports": { ".": { "import": "./esm.js" } }
|
|
288
|
+
*
|
|
289
|
+
* Returns null if no entry can be determined.
|
|
290
|
+
*/
|
|
291
|
+
export function resolveMainEntry(pkg) {
|
|
292
|
+
if (pkg.main) return pkg.main;
|
|
293
|
+
|
|
294
|
+
const exp = pkg.exports;
|
|
295
|
+
if (!exp) return null;
|
|
296
|
+
if (typeof exp === "string") return exp;
|
|
297
|
+
if (typeof exp !== "object") return null;
|
|
298
|
+
|
|
299
|
+
// If any key starts with ".", this is a subpath map and the root export
|
|
300
|
+
// lives at "."; otherwise the object itself is the conditional map.
|
|
301
|
+
const isSubpathMap = Object.keys(exp).some((k) => k.startsWith("."));
|
|
302
|
+
const root = isSubpathMap ? exp["."] : exp;
|
|
303
|
+
|
|
304
|
+
return resolveExportCondition(root);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function resolveExportCondition(node) {
|
|
308
|
+
if (node == null) return null;
|
|
309
|
+
if (typeof node === "string") return node;
|
|
310
|
+
if (typeof node !== "object") return null;
|
|
311
|
+
|
|
312
|
+
// Prefer import > default > require
|
|
313
|
+
for (const key of ["import", "default", "require"]) {
|
|
314
|
+
if (key in node) {
|
|
315
|
+
const resolved = resolveExportCondition(node[key]);
|
|
316
|
+
if (resolved) return resolved;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|