readme-assert 6.0.3 → 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,54 +1,45 @@
1
1
  {
2
2
  "name": "readme-assert",
3
- "version": "6.0.3",
4
- "engines": {
5
- "node": ">=4"
6
- },
7
- "description": "Run code blocks in your readme as test",
8
- "main": "lib/index.js",
9
- "jsnext:main": "src/index.js",
10
- "bin": "cli.js",
11
- "scripts": {
12
- "build": "babel src/. -d lib/. --ignore=spec.js",
13
- "test:readme": "babel-node src/cli.js -m ./src",
14
- "test": "npm-run-all test:*",
15
- "prettier": "prettier 'src/**/*'",
16
- "prepublish": "npm run build"
3
+ "version": "7.1.0",
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
+ ],
16
+ "type": "module",
17
+ "exports": "./src/index.js",
18
+ "bin": {
19
+ "readme-assert": "./src/cli.js"
17
20
  },
21
+ "files": [
22
+ "src"
23
+ ],
24
+ "license": "MIT",
18
25
  "author": {
19
26
  "name": "Sigurd Fosseng",
20
27
  "email": "sigurd@fosseng.net",
21
28
  "url": "http://laat.io"
22
29
  },
23
- "license": "MIT",
24
30
  "repository": {
25
31
  "type": "git",
26
32
  "url": "https://github.com/laat/readme-assert.git"
27
33
  },
28
- "dependencies": {
29
- "@babel/core": "^7.0.0",
30
- "@babel/plugin-syntax-typescript": "^7.3.3",
31
- "@babel/plugin-transform-typescript": "^7.4.5",
32
- "@babel/preset-env": "^7.0.0",
33
- "babel-plugin-transform-comment-to-assert": "^4.1.0",
34
- "babel-plugin-transform-rename-import": "^2.0.0",
35
- "gfm-code-blocks": "^1.0.0",
36
- "pkg-up": "^3.1.0",
37
- "source-map-support": "^0.5.12",
38
- "stack-utils": "^1.0.2",
39
- "tap-yaml": "^1.0.0",
40
- "yargs": "^13.2.4"
34
+ "engines": {
35
+ "node": ">=20.19.0 || >=22.12.0"
41
36
  },
42
- "devDependencies": {
43
- "@babel/cli": "7.4.4",
44
- "@babel/node": "7.4.5",
45
- "@babel/register": "7.4.4",
46
- "npm-run-all": "4.1.5",
47
- "prettier": "1.18.2",
48
- "run-tests": "1.0.4"
37
+ "dependencies": {
38
+ "esbuild": "^0.27.0",
39
+ "magic-string": "^0.30.0",
40
+ "oxc-parser": "^0.123.0"
49
41
  },
50
- "files": [
51
- "lib",
52
- "cli.js"
53
- ]
54
- }
42
+ "scripts": {
43
+ "test": "node --test test/*.test.js && pnpm -r test"
44
+ }
45
+ }
package/readme.md CHANGED
@@ -1,37 +1,42 @@
1
- # readme-assert [![travis][travis-image]][travis-url] [![npm][npm-image]][npm-url]
1
+ # readme-assert [![npm][npm-image]][npm-url]
2
2
 
3
- [travis-image]: https://travis-ci.org/laat/readme-assert.svg?branch=master
4
- [travis-url]: https://travis-ci.org/laat/readme-assert
5
3
  [npm-image]: https://img.shields.io/npm/v/readme-assert.svg?style=flat
6
4
  [npm-url]: https://npmjs.org/package/readme-assert
7
5
 
8
- > Run code blocks in your readme as test
6
+ `README.md` files often become outdated because the code examples are
7
+ not regularly tested. readme-assert extracts fenced code blocks from
8
+ your readme and runs them as tests, using special comments as assertions.
9
9
 
10
- `README.md` files often become outdated over time because the code
11
- examples are not regulary tested. By commenting `javascript`
12
- codeblocks the `README.md` file with special comments we can create
13
- simple tests that ensures that the readme is still correct.
10
+ ## Install
11
+
12
+ ```
13
+ npm install readme-assert
14
+ ```
14
15
 
15
16
  ## Usage
16
17
 
17
18
  ```
18
- Run readme as test
19
-
20
19
  Usage: readme-assert [options]
21
20
 
22
21
  Options:
23
- --auto, -a Auto discover test code block [boolean]
24
- --all, -l Run all supported code blocks [boolean]
25
- --babel Use babelrc when transpiling [boolean] [default: false]
26
22
  --file, -f readme.md file to read
27
- --main, -m Points to the entry point of the module [string]
28
- --print-code, -p Print the transformed code [boolean]
29
- --require, -r Require a given module [array]
30
- --version Show version number [boolean]
31
- -h, --help Show help [boolean]
23
+ --main, -m Entry point of the module
24
+ --auto, -a Auto discover test code blocks
25
+ --all, -l Run all supported code blocks
26
+ --print-code, -p Print the transformed code
27
+ --version, -v Show version number
28
+ -h, --help Show help
32
29
  ```
33
30
 
34
- Write a test in the readme with the special code-block tag `test`
31
+ Run in the same folder as your readme:
32
+
33
+ ```
34
+ $ readme-assert
35
+ ```
36
+
37
+ ## Writing Tests
38
+
39
+ Tag your fenced code blocks with `test` or `should`:
35
40
 
36
41
  ````
37
42
  ```javascript test
@@ -39,88 +44,98 @@ Write a test in the readme with the special code-block tag `test`
39
44
  ```
40
45
  ````
41
46
 
42
- Run the test in the same folder as your readme:
47
+ ### Assertion Comments
43
48
 
44
- ```
45
- $ readme-assert
49
+ Use `//=>` to assert the value of an expression:
50
+
51
+ ```javascript test
52
+ let a = 1;
53
+ a; //=> 1
46
54
  ```
47
55
 
48
- output:
56
+ The `// →` (unicode arrow) and `// ->` (ascii arrow) variants also work:
49
57
 
50
- ```
51
- TAP version 13
52
- ok 1
53
- # tests 1
54
- # pass 1
55
- # fail 0
58
+ ```javascript test
59
+ let b = 1;
60
+ b; // → 1
56
61
  ```
57
62
 
58
- Printing the evaluated code, can be useful when debugging:
63
+ ### throws
59
64
 
60
- ```
61
- $ readme-assert --print-code
65
+ Assert that an expression throws using `// throws` with a regex pattern:
66
+
67
+ ```javascript test
68
+ const b = () => {
69
+ throw new Error("fail");
70
+ };
71
+ b(); // throws /fail/
62
72
  ```
63
73
 
64
- output:
74
+ ### console.log
65
75
 
66
- ```
67
- # /path/to/module/readme.md.js
68
- # 1 "use strict";
69
- # 2
70
- # 3 assert.deepEqual(1 + 1, 2);
71
- TAP version 13
72
- ok 1
73
- 1..1
74
- # tests 1
75
- # pass 1
76
- # fail 0
76
+ Assert console output — the call is preserved and an assertion is added:
77
+
78
+ ```javascript test
79
+ let a = { a: 1 };
80
+ console.log(a); //=> { a: 1 }
77
81
  ```
78
82
 
79
- ## Examples
83
+ ### Promises
80
84
 
81
- To see some examples of use see the [examples folder](https://github.com/laat/readme-assert/tree/master/examples) in the repository
85
+ Assert that a promise resolves to a value with `//=> resolves to`:
82
86
 
83
- ## Sample tests
87
+ ```javascript test
88
+ Promise.resolve(true) //=> resolves to true
89
+ ```
84
90
 
85
- ### simple
91
+ Assert that a promise rejects with `// rejects`:
86
92
 
87
- ```javascript should equal 1
88
- let a = 1;
89
- a; //=> 1
93
+ ```javascript test
94
+ Promise.reject(new Error("no")) // rejects /no/
90
95
  ```
91
96
 
92
- ### utf-8 arrow
97
+ ### TypeScript
93
98
 
94
- ```javascript test utf8 arrow
95
- a; // → 1
99
+ TypeScript code blocks are supported natively:
100
+
101
+ ```typescript should add two numbers
102
+ const sum: number = 1 + 1;
103
+ sum; //=> 2
96
104
  ```
97
105
 
98
- ### console.log
106
+ ### Grouping blocks
99
107
 
100
- ```javascript test console.log
101
- a = { a: 1 };
102
- console.log(a); //=> { a: 1 }
103
- ```
108
+ Each code block runs as its own file. To share variables across
109
+ blocks, give them the same group name with `test:groupname`:
104
110
 
105
- ### throws
111
+ ````
112
+ ```javascript test:math
113
+ let x = 2;
114
+ ```
106
115
 
107
- ```javascript test throws
108
- const b = () => {
109
- throw new Error("fail");
110
- };
111
- b(); // throws /fail/
116
+ ```javascript test:math
117
+ x; //=> 2
112
118
  ```
119
+ ````
113
120
 
114
- ### TypesScript
121
+ ### Auto-discover mode
115
122
 
116
- ```typescript should add two numbers with typescript
117
- const sum: number = 1 + 1;
118
- sum; //=> 2
119
- ```
123
+ With `--auto`, any code block containing `//=>`, `// →`, `// ->`,
124
+ `// throws`, or `// rejects` is treated as a test — no `test` tag needed.
125
+
126
+ ### All mode
127
+
128
+ With `--all`, every JavaScript and TypeScript code block is executed,
129
+ regardless of tags.
130
+
131
+ ## How It Works
132
+
133
+ 1. Each fenced code block is extracted from the markdown
134
+ 2. Blocks with the same `test:group` name are merged; others run independently
135
+ 3. Assertion comments (`//=> value`) are transformed into `assert.deepEqual()` calls using [oxc-parser](https://oxc.rs) and [magic-string](https://github.com/rich-harris/magic-string)
136
+ 4. Imports of your package name are rewritten to point to your local source
137
+ 5. Each block is written to a temp file and executed with `node`
120
138
 
121
- ## Projects using readme-assert
139
+ ## License
122
140
 
123
- - [fen-chess-board](https://github.com/laat/fen-chess-board)
124
- - [babel-plugin-transform-comment-to-assert](https://github.com/laat/babel-plugin-transform-comment-to-assert)
125
- - [babel-plugin-transform-rename-import](https://github.com/laat/babel-plugin-transform-rename-import)
126
- - [escape-invisibles](https://github.com/laat/escape-invisibles)
141
+ MIT
package/src/cli.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import path from "node:path";
4
+ import fs from "node:fs";
5
+
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
+ }
28
+
29
+ if (args.help) {
30
+ console.log(`
31
+ Run code blocks in your readme as tests
32
+
33
+ Usage: readme-assert [options]
34
+
35
+ Options:
36
+ --file, -f readme.md file to read
37
+ --main, -m Entry point of the module
38
+ --auto, -a Auto discover test code blocks
39
+ --all, -l Run all supported code blocks
40
+ --require, -r Require a module before running [array]
41
+ --import, -i Import a module before running [array]
42
+ --print-code, -p Print the transformed code
43
+ --version, -v Show version number
44
+ -h, --help Show help
45
+ `);
46
+ process.exit(0);
47
+ }
48
+
49
+ if (args.version) {
50
+ const pkgPath = new URL("../package.json", import.meta.url);
51
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
52
+ console.log(pkg.version);
53
+ process.exit(0);
54
+ }
55
+
56
+ function findReadme() {
57
+ for (const name of ["README.md", "readme.md"]) {
58
+ const p = path.resolve(name);
59
+ if (fs.existsSync(p)) return p;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ const filePath = args.file ? path.resolve(args.file) : findReadme();
65
+
66
+ if (!filePath) {
67
+ console.error("Could not locate readme.md");
68
+ process.exit(1);
69
+ }
70
+
71
+ const opts = {
72
+ auto: args.auto,
73
+ all: args.all,
74
+ main: args.main,
75
+ require: args.require,
76
+ import: args.import,
77
+ };
78
+
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;
97
+ }
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;
105
+ }
106
+ }
@@ -0,0 +1,138 @@
1
+ import { parseSync } from "oxc-parser";
2
+ import MagicString from "magic-string";
3
+
4
+ /**
5
+ * Transform assertion comments into assert calls.
6
+ *
7
+ * expr //=> value → assert.deepEqual(expr, value)
8
+ * expr // → value → assert.deepEqual(expr, value)
9
+ * expr // throws /pat/ → assert.throws(() => { expr }, /pat/)
10
+ * console.log(x) //=> v → console.log(x); assert.deepEqual(x, v)
11
+ * expr //=> resolves to v → assert.deepEqual(await expr, v)
12
+ * expr // rejects /pat/ → assert.rejects(() => expr, /pat/)
13
+ *
14
+ * Uses oxc-parser for AST + comment extraction. Handles both JS and TS.
15
+ *
16
+ * @param {string} code - JavaScript or TypeScript source
17
+ * @param {{ typescript?: boolean }} options
18
+ * @returns {{ code: string }}
19
+ */
20
+ export function commentToAssert(code, { typescript = false } = {}) {
21
+ const ext = typescript ? "test.ts" : "test.js";
22
+ const result = parseSync(ext, code);
23
+ const ast = result.program;
24
+ const comments = result.comments;
25
+
26
+ const s = new MagicString(code);
27
+ let changed = false;
28
+
29
+ for (const node of ast.body) {
30
+ if (node.type !== "ExpressionStatement") continue;
31
+
32
+ const comment = findTrailingComment(comments, node, code);
33
+ if (!comment) continue;
34
+
35
+ const match = comment.value.match(/^\s*(=>|→|->)\s*([\s\S]*)$/);
36
+ const throwsMatch = comment.value.match(/^\s*throws\s+([\s\S]*)$/);
37
+ const rejectsMatch = comment.value.match(/^\s*rejects\s+([\s\S]*)$/);
38
+
39
+ if (match) {
40
+ const rest = match[2].trim();
41
+ const resolvesMatch = rest.match(/^resolves\s+(?:to\s+)?([\s\S]*)$/);
42
+ changed = true;
43
+
44
+ if (resolvesMatch) {
45
+ // expr //=> resolves to value → assert.deepEqual(await expr, value)
46
+ const expected = resolvesMatch[1].trim();
47
+ const exprSource = code.slice(
48
+ node.expression.start,
49
+ node.expression.end,
50
+ );
51
+ s.overwrite(
52
+ node.start,
53
+ comment.end,
54
+ `assert.deepEqual(await ${exprSource}, ${expected});`,
55
+ );
56
+ } else if (isConsoleCall(node.expression)) {
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.
60
+ const arg = code.slice(
61
+ node.expression.arguments[0].start,
62
+ node.expression.arguments[0].end,
63
+ );
64
+ s.overwrite(
65
+ node.expression.end,
66
+ comment.end,
67
+ `; assert.deepEqual(${arg}, ${rest});`,
68
+ );
69
+ } else {
70
+ // expr //=> value → assert.deepEqual(expr, value)
71
+ const exprSource = code.slice(
72
+ node.expression.start,
73
+ node.expression.end,
74
+ );
75
+ s.overwrite(
76
+ node.start,
77
+ comment.end,
78
+ `assert.deepEqual(${exprSource}, ${rest});`,
79
+ );
80
+ }
81
+ } else if (throwsMatch) {
82
+ const pattern = throwsMatch[1].trim();
83
+ const exprSource = code.slice(
84
+ node.expression.start,
85
+ node.expression.end,
86
+ );
87
+ s.overwrite(
88
+ node.start,
89
+ comment.end,
90
+ `assert.throws(() => { ${exprSource}; }, ${pattern});`,
91
+ );
92
+ changed = true;
93
+ } else if (rejectsMatch) {
94
+ const pattern = rejectsMatch[1].trim();
95
+ const exprSource = code.slice(
96
+ node.expression.start,
97
+ node.expression.end,
98
+ );
99
+ s.overwrite(
100
+ node.start,
101
+ comment.end,
102
+ `await assert.rejects(() => ${exprSource}, ${pattern});`,
103
+ );
104
+ changed = true;
105
+ }
106
+ }
107
+
108
+ if (!changed) return { code };
109
+
110
+ return { code: s.toString() };
111
+ }
112
+
113
+ /**
114
+ * Find a trailing line comment for an expression statement.
115
+ * The comment must start after the expression and be on the same line.
116
+ */
117
+ function findTrailingComment(comments, node, code) {
118
+ for (const c of comments) {
119
+ if (c.type !== "Line") continue;
120
+ if (c.start < node.expression.end) continue;
121
+
122
+ // Must be on the same line as the expression (no newline between)
123
+ const between = code.slice(node.expression.end, c.start);
124
+ if (between.includes("\n")) continue;
125
+
126
+ return c;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function isConsoleCall(expr) {
132
+ return (
133
+ expr.type === "CallExpression" &&
134
+ expr.callee.type === "MemberExpression" &&
135
+ expr.callee.object.type === "Identifier" &&
136
+ expr.callee.object.name === "console"
137
+ );
138
+ }
package/src/extract.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Extract tagged code blocks from a markdown string.
3
+ *
4
+ * @param {string} markdown
5
+ * @param {{ auto?: boolean, all?: boolean }} options
6
+ * @returns {{ blocks: Block[], hasTypescript: boolean }}
7
+ *
8
+ * Block: { code, lang, tag, group, startLine, endLine }
9
+ *
10
+ * Tags: "test", "test:groupname", "should description", "should:groupname description"
11
+ * Blocks with the same group name are merged into a single execution unit.
12
+ * Blocks without a group name each run independently.
13
+ */
14
+ export function extractBlocks(markdown, { auto = false, all = false } = {}) {
15
+ // Based on gfm-code-block-regex — the backreference \2 ensures a 4-backtick
16
+ // fence only closes with 4 backticks, so nested display fences are skipped.
17
+ const fenceRe = /^(([ \t]*`{3,4})([^\n]*)([\s\S]+?)(^[ \t]*\2))/gm;
18
+ const supportedLangs = new Set(["javascript", "js", "typescript", "ts"]);
19
+ const tsLangs = new Set(["typescript", "ts"]);
20
+ const assertRe = /\/[/*]\s*(=>|→|->|throws|rejects)/;
21
+
22
+ let hasTypescript = false;
23
+ const blocks = [];
24
+ let match;
25
+
26
+ while ((match = fenceRe.exec(markdown)) !== null) {
27
+ const infoString = match[3].trim();
28
+ const code = match[4].replace(/^\n/, "");
29
+ const blockStart = match.index;
30
+
31
+ // Parse language and tag from info string (e.g. "javascript test" or "ts test:group")
32
+ const parts = infoString.split(/\s+/);
33
+ const lang = parts[0] || "";
34
+ const tag = parts.slice(1).join(" ");
35
+
36
+ if (!supportedLangs.has(lang)) continue;
37
+
38
+ // Filter by mode
39
+ if (!all) {
40
+ if (auto) {
41
+ if (!assertRe.test(code)) continue;
42
+ } else {
43
+ const firstWord = tag.split(/\s+/)[0] || "";
44
+ const keyword = firstWord.split(":")[0];
45
+ if (keyword !== "test" && keyword !== "should") continue;
46
+ }
47
+ }
48
+
49
+ // Count lines before this block to get startLine (1-based)
50
+ const linesBeforeBlock = markdown.slice(0, blockStart).split("\n").length;
51
+ const startLine = linesBeforeBlock + 1; // +1 for the fence line itself
52
+ const codeLines = code.split("\n").length;
53
+ const endLine = startLine + codeLines - 1;
54
+
55
+ if (tsLangs.has(lang)) hasTypescript = true;
56
+
57
+ // Parse group from tag: "test:mygroup ..." or "should:mygroup ..."
58
+ const firstTagWord = tag.split(/\s+/)[0] || "";
59
+ const colonIdx = firstTagWord.indexOf(":");
60
+ const group = colonIdx !== -1 ? firstTagWord.slice(colonIdx + 1) : null;
61
+
62
+ blocks.push({ code, lang, tag, group, startLine, endLine });
63
+ }
64
+
65
+ return { blocks, hasTypescript };
66
+ }