// Docs link audit tests cover documentation link validation behavior. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { createDocsMarkdown, parseDocsDocument } from "../../scripts/lib/docs-markdown.mjs"; import { normalizeRoute } from "../../scripts/lib/docs-published-routes.mts"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; const { auditDocsLinks, prepareExternalLinkAuditTree, prepareMirroredDocsDir, resolveRoute } = await import("../../scripts/docs-link-audit.mts"); type AuditCliCase = { name: string; source: string[]; broken: number; anchors?: boolean; diagnostics?: string[]; files?: Record; navigation?: string[]; redirects?: Array<{ source: string; destination: string }>; }; describe("docs-link-audit", () => { function tempEntries(prefix: string): Set { return new Set(fs.readdirSync(os.tmpdir()).filter((entry) => entry.startsWith(prefix))); } it.each(["\n", "\r\n"])("preserves code literals with line ending %j", (newline) => { const source = [ "Intro", "", "```md", '', "```", "", "[live](/same)", ].join(newline); const md = createDocsMarkdown(); const document = parseDocsDocument(source, md, { mapLink: (href: string, line: number | undefined) => ({ href, line }), }); expect(document.links).toEqual([{ href: "/same", line: 7 }]); expect(md.renderer.render(document.tokens, md.options, document.env)).toContain( "<Card href="/same" title="Literal" />", ); }); it.each([ { name: "published possessive links", source: "## Request today's summary\n\n## The vendor's harness, as a plugin", headings: [ ["request-today's-summary", "request-todays-summary"], ["the-vendor's-harness%2C-as-a-plugin", "the-vendors-harness-as-a-plugin"], ], collisions: [], }, { name: "normalized duplicates and numbered suffixes before alias reservation", source: "## A-s\n\n## As\n\n## A-s\n\n## A-s-2\n\n## A-s", headings: [ ["a-s", null], ["as", "as-2"], ["a-s-1", "as-3"], ["a-s-2", "as-2-1"], ["a-s-3", "as-4"], ], collisions: [{ id: "as", reason: "compatibility alias collision" }], }, { name: "percent bytes, underscores and numbered title prefixes", source: "## café 中文 _Über\n\n## café 中文 _Über\n\n## 1. Today\n\n## 1. Today\n\n## 100% ready", headings: [ ["caf%C3%A9-%E4%B8%AD%E6%96%87-_%C3%BCber", "café-中文-_über"], ["caf%C3%A9-%E4%B8%AD%E6%96%87-_%C3%BCber-1", "café-中文-_über-2"], ["1.-today", "1-today"], ["1.-today-1", "1-today-2"], ["100%25-ready", "100%-ready"], ], collisions: [], }, ])("preserves Mint heading targets for $name", ({ source, headings, collisions }) => { const document = parseDocsDocument(source); expect( document.tokens .filter((token) => token.type === "heading_open") .map((token) => [token.attrGet("id"), token.meta?.anchorAlias ?? null]), ).toEqual(headings); expect(document.collisions).toEqual(collisions); expect(new Set(document.ids).size).toBe(document.ids.length); }); it.each([ { name: "shared heading/Step TOC with an independent Tab counter", source: [ "## Today's summary", '', 'one', 'two', "", "", 'one', 'two', 'three', "", 'one', 'two', 'one', 'two', ], ids: [ "today's-summary", "todays-summary", "todays-summary-2", "todays-summary-3", "todays-summary-1", "todays-summary-2-1", "todays-summary-3-1", "as", "as-1", "param-as", "param-as-1", ], collisions: [], }, { name: "authored IDs reserved before heading aliases and component IDs", source: [ "## Today's summary", '', 'one', 'two', 'three', 'four', '', ], ids: ["today's-summary", "todays-summary", "as", "param-as", "as-1", "as-2", "param-as-1"], collisions: [{ id: "todays-summary", reason: "compatibility alias collision" }], }, { name: "raw component apostrophes normalized after separators", source: [ 'one', 'two', ], ids: ["as-s-guide", "param-as-s-guide"], collisions: [], }, ])("preserves Mint component targets for $name", ({ source, ids, collisions }) => { const document = parseDocsDocument(source.join("\n\n")); expect(document.ids).toEqual(ids); expect(document.collisions).toEqual(collisions); expect(new Set(document.ids).size).toBe(document.ids.length); }); it.each([ { name: "component and list fences", source: [ "", " ~~~md", " [example](/hidden-tilde)", " ~~~not-a-closing-fence", " [example](/hidden-after-false-close)", " ~~~", "", "", "- Example", "", " ````md", " ```text", " [example](/hidden-nested)", " ```", " ````", "", "[real](/missing-page)", "[valid](/page)", ], broken: 1, }, { name: "legacy malformed MDX", source: [ "", "~~~md", "[example](/hidden-fallback)", "~~~", "[real](/missing-page)", "[valid](/page)", ], broken: 1, }, { name: "indented component prose", source: ["", " [real](/missing-page)", "", "[valid](/page)"], broken: 1, }, { name: "valid prose", source: ["[valid](/page)"], broken: 0 }, ...[false, true].map((anchors) => ({ name: `fenced raw HTML boundaries (anchors=${anchors})`, anchors, source: [ "```html", "", "", "```", "", '', "[Visible](/missing-page)", "", "", "```html", "", "```", "", "[valid](/page)", ], broken: 1, diagnostics: ["page.mdx:7 :: /missing-page :: route/file not found"], })), { name: "repeated occurrences beside protected literals", source: [ "---", "title: Positions", "---", "```md", "[hidden](/missing-page)", "```", "[first](/missing-page)", "[second](/missing-page)", "", "`[hidden](/missing-page)` [third](/missing-page)", "[valid](/page)", ], broken: 3, diagnostics: [7, 8, 10].map( (line) => `page.mdx:${line} :: /missing-page :: route/file not found`, ), }, { name: "reference, HTML, component and normalized relative occurrences", source: [ "[reference][missing]", "", "[missing]: /missing-page", "", 'HTML', "", '', "", "[relative](./missing-page.md)", "[valid](/page)", ], broken: 4, diagnostics: [1, 5, 7, 9].map( (line) => `page.mdx:${line} :: /missing-page :: route/file not found`, ), }, { name: "unmapped expansions", source: [ '', "", '
', "[embedded](/missing-page)", "
", "", "[valid](/page)", ], files: { "docs/part.txt": "[included](/missing-page)\n" }, broken: 2, diagnostics: ["page.mdx:unknown :: /missing-page :: route/file not found"], }, { name: "decoded direct paths and HTML-alias targets", anchors: true, source: [ "[direct](/caf%C3%A9#known)", "[literal percent](/100%25#known)", "[asset](/image%20space.svg)", "[redirect](/via)", "[bad redirect](/invalid)", "[raw Markdown redirect](/raw)", "[after](/missing-page)", "[external](/outside)", ], files: { "docs/café.md": "## Known\n", "docs/100%.md": "## Known\n", "docs/image space.svg": "", }, redirects: [ { source: "/via", destination: "https://docs.openclaw.ai/caf%C3%A9#known" }, { source: "/invalid", destination: "https://docs.openclaw.ai/bad%" }, { source: "/raw", destination: "/café.md#known" }, { source: "/outside", destination: "https://example.test/page#external-section" }, ], broken: 4, diagnostics: [ "docs.json:unknown :: /invalid :: malformed URL path", "page.mdx:5 :: /invalid :: malformed URL path", "page.mdx:6 :: /raw :: fragment requires an HTML page, not raw Markdown", "page.mdx:7 :: /missing-page :: route/file not found", ], }, ...[false, true].map((anchors) => ({ name: `malformed emitted paths (anchors=${anchors})`, anchors, source: [ '', "", 'HTML', "", '', "", '', "", "[markdown](/bad%FF)", "[reference][bad]", "", "[bad]: /bad%FF", "", "[after](/missing-page)", "[valid](/page)", ], broken: anchors ? 8 : 7, diagnostics: [ ":: /bad% :: malformed URL path", ":: /bad%FF :: malformed URL path", ":: /missing-page :: route/file not found", ], })), ...[false, true].map((anchors) => ({ name: `unpublished permalink routes (anchors=${anchors})`, anchors, source: [ "---", "permalink: /unpublished", "---", "[unpublished](/unpublished)", "[fragment](/unpublished#connection)", "[valid](/page#connection)", '', ], navigation: ["unpublished"], broken: 3, diagnostics: [ "page.mdx:4 :: /unpublished :: route/file not found", "page.mdx:5 :: /unpublished#connection :: route/file not found", "docs.json:unknown :: unpublished :: navigation page not published", ], })), { name: "shared emitted fragments", anchors: true, broken: 4, source: [ "## agents.defaults.cwd", "", '', "", "## Nested", "", "", "", "[legacy](#agents-defaults-cwd)", "[canonical](#agents.defaults.cwd)", "[component](#connection)", "[nested](https://docs.openclaw.ai/page#nested)", "[absent](#missing)", "[relative](./page.mdx#connection)", "[raw Markdown](/page.md#connection)", "[root](/#root)", "[missing root alias](/index#missing)", "[unpublished permalink](/unpublished#connection)", "[dropped incoming fragment](/drops#missing)", "[redirect](/legacy#wrong-incoming-fragment)", "[chain](/middle#also-wrong)", "[reference][ref]", "", "[ref]: /page#connection", "", "```md", "## Phantom", "[hidden](/hidden-code)", "```", '', ], }, ])( "audits real CLI links after $name", ({ source, broken, anchors = false, diagnostics, files = {}, navigation = [], redirects }) => { const tempDirs: string[] = []; const fixtureRoot = makeTempDir(tempDirs, "docs-link-audit-cli-"); const docsRoot = path.join(fixtureRoot, "docs"); const clawHubRoot = path.join(fixtureRoot, "clawhub"); const home = path.join(fixtureRoot, "home"); fs.mkdirSync(docsRoot); fs.mkdirSync(path.join(clawHubRoot, "docs"), { recursive: true }); fs.mkdirSync(home); fs.writeFileSync( path.join(docsRoot, "docs.json"), JSON.stringify({ navigation: [{ pages: navigation }], redirects: redirects ?? (anchors && !diagnostics ? [ { source: "/legacy", destination: "/page#connection" }, { source: "/middle", destination: "/legacy#nested" }, { source: "/drops", destination: "/next" }, ] : []), }), ); for (const [file, content] of Object.entries(files)) { fs.writeFileSync(path.join(fixtureRoot, file), content); } if (anchors) { fs.writeFileSync(path.join(docsRoot, "index.md"), "## Root\n\n[next](next#target)\n"); fs.writeFileSync(path.join(docsRoot, "next.md"), "## Target\n"); } const pageSource = anchors && !diagnostics ? ["---", "permalink: /unpublished", "---", ...source] : source; fs.writeFileSync(path.join(docsRoot, "page.mdx"), `${pageSource.join("\n")}\n`); try { const result = spawnSync( process.execPath, [ fileURLToPath(new URL("../../scripts/docs-link-audit.mjs", import.meta.url)), ...(anchors ? ["--anchors"] : []), ], { cwd: fixtureRoot, encoding: "utf8", env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)), OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: clawHubRoot, }, timeout: 30_000, }, ); expect(result.error).toBeUndefined(); expect(result.stderr).toBe(""); expect(result.status, result.stdout).toBe(broken ? 1 : 0); if (!anchors) { expect(result.stdout).toContain(`checked_internal_links=${broken + 1}\n`); } expect(result.stdout).toContain(`broken_links=${broken}\n`); expect(result.stdout).not.toContain("/hidden-"); if (anchors && !diagnostics) { expect(result.stdout).toContain("#missing :: fragment not found"); expect(result.stdout).toContain("/unpublished#connection :: route/file not found"); } for (const diagnostic of diagnostics ?? []) { expect(result.stdout).toContain(diagnostic); } if (broken && !anchors && !diagnostics) { expect(result.stdout).toContain( `page.mdx:${source.findIndex((line) => line.includes("/missing-page")) + 1} :: /missing-page :: route/file not found`, ); } } finally { cleanupTempDirs(tempDirs); } }, ); describe("ClawHub routes mirrored from openclaw/clawhub", () => { // /clawhub/** pages are authored upstream and injected by the publisher, so a // checkout without the ClawHub source has the navigation entries but no pages. const buildDocsTree = ( tempDirs: string[], link: string, options: { redirects?: Array<{ source: string; destination: string }>; root?: string } = {}, ) => { const docsRoot = path.join( options.root ?? makeTempDir(tempDirs, "docs-clawhub-mirror-"), "docs", ); fs.mkdirSync(docsRoot, { recursive: true }); fs.writeFileSync( path.join(docsRoot, "docs.json"), JSON.stringify({ navigation: [{ group: "ClawHub", pages: ["clawhub/index", "clawhub/publishing"] }], redirects: options.redirects ?? [{ source: "/tools/clawhub", destination: "/clawhub" }], }), ); fs.writeFileSync(path.join(docsRoot, "page.md"), `## Page\n\n[hub](${link})\n`); return docsRoot; }; it("accepts declared mirrored routes in anchors mode when the source is absent", () => { const tempDirs: string[] = []; try { const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/clawhub/publishing"), allowExternalClawHubRoutes: true, anchors: true, }); expect(result.broken).toEqual([]); expect(result.unverifiedMirroredFragments).toBe(0); } finally { cleanupTempDirs(tempDirs); } }); it("reports fragments into mirrored routes as unverified rather than missing", () => { const tempDirs: string[] = []; try { const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/clawhub/publishing#package-publish-source"), allowExternalClawHubRoutes: true, anchors: true, }); expect(result.unverifiedMirroredFragments).toBe(1); expect(result.broken).toHaveLength(1); expect(result.broken[0]?.reason).toContain("fragment unverified"); expect(result.broken[0]?.reason).toContain("OPENCLAW_DOCS_SYNC_CLAWHUB_REPO"); } finally { cleanupTempDirs(tempDirs); } }); it("leaves fragments into mirrored routes alone in plain mode", () => { const tempDirs: string[] = []; try { // Plain mode has never inspected fragments; the declared route is proof // enough, so the unverifiable fragment must not become a broken link. const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/clawhub/publishing#package-publish-source"), allowExternalClawHubRoutes: true, }); expect(result.broken).toEqual([]); expect(result.unverifiedMirroredFragments).toBe(0); } finally { cleanupTempDirs(tempDirs); } }); it("exits clean from the CLI in plain mode for a fragment into a mirrored route", () => { const tempDirs: string[] = []; try { // Nest the fixture so `/../clawhub` cannot accidentally resolve and // turn the allowance off: this run must be the source-absent shape. const fixtureRoot = path.join(makeTempDir(tempDirs, "docs-clawhub-mirror-cli-"), "repo"); const home = path.join(fixtureRoot, "home"); buildDocsTree(tempDirs, "/clawhub/publishing#package-publish-source", { root: fixtureRoot, }); fs.mkdirSync(home, { recursive: true }); const result = spawnSync( process.execPath, [fileURLToPath(new URL("../../scripts/docs-link-audit.mjs", import.meta.url))], { cwd: fixtureRoot, encoding: "utf8", env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)), }, timeout: 30_000, }, ); expect(result.error).toBeUndefined(); expect(result.stdout).toContain("broken_links=0\n"); expect(result.stdout).not.toContain("fragment unverified"); expect(result.status).toBe(0); } finally { cleanupTempDirs(tempDirs); } }); it("reports unverified fragments in redirect destinations into mirrored routes", () => { const tempDirs: string[] = []; try { const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/page", { redirects: [ { source: "/tools/clawhub", destination: "/clawhub/publishing#package-publish-source", }, ], }), allowExternalClawHubRoutes: true, anchors: true, }); expect(result.unverifiedMirroredFragments).toBe(1); expect(result.broken).toHaveLength(1); expect(result.broken[0]?.file).toBe("docs.json"); expect(result.broken[0]?.link).toBe("/tools/clawhub"); expect(result.broken[0]?.reason).toContain("fragment unverified"); expect(result.broken[0]?.reason).toContain("OPENCLAW_DOCS_SYNC_CLAWHUB_REPO"); } finally { cleanupTempDirs(tempDirs); } }); it("keeps mirrored redirect destinations silent in plain mode", () => { const tempDirs: string[] = []; try { const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/page", { redirects: [ { source: "/tools/clawhub", destination: "/clawhub/publishing#package-publish-source", }, ], }), allowExternalClawHubRoutes: true, }); expect(result.broken).toEqual([]); expect(result.unverifiedMirroredFragments).toBe(0); } finally { cleanupTempDirs(tempDirs); } }); it("still reports undeclared routes under /clawhub as missing", () => { const tempDirs: string[] = []; try { const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/clawhub/not-in-navigation"), allowExternalClawHubRoutes: true, anchors: true, }); expect(result.broken).toHaveLength(1); expect(result.broken[0]?.reason).toContain("route/file not found"); } finally { cleanupTempDirs(tempDirs); } }); it("does not accept mirrored routes when the allowance is off", () => { const tempDirs: string[] = []; try { const result = auditDocsLinks({ docsDir: buildDocsTree(tempDirs, "/clawhub/publishing"), allowExternalClawHubRoutes: false, anchors: true, }); expect(result.broken.some((item) => item.reason.includes("route/file not found"))).toBe( true, ); } finally { cleanupTempDirs(tempDirs); } }); }); it("normalizes route fragments away", () => { expect(normalizeRoute("/plugins/building-plugins#registering-agent-tools")).toBe( "/plugins/building-plugins", ); expect(normalizeRoute("/plugins/building-plugins?tab=all")).toBe("/plugins/building-plugins"); }); it("prepares every external-link input without exposing code literals", () => { const tempDirs: string[] = []; const fixtureRoot = makeTempDir(tempDirs, "docs-external-link-audit-"); const docsRoot = path.join(fixtureRoot, "docs"); const source = [ "", ' ', " [reasoning](https://docs.example.test/reasoning)", " `https://api.example.test/v1`", " ````markdown", " ```text", " ", " ```", " ~~~", " [code literal](https://code.example.test)", " ~~~", " ````", " - ```html", '