| 1 | /** |
| 2 | * The public docs: every page builds, every link and anchor resolves, the |
| 3 | * generated references match the code, and the agent formats are complete. |
| 4 | * Needs src/docs/content.generated.ts (`pnpm build` or `pnpm build:docs`). |
| 5 | */ |
| 6 | import { describe, expect, it } from "vitest"; |
| 7 | import { app } from "../../src/app.js"; |
| 8 | import { web } from "../../src/web/routes.js"; |
| 9 | import { slugify } from "../../src/web/markdown.js"; |
| 10 | import type { Env } from "../../src/env.js"; |
| 11 | import { mcpTools } from "../../src/mcp/server.js"; |
| 12 | import { docsSlug } from "../../src/mcp/docs.js"; |
| 13 | import { ACCOUNT_SCOPES, API_DOCS } from "../../src/docs/api-meta.js"; |
| 14 | import { allPages, getPage, headings, llmsFullTxt, llmsTxt, searchDocs, SLUGS } from "../../src/docs/pages.js"; |
| 15 | import { apiOperations, openApiDocument, openApiPath, OTHER_ENDPOINTS } from "../../src/docs/reference.js"; |
| 16 | |
| 17 | // index.ts mounts the web UI last; do the same here (it can't load Durable Objects under Node). |
| 18 | app.route("/", web); |
| 19 | |
| 20 | const ORIGIN = "https://diffinery.example"; |
| 21 | // An empty D1: the docs need no data, and the sitemap finds no public repositories. |
| 22 | const stmt = { bind: () => stmt, all: async () => ({ results: [] }), first: async () => null, run: async () => ({ success: true, meta: {} }) }; |
| 23 | const env = { PUBLIC_URL: ORIGIN, DB: { prepare: () => stmt } } as unknown as Env; |
| 24 | const ctx = { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext; |
| 25 | const get = (path: string, headers: Record<string, string> = {}) => app.fetch(new Request(`${ORIGIN}${path}`, { headers }), env, ctx); |
| 26 | |
| 27 | const pages = allPages(ORIGIN); |
| 28 | |
| 29 | /** Markdown with fenced code blocks and inline code removed, so examples aren't checked as prose. */ |
| 30 | function prose(md: string): string { |
| 31 | return md.replace(/^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1\s*$/gm, "").replace(/`[^`\n]*`/g, ""); |
| 32 | } |
| 33 | |
| 34 | /** Every heading id on a page (all levels). */ |
| 35 | function ids(md: string): Set<string> { |
| 36 | const out = new Set<string>(); |
| 37 | for (const line of prose(md).split("\n")) { |
| 38 | const h = /^ {0,3}#{1,6} +(.+?)\s*#*\s*$/.exec(line); |
| 39 | if (h) out.add(slugify(h[1]!)); |
| 40 | } |
| 41 | return out; |
| 42 | } |
| 43 | |
| 44 | /** Paths outside /docs that pages may link to: real routes of the app. */ |
| 45 | const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/status$/, /^\/healthz$/]; |
| 46 | |
| 47 | describe("docs pages", () => { |
| 48 | it("has every page in the navigation, in order", () => { |
| 49 | expect(pages.map((p) => p.slug)).toEqual(SLUGS); |
| 50 | expect(SLUGS.length).toBeGreaterThan(40); |
| 51 | for (const p of pages) { |
| 52 | expect(p.title, p.slug).toBeTruthy(); |
| 53 | expect(p.description.length, `${p.slug} description`).toBeGreaterThan(20); |
| 54 | expect(p.markdown.length, `${p.slug} is too short`).toBeGreaterThan(400); |
| 55 | } |
| 56 | }); |
| 57 | |
| 58 | it.each(pages.map((p) => [p.slug, p] as const))("%s: links and anchors resolve", (slug, p) => { |
| 59 | const problems: string[] = []; |
| 60 | const text = prose(p.markdown); |
| 61 | for (const m of text.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { |
| 62 | const href = m[1]!; |
| 63 | if (/^https?:\/\//.test(href)) { |
| 64 | if (href.startsWith(ORIGIN) || href.startsWith("https://diffinery.com/docs")) problems.push(`absolute link to the docs (use a /docs/... path): ${href}`); |
| 65 | continue; |
| 66 | } |
| 67 | if (href.startsWith("#")) { |
| 68 | if (!ids(p.markdown).has(href.slice(1))) problems.push(`no heading for ${href}`); |
| 69 | continue; |
| 70 | } |
| 71 | if (!href.startsWith("/")) { |
| 72 | problems.push(`relative link (use an absolute /docs/... path): ${href}`); |
| 73 | continue; |
| 74 | } |
| 75 | const [path, frag] = href.split("#") as [string, string | undefined]; |
| 76 | if (path === "/docs" || path.startsWith("/docs/")) { |
| 77 | if (path === "/docs/api/openapi.json") continue; |
| 78 | const target = path === "/docs" ? "index" : path.slice(6).replace(/\.md$/, ""); |
| 79 | const t = getPage(target, ORIGIN); |
| 80 | if (!t) problems.push(`no docs page for ${href}`); |
| 81 | else if (frag && !ids(t.markdown).has(frag)) problems.push(`no heading #${frag} on ${target}`); |
| 82 | } else if (!SITE_PATHS.some((re) => re.test(path))) problems.push(`link to ${path} isn't a docs page or a known site path`); |
| 83 | } |
| 84 | expect(problems.join("\n"), slug).toBe(""); |
| 85 | }); |
| 86 | |
| 87 | it.each(pages.map((p) => [p.slug, p] as const))("%s: headings are unique and there is no raw HTML", (slug, p) => { |
| 88 | const seen = new Map<string, number>(); |
| 89 | for (const h of headings(p.markdown)) seen.set(h.id, (seen.get(h.id) ?? 0) + 1); |
| 90 | expect([...seen].filter(([, n]) => n > 1).map(([id]) => id), `${slug}: duplicate headings`).toEqual([]); |
| 91 | // The renderer escapes HTML, so a tag in prose would show up as literal text. (Placeholders like <name> are fine.) |
| 92 | expect(prose(p.markdown).match(/<\/?(a|b|i|em|strong|br|p|div|span|img|table|tr|td|th|ul|ol|li|details|summary|code|pre|kbd|sup|sub|hr|h[1-6])(\s[^>]*)?\/?>/gi) ?? [], `${slug}: raw HTML`).toEqual([]); |
| 93 | }); |
| 94 | }); |
| 95 | |
| 96 | describe("generated references", () => { |
| 97 | it("has notes only for endpoints that exist", () => { |
| 98 | const routes = new Set(apiOperations().map((o) => `${o.method} ${o.path}`)); |
| 99 | expect(Object.keys(API_DOCS).filter((k) => !routes.has(k))).toEqual([]); |
| 100 | expect(Object.keys(ACCOUNT_SCOPES).filter((k) => !routes.has(k))).toEqual([]); |
| 101 | }); |
| 102 | |
| 103 | it("documents every endpoint under /api/v3", () => { |
| 104 | const ops = apiOperations(); |
| 105 | expect(ops.length).toBeGreaterThan(100); |
| 106 | for (const o of ops) expect(o.doc.summary, `${o.method} ${o.path}`).toMatch(/\w/); |
| 107 | const page = getPage("api", ORIGIN)!.markdown; |
| 108 | for (const o of ops) expect(page, `${o.method} ${o.path}`).toContain(`${o.method} /api/v3${o.path === "/" ? "" : o.path}`); |
| 109 | }); |
| 110 | |
| 111 | it("lists other endpoints that are really routed", () => { |
| 112 | const routed = app.routes.map((r) => openApiPath(r.path)); |
| 113 | for (const [endpoint] of OTHER_ENDPOINTS) { |
| 114 | const path = endpoint.replace(/^[A-Z, ]+ /, "").replace(".git/", "/").replace("{run_id}", "{runId}").replace("{owner}/{repo}", "{owner}/{repo}"); |
| 115 | const want = path === "/v2/..." ? "/v2/{path}" : path; |
| 116 | const docs = ["/llms.txt", "/llms-full.txt", "/docs/{page}.md", "/docs/api/openapi.json"].includes(want); |
| 117 | expect(docs || routed.includes(want), endpoint).toBe(true); |
| 118 | } |
| 119 | }); |
| 120 | |
| 121 | it("builds a valid OpenAPI 3.1 document", () => { |
| 122 | const doc = openApiDocument(ORIGIN, "test") as { openapi: string; paths: Record<string, Record<string, { operationId: string }>> }; |
| 123 | expect(doc.openapi).toBe("3.1.0"); |
| 124 | const opIds = Object.values(doc.paths).flatMap((p) => Object.values(p).map((o) => o.operationId)); |
| 125 | expect(new Set(opIds).size).toBe(opIds.length); |
| 126 | expect(opIds.length).toBe(apiOperations().length); |
| 127 | }); |
| 128 | |
| 129 | it("documents every MCP tool", () => { |
| 130 | const page = getPage("reference/mcp-tools", ORIGIN)!.markdown; |
| 131 | const tools = mcpTools(); |
| 132 | expect(tools.length).toBeGreaterThan(20); |
| 133 | for (const t of tools) { |
| 134 | expect(t.description.length, t.name).toBeGreaterThan(10); |
| 135 | expect(page).toContain(`### ${t.name}`); |
| 136 | } |
| 137 | }); |
| 138 | |
| 139 | it("documents every CLI command", () => { |
| 140 | const page = getPage("reference/cli", ORIGIN)!.markdown; |
| 141 | for (const cmd of ["login", "repo create", "pr create", "pr merge", "issue create", "run list", "workflow run", "secret set", "runner", "migrate"]) { |
| 142 | expect(page, cmd).toMatch(new RegExp(`#+ diffinery ${cmd}\\b`)); |
| 143 | } |
| 144 | }); |
| 145 | }); |
| 146 | |
| 147 | describe("agent formats", () => { |
| 148 | it("llms.txt links every page as Markdown", () => { |
| 149 | const txt = llmsTxt(ORIGIN); |
| 150 | expect(txt.startsWith("# Diffinery\n\n> ")).toBe(true); |
| 151 | for (const s of SLUGS) expect(txt).toContain(`(${ORIGIN}/docs/${s}.md)`); |
| 152 | }); |
| 153 | |
| 154 | it("llms-full.txt holds every page", () => { |
| 155 | const txt = llmsFullTxt(ORIGIN); |
| 156 | for (const p of pages) expect(txt).toContain(`# ${p.title}\n\n> ${p.description}\n\nSource: ${ORIGIN}`); |
| 157 | expect(txt).not.toMatch(/\]\(\/docs/); |
| 158 | }); |
| 159 | |
| 160 | it("finds pages by search", () => { |
| 161 | expect(searchDocs(ORIGIN, "merge queue")[0]?.page).toBeTruthy(); |
| 162 | expect(searchDocs(ORIGIN, "zzzz-not-a-word")).toEqual([]); |
| 163 | }); |
| 164 | |
| 165 | it("read_docs accepts slugs, paths and URLs, with .md, queries and anchors", () => { |
| 166 | for (const input of ["actions/events", "/docs/actions/events", `${ORIGIN}/docs/actions/events.md`, `${ORIGIN}/docs/actions/events.md#push`, "/docs/actions/events/?x=1#push"]) expect(docsSlug(input), input).toBe("actions/events"); |
| 167 | for (const input of ["", "/docs", `${ORIGIN}/docs/`, "index.md", `${ORIGIN}/docs/index.md#start-here`]) expect(docsSlug(input), input).toBe("index"); |
| 168 | }); |
| 169 | }); |
| 170 | |
| 171 | describe("docs routes", () => { |
| 172 | it("serves the docs without signing in", async () => { |
| 173 | const res = await get("/docs"); |
| 174 | expect(res.status).toBe(200); |
| 175 | const html = await res.text(); |
| 176 | expect(html).toContain("<h1>Overview</h1>"); |
| 177 | expect(html).toContain('rel="alternate" type="text/markdown" href="/docs/index.md"'); |
| 178 | expect(html).not.toContain('name="robots"'); |
| 179 | }); |
| 180 | |
| 181 | it("highlights code examples", async () => { |
| 182 | const html = await (await get("/docs/actions/workflow-syntax")).text(); |
| 183 | expect(html).toMatch(/<pre[^>]*><code[^>]*>[\s\S]*?<span style="/); |
| 184 | }); |
| 185 | |
| 186 | it("serves every page as HTML and as Markdown", async () => { |
| 187 | for (const s of SLUGS) { |
| 188 | const html = await get(s === "index" ? "/docs" : `/docs/${s}`); |
| 189 | expect(html.status, s).toBe(200); |
| 190 | const md = await get(`/docs/${s}.md`); |
| 191 | expect(md.status, `${s}.md`).toBe(200); |
| 192 | expect(md.headers.get("content-type")).toContain("text/markdown"); |
| 193 | } |
| 194 | const neg = await get("/docs/quickstart", { accept: "text/markdown, text/html;q=0.8" }); |
| 195 | expect(neg.headers.get("content-type")).toContain("text/markdown"); |
| 196 | // Renders and highlights every page twice: about 1 s alone, several times that on a busy CI runner. |
| 197 | }, 30_000); |
| 198 | |
| 199 | it("serves llms.txt, llms-full.txt, OpenAPI, the search index and the CLI", async () => { |
| 200 | for (const [path, type] of [ |
| 201 | ["/llms.txt", "text/plain"], |
| 202 | ["/llms-full.txt", "text/plain"], |
| 203 | ["/docs/api/openapi.json", "application/json"], |
| 204 | ["/docs/search.json", "application/json"], |
| 205 | ["/-/cli/diffinery.mjs", "text/javascript"], |
| 206 | ]) { |
| 207 | const res = await get(path!); |
| 208 | expect(res.status, path).toBe(200); |
| 209 | expect(res.headers.get("content-type"), path).toContain(type); |
| 210 | } |
| 211 | expect(await (await get("/-/cli/diffinery.mjs")).text()).toMatch(/^#!\/usr\/bin\/env node\n/); |
| 212 | }); |
| 213 | |
| 214 | it("returns 404 for unknown pages", async () => { |
| 215 | expect((await get("/docs/no-such-page")).status).toBe(404); |
| 216 | expect((await get("/docs/no-such-page.md")).status).toBe(404); |
| 217 | }); |
| 218 | |
| 219 | it("lists the docs in the sitemap", async () => { |
| 220 | const res = await get("/sitemap.xml"); |
| 221 | expect(res.status).toBe(200); |
| 222 | expect(await res.text()).toContain(`${ORIGIN}/docs/quickstart`); |
| 223 | }); |
| 224 | }); |