|
| 1 | +const assert = require("node:assert/strict"); |
| 2 | +const { spawnSync } = require("node:child_process"); |
| 3 | +const fs = require("node:fs"); |
| 4 | +const path = require("node:path"); |
| 5 | +const test = require("node:test"); |
| 6 | + |
| 7 | +const { |
| 8 | + formatIssue, |
| 9 | + reportProblems, |
| 10 | +} = require("../check-markdown-links.js"); |
| 11 | + |
| 12 | +const root = path.resolve(__dirname, "../.."); |
| 13 | +const script = path.join(root, "scripts/check-markdown-links.js"); |
| 14 | + |
| 15 | +test("formatIssue includes correction details", () => { |
| 16 | + assert.equal( |
| 17 | + formatIssue({ |
| 18 | + raw: "./courses/intro.md", |
| 19 | + reason: "cross-repo-relative-link", |
| 20 | + suggestion: "https://github.com/ai-driven-dev/framework/blob/main/courses/intro.md", |
| 21 | + }), |
| 22 | + "./courses/intro.md (cross-repo relative link; suggestion: https://github.com/ai-driven-dev/framework/blob/main/courses/intro.md)", |
| 23 | + ); |
| 24 | +}); |
| 25 | + |
| 26 | +test("reportProblems writes a markdown source issue table", () => { |
| 27 | + const lines = []; |
| 28 | + const originalConsoleError = console.error; |
| 29 | + console.error = (line = "") => lines.push(line); |
| 30 | + |
| 31 | + try { |
| 32 | + reportProblems( |
| 33 | + [ |
| 34 | + { |
| 35 | + file: path.join(root, "CLAUDE.md"), |
| 36 | + line: 34, |
| 37 | + raw: "@aidd_docs/memory/architecture.md", |
| 38 | + reason: "local-path-not-found", |
| 39 | + }, |
| 40 | + ], |
| 41 | + 339, |
| 42 | + ); |
| 43 | + } finally { |
| 44 | + console.error = originalConsoleError; |
| 45 | + } |
| 46 | + |
| 47 | + assert.deepEqual(lines, [ |
| 48 | + "| source | issue |", |
| 49 | + "| - | - |", |
| 50 | + "| CLAUDE.md:34 | @aidd_docs/memory/architecture.md (local path not found) |", |
| 51 | + "", |
| 52 | + "❌ Links: 1 broken in 339 files", |
| 53 | + ]); |
| 54 | +}); |
| 55 | + |
| 56 | +test("reportProblems writes a success summary when there are no problems", () => { |
| 57 | + const lines = []; |
| 58 | + |
| 59 | + const status = reportProblems([], 42, () => { |
| 60 | + throw new Error("unexpected error logger call"); |
| 61 | + }, (line) => lines.push(line)); |
| 62 | + |
| 63 | + assert.equal(status, 0); |
| 64 | + assert.deepEqual(lines, ["✅ Links: 0 broken in 42 files"]); |
| 65 | +}); |
| 66 | + |
| 67 | +test("--help documents supported links, exclusions, fixes, and examples", () => { |
| 68 | + const result = spawnSync(process.execPath, [script, "--help"], { |
| 69 | + cwd: root, |
| 70 | + encoding: "utf8", |
| 71 | + }); |
| 72 | + |
| 73 | + assert.equal(result.status, 0); |
| 74 | + assert.match(result.stdout, /node scripts\/check-markdown-links\.js \[--ignore path\]/u); |
| 75 | + assert.match(result.stdout, /Markdown links and images/u); |
| 76 | + assert.match(result.stdout, /@path/u); |
| 77 | + assert.match(result.stdout, /src: path/u); |
| 78 | + assert.match(result.stdout, /Anchor-only links/u); |
| 79 | + assert.match(result.stdout, /mailto:/u); |
| 80 | + assert.match(result.stdout, /tel:/u); |
| 81 | + assert.match(result.stdout, /HTML angle-bracket/u); |
| 82 | + assert.match(result.stdout, /\.git and node_modules/u); |
| 83 | + assert.match(result.stdout, /Runtime variables, glob patterns, and bare words/u); |
| 84 | + assert.match(result.stdout, /\| Need \| Use \|/u); |
| 85 | + assert.match(result.stdout, /\| Include\/import a file in agent context \| @path\/to\/file\.md \|/u); |
| 86 | + assert.match(result.stdout, /\| Reference a file for the reader \| \[label\]\(path\/to\/file\.md\) \|/u); |
| 87 | + assert.match(result.stdout, /\| Case \| Example \|/u); |
| 88 | + assert.match(result.stdout, /@plugins\/aidd-context\/skills\/11-explore\/SKILL\.md/u); |
| 89 | + assert.match(result.stdout, /\[explore skill\]\(plugins\/aidd-context\/skills\/11-explore\/SKILL\.md\)/u); |
| 90 | +}); |
| 91 | + |
| 92 | +test("CLI checks one markdown file covering supported, ignored, and broken forms", () => { |
| 93 | + const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-")); |
| 94 | + const fixture = path.join(tempDir, "all-cases.md"); |
| 95 | + const readmePath = path.relative(tempDir, path.join(root, "README.md")); |
| 96 | + const logoPath = path.relative(tempDir, path.join(root, "docs/assets/logo.png")); |
| 97 | + const claudePath = path.relative(tempDir, path.join(root, "CLAUDE.md")); |
| 98 | + const contributingPath = path.relative(tempDir, path.join(root, "CONTRIBUTING.md")); |
| 99 | + const outsidePath = path.relative(tempDir, path.resolve(root, "..", "outside.md")); |
| 100 | + const outsideSuggestion = "https://github.com/ai-driven-dev/framework/blob/main/outside.md"; |
| 101 | + |
| 102 | + fs.writeFileSync( |
| 103 | + fixture, |
| 104 | + [ |
| 105 | + "# Link checker fixture", |
| 106 | + "", |
| 107 | + "## Supported existing forms", |
| 108 | + `See [README](${readmePath}).`, |
| 109 | + ``, |
| 110 | + `@${claudePath}`, |
| 111 | + `src: ${contributingPath}`, |
| 112 | + "", |
| 113 | + "## Ignored forms", |
| 114 | + "[Anchor only](#local-heading)", |
| 115 | + "[Mail](mailto:security@example.com)", |
| 116 | + "[Phone](tel:+33123456789)", |
| 117 | + "<https://example.com>", |
| 118 | + `<img src="${logoPath}" alt="Logo">`, |
| 119 | + "src: $ARGUMENTS", |
| 120 | + "src: plugins/*/README.md", |
| 121 | + "src: README", |
| 122 | + "[External URL](https://example.com/not-checked.md)", |
| 123 | + "", |
| 124 | + "## Broken forms", |
| 125 | + "[Missing markdown](./missing.md)", |
| 126 | + "@./missing-agent.md", |
| 127 | + "src: ./missing-source.md", |
| 128 | + `[Cross repo](${outsidePath})`, |
| 129 | + "", |
| 130 | + ].join("\n"), |
| 131 | + "utf8", |
| 132 | + ); |
| 133 | + |
| 134 | + try { |
| 135 | + const result = spawnSync(process.execPath, [script, fixture], { |
| 136 | + cwd: root, |
| 137 | + encoding: "utf8", |
| 138 | + }); |
| 139 | + |
| 140 | + assert.equal(result.status, 1); |
| 141 | + assert.equal(result.stdout, ""); |
| 142 | + assert.match(result.stderr, /\| source \| issue \|/u); |
| 143 | + assert.match(result.stderr, /\| - \| - \|/u); |
| 144 | + assert.match(result.stderr, /missing\.md \(local path not found\)/u); |
| 145 | + assert.match(result.stderr, /@\.\/missing-agent\.md \(local path not found\)/u); |
| 146 | + assert.match(result.stderr, /\.\/missing-source\.md \(local path not found\)/u); |
| 147 | + assert.ok( |
| 148 | + result.stderr.includes(`${outsidePath} (cross-repo relative link; suggestion: ${outsideSuggestion})`), |
| 149 | + result.stderr, |
| 150 | + ); |
| 151 | + assert.match(result.stderr, /❌ Links: 4 broken in 1 files/u); |
| 152 | + |
| 153 | + assert.doesNotMatch(result.stderr, /local-heading/u); |
| 154 | + assert.doesNotMatch(result.stderr, /mailto:security@example\.com/u); |
| 155 | + assert.doesNotMatch(result.stderr, /tel:\+33123456789/u); |
| 156 | + assert.doesNotMatch(result.stderr, /(?:^|\s)https?:\/\/example\.com\/not-checked(?:\s|$)/u); |
| 157 | + assert.doesNotMatch(result.stderr, /\$ARGUMENTS/u); |
| 158 | + assert.doesNotMatch(result.stderr, /plugins\/\*\/README\.md/u); |
| 159 | + } finally { |
| 160 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 161 | + } |
| 162 | +}); |
| 163 | + |
| 164 | +test("CLI without paths scans the repository and prints a summary", () => { |
| 165 | + const result = spawnSync(process.execPath, [script], { |
| 166 | + cwd: root, |
| 167 | + encoding: "utf8", |
| 168 | + }); |
| 169 | + |
| 170 | + assert.equal(result.status, 0); |
| 171 | + assert.match(result.stdout, /✅ Links: 0 broken in \d+ files/u); |
| 172 | +}); |
| 173 | + |
| 174 | +test("CLI fails when an explicit input path does not exist", () => { |
| 175 | + const result = spawnSync(process.execPath, [script, "DOES_NOT_EXIST.md"], { |
| 176 | + cwd: root, |
| 177 | + encoding: "utf8", |
| 178 | + }); |
| 179 | + |
| 180 | + assert.equal(result.status, 1); |
| 181 | + assert.match(result.stderr, /❌ Path not found: DOES_NOT_EXIST\.md/u); |
| 182 | +}); |
| 183 | + |
| 184 | +test("repository scan ignores interrupted test temp directories", () => { |
| 185 | + const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-")); |
| 186 | + |
| 187 | + try { |
| 188 | + fs.writeFileSync(path.join(tempDir, "leftover.md"), "[Missing](./missing.md)\n", "utf8"); |
| 189 | + |
| 190 | + const result = spawnSync(process.execPath, [script], { |
| 191 | + cwd: root, |
| 192 | + encoding: "utf8", |
| 193 | + }); |
| 194 | + |
| 195 | + assert.equal(result.status, 0); |
| 196 | + assert.match(result.stdout, /✅ Links: 0 broken in \d+ files/u); |
| 197 | + } finally { |
| 198 | + fs.rmSync(tempDir, { recursive: true, force: true }); |
| 199 | + } |
| 200 | +}); |
0 commit comments