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 CHANGED
@@ -1,7 +1,18 @@
1
1
  {
2
2
  "name": "readme-assert",
3
- "version": "7.0.0",
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 `//=>`, `// →`, or `// throws`
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
- const { values: args } = parseArgs({
7
- options: {
8
- file: { type: "string", short: "f" },
9
- main: { type: "string", short: "m" },
10
- auto: { type: "boolean", short: "a", default: false },
11
- all: { type: "boolean", short: "l", default: false },
12
- require: { type: "string", short: "r", multiple: true },
13
- import: { type: "string", short: "i", multiple: true },
14
- "print-code": { type: "boolean", short: "p", default: false },
15
- help: { type: "boolean", short: "h", default: false },
16
- version: { type: "boolean", short: "v", default: false },
17
- },
18
- strict: false,
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
- if (args["print-code"]) {
72
- const { processMarkdown } = await import("./run.js");
73
- const units = await processMarkdown(filePath, opts);
74
- for (const unit of units) {
75
- console.log(`# --- ${unit.name} ---`);
76
- console.log(unit.code);
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
- } else {
79
- const { run } = await import("./run.js");
80
- const { exitCode, stdout, stderr, results } = await run(filePath, opts);
81
- if (stdout) process.stdout.write(stdout);
82
- if (stderr) process.stderr.write(stderr);
83
- if (exitCode === 0) {
84
- console.log(`All assertions passed. (${results.length} blocks)`);
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
  }
@@ -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 {{ filename?: string, typescript?: boolean }} options
18
- * @returns {{ code: string, map: object }}
18
+ * @param {{ typescript?: boolean }} options
19
+ * @returns {{ code: string }}
19
20
  */
20
- export function commentToAssert(code, { filename, typescript = false } = {}) {
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
- `;\nassert.deepEqual(${arg}, ${rest});`,
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, map: null };
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*(=>|→|throws)/;
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
- const header = [assertLine, ...imports].join("; ");
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
- throw new Error("README has no test code blocks");
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.main || pkg.exports?.["."] || "./index.js";
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", (d) => (stdout += d));
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
+ }