| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { buildInstallGuide, installAnchorErrors, renderInstallGuideModule } from "../scripts/install-guide-lib.mjs"; |
| 4 | import { INSTALL_GUIDE } from "./install-guide.generated"; |
| 5 | |
| 6 | describe("canonical installation guide", () => { |
| 7 | it("ships the entire current guide, including the untranslated-document notice", () => { |
| 8 | const source = readFileSync(new URL("../../docs/INSTALL.md", import.meta.url), "utf8"); |
| 9 | expect(readFileSync(new URL("./install-guide.generated.ts", import.meta.url), "utf8")) |
| 10 | .toBe(renderInstallGuideModule(source)); |
| 11 | expect(INSTALL_GUIDE.chunks.map((chunk) => chunk.text).join("\n")) |
| 12 | .toContain("not yet updated for this revision"); |
| 13 | }); |
| 14 | |
| 15 | it("keeps top-level command bytes intact and nested code inside its list", () => { |
| 16 | const guide = buildInstallGuide('# Setup\n\n```sh\nprintf "$HOME & <path>"\n```\n\n1. In a list:\n\n ```sh\n echo nested\n ```\n'); |
| 17 | expect(guide.chunks[1]).toEqual({ kind: "code", text: 'printf "$HOME & <path>"' }); |
| 18 | expect(guide.chunks[2].text).toMatch(/<ol>[\s\S]*<li>[\s\S]*<pre><code[\s\S]*echo nested[\s\S]*<\/li>[\s\S]*<\/ol>/); |
| 19 | }); |
| 20 | |
| 21 | it("resolves relative docs and preserves duplicate-heading and legacy anchors", () => { |
| 22 | const guide = buildInstallGuide('# Start\n\n## Notes\n\n## Notes\n\n<a id="legacy"></a>\n\n[Keys](KEYBINDINGS.md) [Notes](INSTALL.md#notes-1) [Old](#legacy)\n'); |
| 23 | expect(guide.anchors).toEqual(["start", "notes", "notes-1", "legacy"]); |
| 24 | expect(guide.chunks[0].text).toContain('href="https://github.com/codewhale-hq/CodeWhale/blob/main/docs/KEYBINDINGS.md"'); |
| 25 | expect(guide.chunks[0].text).toContain('href="#notes-1"'); |
| 26 | expect(installAnchorErrors("INSTALL.md#notes-1 INSTALL.md#missing", guide.anchors)).toEqual(["missing"]); |
| 27 | }); |
| 28 | |
| 29 | it("fails generation on a broken internal link", () => { |
| 30 | expect(() => buildInstallGuide("# Setup\n\n[Missing](#gone)")) |
| 31 | .toThrow("Missing INSTALL.md anchors: gone"); |
| 32 | }); |
| 33 | |
| 34 | it("gives scrollable tables distinct, escaped names from their headers", () => { |
| 35 | const table = '| "Need" | Reason |\n| --- | --- |\n| curl | Download |\n'; |
| 36 | const guide = buildInstallGuide(`# Setup\n\n${table}\n${table}`); |
| 37 | const html = guide.chunks.map((chunk) => chunk.text).join("\n"); |
| 38 | const names = [...html.matchAll(/role="region" aria-label="([^"]+)"/g)].map((match) => match[1]); |
| 39 | expect(names).toEqual([ |
| 40 | "Installation table 1: "Need" / Reason", |
| 41 | "Installation table 2: "Need" / Reason", |
| 42 | ]); |
| 43 | }); |
| 44 | |
| 45 | it.each(["javascript:alert(1)", "data:text/html,hello", "file:///tmp/private"])("rejects unsafe URL %s", (href) => { |
| 46 | expect(() => buildInstallGuide(`# Setup\n\n[link](${href})`)).toThrow("Unsupported install-guide link protocol"); |
| 47 | }); |
| 48 | |
| 49 | it("rejects arbitrary raw HTML instead of publishing it", () => { |
| 50 | expect(() => buildInstallGuide('# Setup\n\n<img src="x" onerror="alert(1)">')) |
| 51 | .toThrow("Unsupported raw HTML"); |
| 52 | }); |
| 53 | }); |
| 54 |