readme-assert 7.0.0 → 7.1.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.1.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
@@ -120,8 +120,8 @@ x; //=> 2
120
120
 
121
121
  ### Auto-discover mode
122
122
 
123
- With `--auto`, any code block containing `//=>`, `// →`, or `// throws`
124
- is treated as a test — no `test` tag needed.
123
+ With `--auto`, any code block containing `//=>`, `// →`, `// ->`,
124
+ `// throws`, or `// rejects` is treated as a test — no `test` tag needed.
125
125
 
126
126
  ### All mode
127
127
 
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
  }
@@ -14,10 +14,10 @@ import MagicString from "magic-string";
14
14
  * Uses oxc-parser for AST + comment extraction. Handles both JS and TS.
15
15
  *
16
16
  * @param {string} code - JavaScript or TypeScript source
17
- * @param {{ filename?: string, typescript?: boolean }} options
18
- * @returns {{ code: string, map: object }}
17
+ * @param {{ typescript?: boolean }} options
18
+ * @returns {{ code: string }}
19
19
  */
20
- export function commentToAssert(code, { filename, typescript = false } = {}) {
20
+ export function commentToAssert(code, { typescript = false } = {}) {
21
21
  const ext = typescript ? "test.ts" : "test.js";
22
22
  const result = parseSync(ext, code);
23
23
  const ast = result.program;
@@ -54,7 +54,9 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
54
54
  `assert.deepEqual(await ${exprSource}, ${expected});`,
55
55
  );
56
56
  } else if (isConsoleCall(node.expression)) {
57
- // console.log(expr) //=> value → keep log, add assertion after
57
+ // console.log(expr) //=> value → keep log, add assertion after.
58
+ // Stay on the same line so subsequent markdown line numbers are
59
+ // preserved for error reporting.
58
60
  const arg = code.slice(
59
61
  node.expression.arguments[0].start,
60
62
  node.expression.arguments[0].end,
@@ -62,7 +64,7 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
62
64
  s.overwrite(
63
65
  node.expression.end,
64
66
  comment.end,
65
- `;\nassert.deepEqual(${arg}, ${rest});`,
67
+ `; assert.deepEqual(${arg}, ${rest});`,
66
68
  );
67
69
  } else {
68
70
  // expr //=> value → assert.deepEqual(expr, value)
@@ -103,12 +105,9 @@ export function commentToAssert(code, { filename, typescript = false } = {}) {
103
105
  }
104
106
  }
105
107
 
106
- if (!changed) return { code, map: null };
108
+ if (!changed) return { code };
107
109
 
108
- return {
109
- code: s.toString(),
110
- map: s.generateMap({ source: filename, hires: true }),
111
- };
110
+ return { code: s.toString() };
112
111
  }
113
112
 
114
113
  /**
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
+ }