| 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 { DOCS_TOOLS, docsSlug } from "../../src/mcp/docs.js"; |
| 13 | import { ACCOUNT_SCOPES, API_DOCS } from "../../src/docs/api-meta.js"; |
| 14 | import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, searchDocs, SECTIONS, sectionLlmsFullTxt, SLUGS } from "../../src/docs/pages.js"; |
| 15 | import { sitePage, sitePages } from "../../src/web/site.js"; |
| 16 | import { apiOperations, openApiDocument, openApiPath, OTHER_ENDPOINTS } from "../../src/docs/reference.js"; |
| 17 | |
| 18 | // index.ts mounts the web UI last; do the same here (it can't load Durable Objects under Node). |
| 19 | app.route("/", web); |
| 20 | |
| 21 | const ORIGIN = "https://diffinery.example"; |
| 22 | // An empty D1: the docs need no data, and the sitemap finds no public repositories. |
| 23 | const stmt = { bind: () => stmt, all: async () => ({ results: [] }), first: async () => null, run: async () => ({ success: true, meta: {} }) }; |
| 24 | const env = { PUBLIC_URL: ORIGIN, DB: { prepare: () => stmt } } as unknown as Env; |
| 25 | const ctx = { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext; |
| 26 | const get = (path: string, headers: Record<string, string> = {}) => app.fetch(new Request(`${ORIGIN}${path}`, { headers }), env, ctx); |
| 27 | |
| 28 | const pages = allPages(ORIGIN); |
| 29 | |
| 30 | /** Markdown with fenced code blocks and inline code removed, so examples aren't checked as prose. */ |
| 31 | function prose(md: string): string { |
| 32 | return md.replace(/^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1\s*$/gm, "").replace(/`[^`\n]*`/g, ""); |
| 33 | } |
| 34 | |
| 35 | /** Every heading id on a page (all levels). */ |
| 36 | function ids(md: string): Set<string> { |
| 37 | const out = new Set<string>(); |
| 38 | for (const line of prose(md).split("\n")) { |
| 39 | const h = /^ {0,3}#{1,6} +(.+?)\s*#*\s*$/.exec(line); |
| 40 | if (h) out.add(slugify(h[1]!)); |
| 41 | } |
| 42 | return out; |
| 43 | } |
| 44 | |
| 45 | /** Paths outside /docs that pages may link to: real routes of the app. */ |
| 46 | const SITE_PATHS = [/^\/$/, /^\/llms(-full|-small)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/early-access(\/remove)?$/, /^\/contact$/, /^\/status$/, /^\/healthz$/]; |
| 47 | |
| 48 | describe("docs pages", () => { |
| 49 | it("has every page in the navigation, in order", () => { |
| 50 | expect(pages.map((p) => p.slug)).toEqual(SLUGS); |
| 51 | expect(SLUGS.length).toBeGreaterThan(40); |
| 52 | for (const p of pages) { |
| 53 | expect(p.title, p.slug).toBeTruthy(); |
| 54 | expect(p.description.length, `${p.slug} description`).toBeGreaterThan(20); |
| 55 | expect(p.markdown.length, `${p.slug} is too short`).toBeGreaterThan(400); |
| 56 | } |
| 57 | }); |
| 58 | |
| 59 | it.each(pages.map((p) => [p.slug, p] as const))("%s: links and anchors resolve", (slug, p) => { |
| 60 | const problems: string[] = []; |
| 61 | const text = prose(p.markdown); |
| 62 | for (const m of text.matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { |
| 63 | const href = m[1]!; |
| 64 | if (/^https?:\/\//.test(href)) { |
| 65 | if (href.startsWith(ORIGIN) || href.startsWith("https://diffinery.com/docs")) problems.push(`absolute link to the docs (use a /docs/... path): ${href}`); |
| 66 | continue; |
| 67 | } |
| 68 | if (href.startsWith("#")) { |
| 69 | if (!ids(p.markdown).has(href.slice(1))) problems.push(`no heading for ${href}`); |
| 70 | continue; |
| 71 | } |
| 72 | if (!href.startsWith("/")) { |
| 73 | problems.push(`relative link (use an absolute /docs/... path): ${href}`); |
| 74 | continue; |
| 75 | } |
| 76 | const [path, frag] = href.split("#") as [string, string | undefined]; |
| 77 | if (path === "/docs" || path.startsWith("/docs/")) { |
| 78 | if (path === "/docs/api/openapi.json") continue; |
| 79 | if (path.startsWith("/docs/images/")) continue; // checked when the docs are built (scripts/build-docs.mjs) |
| 80 | const llms = /^\/docs\/([\w-]+)\/llms(-full)?\.txt$/.exec(path); |
| 81 | if (llms) { |
| 82 | if (!SECTIONS.some((x) => x.slug === llms[1])) problems.push(`no docs section for ${href}`); |
| 83 | continue; |
| 84 | } |
| 85 | const target = path === "/docs" ? "index" : path.slice(6).replace(/\.md$/, ""); |
| 86 | const t = getPage(target, ORIGIN); |
| 87 | if (!t) problems.push(`no docs page for ${href}`); |
| 88 | else if (frag && !ids(t.markdown).has(frag)) problems.push(`no heading #${frag} on ${target}`); |
| 89 | } else if (sitePage(path)) { |
| 90 | // The product, comparison and company pages (site/). |
| 91 | if (frag && !ids(sitePage(path)!.markdown).has(frag)) problems.push(`no heading #${frag} on ${path}`); |
| 92 | } else if (!SITE_PATHS.some((re) => re.test(path))) problems.push(`link to ${path} isn't a docs page or a known site path`); |
| 93 | } |
| 94 | expect(problems.join("\n"), slug).toBe(""); |
| 95 | }); |
| 96 | |
| 97 | it.each(pages.map((p) => [p.slug, p] as const))("%s: headings are unique and there is no raw HTML", (slug, p) => { |
| 98 | const seen = new Map<string, number>(); |
| 99 | for (const h of headings(p.markdown)) seen.set(h.id, (seen.get(h.id) ?? 0) + 1); |
| 100 | expect([...seen].filter(([, n]) => n > 1).map(([id]) => id), `${slug}: duplicate headings`).toEqual([]); |
| 101 | // The renderer escapes HTML, so a tag in prose would show up as literal text. (Placeholders like <name> are fine.) |
| 102 | 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([]); |
| 103 | }); |
| 104 | }); |
| 105 | |
| 106 | describe("generated references", () => { |
| 107 | it("has notes only for endpoints that exist", () => { |
| 108 | const routes = new Set(apiOperations().map((o) => `${o.method} ${o.path}`)); |
| 109 | expect(Object.keys(API_DOCS).filter((k) => !routes.has(k))).toEqual([]); |
| 110 | expect(Object.keys(ACCOUNT_SCOPES).filter((k) => !routes.has(k))).toEqual([]); |
| 111 | }); |
| 112 | |
| 113 | it("documents every endpoint under /api/v3", () => { |
| 114 | const ops = apiOperations(); |
| 115 | expect(ops.length).toBeGreaterThan(100); |
| 116 | for (const o of ops) expect(o.doc.summary, `${o.method} ${o.path}`).toMatch(/\w/); |
| 117 | const page = getPage("api", ORIGIN)!.markdown; |
| 118 | for (const o of ops) expect(page, `${o.method} ${o.path}`).toContain(`${o.method} /api/v3${o.path === "/" ? "" : o.path}`); |
| 119 | }); |
| 120 | |
| 121 | it("lists other endpoints that are really routed", () => { |
| 122 | const routed = app.routes.map((r) => openApiPath(r.path)); |
| 123 | for (const [endpoint] of OTHER_ENDPOINTS) { |
| 124 | const path = endpoint.replace(/^[A-Z, ]+ /, "").replace(".git/", "/").replace("{run_id}", "{runId}").replace("{owner}/{repo}", "{owner}/{repo}"); |
| 125 | const want = path === "/v2/..." ? "/v2/{path}" : path; |
| 126 | const docs = ["/llms.txt", "/llms-full.txt", "/docs/{page}.md", "/docs/api/openapi.json"].includes(want); |
| 127 | expect(docs || routed.includes(want), endpoint).toBe(true); |
| 128 | } |
| 129 | }); |
| 130 | |
| 131 | it("builds a valid OpenAPI 3.1 document", () => { |
| 132 | const doc = openApiDocument(ORIGIN, "test") as { openapi: string; paths: Record<string, Record<string, { operationId: string }>> }; |
| 133 | expect(doc.openapi).toBe("3.1.0"); |
| 134 | const opIds = Object.values(doc.paths).flatMap((p) => Object.values(p).map((o) => o.operationId)); |
| 135 | expect(new Set(opIds).size).toBe(opIds.length); |
| 136 | expect(opIds.length).toBe(apiOperations().length); |
| 137 | }); |
| 138 | |
| 139 | it("documents every MCP tool", () => { |
| 140 | const page = getPage("reference/mcp-tools", ORIGIN)!.markdown; |
| 141 | const tools = mcpTools(); |
| 142 | expect(tools.length).toBeGreaterThan(20); |
| 143 | for (const t of tools) { |
| 144 | expect(t.description.length, t.name).toBeGreaterThan(10); |
| 145 | expect(page).toContain(`### ${t.name}`); |
| 146 | } |
| 147 | }); |
| 148 | |
| 149 | it("documents every CLI command", () => { |
| 150 | const page = getPage("reference/cli", ORIGIN)!.markdown; |
| 151 | for (const cmd of ["login", "repo create", "pr create", "pr merge", "issue create", "run list", "workflow run", "secret set", "runner", "migrate"]) { |
| 152 | expect(page, cmd).toMatch(new RegExp(`#+ diffinery ${cmd}\\b`)); |
| 153 | } |
| 154 | }); |
| 155 | }); |
| 156 | |
| 157 | describe("agent formats", () => { |
| 158 | it("llms.txt links every page as Markdown", () => { |
| 159 | const txt = llmsTxt(ORIGIN); |
| 160 | expect(txt.startsWith("# Diffinery\n\n> ")).toBe(true); |
| 161 | for (const s of SLUGS) expect(txt).toContain(`(${ORIGIN}/docs/${s}.md)`); |
| 162 | }); |
| 163 | |
| 164 | it("llms-full.txt holds every page", () => { |
| 165 | const txt = llmsFullTxt(ORIGIN); |
| 166 | for (const p of pages) expect(txt).toContain(`# ${p.title}\n\n> ${p.description}\n\nSource: ${ORIGIN}`); |
| 167 | expect(txt).not.toMatch(/\]\(\/docs/); |
| 168 | }); |
| 169 | |
| 170 | it("finds pages by search, the site's comparison pages included", () => { |
| 171 | expect(searchDocs(ORIGIN, "merge queue")[0]?.page).toBeTruthy(); |
| 172 | expect(searchDocs(ORIGIN, "zzzz-not-a-word")).toEqual([]); |
| 173 | const hit = searchDocs(ORIGIN, "blacksmith")[0]!; |
| 174 | expect(hit.page).toBe("/compare/blacksmith"); |
| 175 | expect(hit.url).toMatch(new RegExp(`^${ORIGIN}/compare/blacksmith(#|$)`)); |
| 176 | expect(hit.markdown_url).toBe(`${ORIGIN}/compare/blacksmith.md`); |
| 177 | }); |
| 178 | |
| 179 | it("read_docs reads site pages by path or URL", async () => { |
| 180 | for (const page of ["/compare/github", "compare/github", `${ORIGIN}/compare/github.md`]) { |
| 181 | const r = (await DOCS_TOOLS.read_docs!.run({ env } as never, { page })) as { page: string; markdown: string }; |
| 182 | expect(r.page, page).toBe("/compare/github"); |
| 183 | expect(r.markdown.startsWith(`# ${sitePage("/compare/github")!.title}\n`)).toBe(true); |
| 184 | } |
| 185 | }); |
| 186 | |
| 187 | it("splits the docs into one llms file per section, and a small file with the site and the essentials", () => { |
| 188 | const full = llmsTxt(ORIGIN); |
| 189 | for (const sec of SECTIONS) { |
| 190 | expect(full).toContain(`${ORIGIN}/docs/${sec.slug}/llms-full.txt`); |
| 191 | const txt = sectionLlmsFullTxt(ORIGIN, sec.slug)!; |
| 192 | for (const slug of sec.pages) expect(txt).toContain(`Source: ${ORIGIN}${slug === "index" ? "/docs" : `/docs/${slug}`}\n`); |
| 193 | } |
| 194 | expect(sectionLlmsFullTxt(ORIGIN, "no-such-section")).toBeNull(); |
| 195 | const small = llmsSmallTxt(ORIGIN); |
| 196 | for (const p of sitePages()) expect(small).toContain(`Source: ${ORIGIN}${p.path} (last updated`); |
| 197 | expect(small).toContain(`Source: ${ORIGIN}/docs/quickstart\n`); |
| 198 | expect(small).not.toContain("# REST API reference\n"); |
| 199 | // Small enough for an assistant's context: well under a tenth of llms-full.txt. |
| 200 | expect(small.length).toBeLessThan(llmsFullTxt(ORIGIN).length / 4); |
| 201 | }); |
| 202 | |
| 203 | it("read_docs accepts slugs, paths and URLs, with .md, queries and anchors", () => { |
| 204 | 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"); |
| 205 | for (const input of ["", "/docs", `${ORIGIN}/docs/`, "index.md", `${ORIGIN}/docs/index.md#start-here`]) expect(docsSlug(input), input).toBe("index"); |
| 206 | }); |
| 207 | }); |
| 208 | |
| 209 | describe("docs routes", () => { |
| 210 | it("serves the docs without signing in", async () => { |
| 211 | const res = await get("/docs"); |
| 212 | expect(res.status).toBe(200); |
| 213 | const html = await res.text(); |
| 214 | expect(html).toContain("<h1>Overview</h1>"); |
| 215 | expect(html).toContain('rel="alternate" type="text/markdown" href="/docs/index.md"'); |
| 216 | expect(html).not.toContain('name="robots"'); |
| 217 | }); |
| 218 | |
| 219 | it("highlights code examples", async () => { |
| 220 | const html = await (await get("/docs/actions/workflow-syntax")).text(); |
| 221 | expect(html).toMatch(/<pre[^>]*><code[^>]*>[\s\S]*?<span style="/); |
| 222 | }); |
| 223 | |
| 224 | it("serves every page as HTML and as Markdown", async () => { |
| 225 | for (const s of SLUGS) { |
| 226 | const html = await get(s === "index" ? "/docs" : `/docs/${s}`); |
| 227 | expect(html.status, s).toBe(200); |
| 228 | const md = await get(`/docs/${s}.md`); |
| 229 | expect(md.status, `${s}.md`).toBe(200); |
| 230 | expect(md.headers.get("content-type")).toContain("text/markdown"); |
| 231 | } |
| 232 | const neg = await get("/docs/quickstart", { accept: "text/markdown, text/html;q=0.8" }); |
| 233 | expect(neg.headers.get("content-type")).toContain("text/markdown"); |
| 234 | // Renders and highlights every page twice: about 1 s alone, several times that on a busy CI runner. |
| 235 | }, 30_000); |
| 236 | |
| 237 | it("serves llms.txt, llms-full.txt, OpenAPI, the search index and the CLI", async () => { |
| 238 | for (const [path, type] of [ |
| 239 | ["/llms.txt", "text/plain"], |
| 240 | ["/llms-full.txt", "text/plain"], |
| 241 | ["/llms-small.txt", "text/plain"], |
| 242 | ["/docs/actions/llms.txt", "text/plain"], |
| 243 | ["/docs/actions/llms-full.txt", "text/plain"], |
| 244 | ["/docs/api/openapi.json", "application/json"], |
| 245 | ["/docs/search.json", "application/json"], |
| 246 | ["/-/cli/diffinery.mjs", "text/javascript"], |
| 247 | ]) { |
| 248 | const res = await get(path!); |
| 249 | expect(res.status, path).toBe(200); |
| 250 | expect(res.headers.get("content-type"), path).toContain(type); |
| 251 | } |
| 252 | expect(await (await get("/-/cli/diffinery.mjs")).text()).toMatch(/^#!\/usr\/bin\/env node\n/); |
| 253 | }); |
| 254 | |
| 255 | it("serves the install scripts, pointed at this server", async () => { |
| 256 | for (const path of ["/install", "/install.sh"]) { |
| 257 | const res = await get(path); |
| 258 | expect(res.status, path).toBe(200); |
| 259 | expect(res.headers.get("content-type")).toContain("text/plain"); |
| 260 | const sh = await res.text(); |
| 261 | expect(sh).toMatch(/^#!\/usr\/bin\/env bash\n/); |
| 262 | expect(sh).toContain(`server=\${DIFFINERY_URL:-${ORIGIN}}`); |
| 263 | expect(sh).not.toContain("__DIFFINERY_URL__"); |
| 264 | expect(sh).toContain(`curl -fsSL ${ORIGIN}/install | bash`); // what to run when piped into sh |
| 265 | } |
| 266 | const ps1 = await (await get("/install.ps1")).text(); |
| 267 | expect(ps1).toContain(`'${ORIGIN}'`); |
| 268 | expect(ps1).toContain("/-/cli/diffinery.mjs"); |
| 269 | }); |
| 270 | |
| 271 | it("serves the JetBrains plugin and the plugin repository the IDEs install and update it from", async () => { |
| 272 | const xml = await get("/-/jetbrains/updatePlugins.xml"); |
| 273 | expect(xml.status).toBe(200); |
| 274 | expect(xml.headers.get("content-type")).toContain("application/xml"); |
| 275 | const body = await xml.text(); |
| 276 | const plugin = body.match(/<plugin id="com\.diffinery" url="([^"]+)" version="([^"]+)">/); |
| 277 | expect(plugin, body).not.toBeNull(); |
| 278 | const [, url, version] = plugin!; |
| 279 | expect(url).toBe(`${ORIGIN}/-/jetbrains/diffinery-jetbrains-${version}.zip`); |
| 280 | expect(body).toMatch(/<idea-version since-build="\d+"\/>/); |
| 281 | expect(body).toContain(`${ORIGIN}/docs/repositories/clone-in-jetbrains-ides`); |
| 282 | for (const path of [new URL(url).pathname, "/-/jetbrains/diffinery-jetbrains.zip"]) { |
| 283 | const zip = await get(path); |
| 284 | expect(zip.status, path).toBe(200); |
| 285 | expect(zip.headers.get("x-diffinery-plugin-version")).toBe(version); |
| 286 | const bytes = new Uint8Array(await zip.arrayBuffer()); |
| 287 | expect(bytes.slice(0, 4)).toEqual(new Uint8Array([0x50, 0x4b, 0x03, 0x04])); |
| 288 | const sha = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (b) => b.toString(16).padStart(2, "0")).join(""); |
| 289 | expect(zip.headers.get("x-diffinery-plugin-sha256")).toBe(sha); |
| 290 | } |
| 291 | }); |
| 292 | |
| 293 | it("serves the Claude Code plugins for claude.ai's Upload plugin", async () => { |
| 294 | for (const name of ["diffinery", "diffinery-ci"]) { |
| 295 | const latest = await get(`/-/claude/${name}.zip`); |
| 296 | expect(latest.status, name).toBe(200); |
| 297 | expect(latest.headers.get("content-type")).toBe("application/zip"); |
| 298 | const version = latest.headers.get("x-diffinery-plugin-version")!; |
| 299 | expect(version).toMatch(/^\d+\.\d+\.\d+$/); |
| 300 | expect(latest.headers.get("content-disposition")).toBe(`attachment; filename="${name}-${version}.zip"`); |
| 301 | const bytes = new Uint8Array(await latest.arrayBuffer()); |
| 302 | expect(bytes.slice(0, 4)).toEqual(new Uint8Array([0x50, 0x4b, 0x03, 0x04])); |
| 303 | const sha = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (b) => b.toString(16).padStart(2, "0")).join(""); |
| 304 | expect(latest.headers.get("x-diffinery-plugin-sha256")).toBe(sha); |
| 305 | const pinned = await get(`/-/claude/${name}-${version}.zip`); |
| 306 | expect(new Uint8Array(await pinned.arrayBuffer())).toEqual(bytes); |
| 307 | expect((await get(`/-/claude/${name}-0.0.0.zip`)).status).toBe(404); |
| 308 | } |
| 309 | for (const path of ["/-/claude/other.zip", "/-/claude/diffinery", "/-/claude/constructor.zip"]) expect((await get(path)).status, path).toBe(404); |
| 310 | }); |
| 311 | |
| 312 | it("serves the screenshots in docs/images", async () => { |
| 313 | const res = await get("/docs/images/connected-apps.webp"); |
| 314 | expect(res.status).toBe(200); |
| 315 | expect(res.headers.get("content-type")).toBe("image/webp"); |
| 316 | expect(new Uint8Array(await res.arrayBuffer()).slice(8, 12)).toEqual(new TextEncoder().encode("WEBP")); |
| 317 | expect((await get("/docs/images/nope.webp")).status).toBe(404); |
| 318 | expect((await get("/docs/images/constructor")).status).toBe(404); |
| 319 | // Pages show them through their own origin, not the image proxy. |
| 320 | expect(await (await get("/docs/connect")).text()).toContain('<img src="/docs/images/connected-apps.webp"'); |
| 321 | }); |
| 322 | |
| 323 | it("sends moved pages to their new address, as HTML and as Markdown", async () => { |
| 324 | const html = await get("/docs/agents/claude-and-codex"); |
| 325 | expect(html.status).toBe(301); |
| 326 | expect(html.headers.get("location")).toBe("/docs/connect"); |
| 327 | expect((await get("/docs/agents/claude-and-codex.md")).headers.get("location")).toBe("/docs/connect.md"); |
| 328 | }); |
| 329 | |
| 330 | it("points agents at the connect guides, the plugin and gh from llms.txt", () => { |
| 331 | const txt = llmsTxt(ORIGIN); |
| 332 | expect(txt).toContain(`${ORIGIN}/docs/connect.md`); |
| 333 | expect(txt).toContain(`codex plugin marketplace add ${ORIGIN}/codician/diffinery.git`); |
| 334 | expect(txt).toContain(`\`diffinery setup\` adds both`); |
| 335 | expect(txt).toContain(`${ORIGIN}/docs/connect/ci-status-in-claude.md`); |
| 336 | expect(txt).toContain("diffinery auth setup-gh"); |
| 337 | }); |
| 338 | |
| 339 | it("returns 404 for unknown pages", async () => { |
| 340 | expect((await get("/docs/no-such-page")).status).toBe(404); |
| 341 | expect((await get("/docs/no-such-page.md")).status).toBe(404); |
| 342 | }); |
| 343 | |
| 344 | it("lists the docs in the sitemap, with the date a page was last reviewed when it has one", async () => { |
| 345 | const res = await get("/sitemap.xml"); |
| 346 | expect(res.status).toBe(200); |
| 347 | const xml = await res.text(); |
| 348 | expect(xml).toContain(`${ORIGIN}/docs/quickstart`); |
| 349 | const dated = pages.find((p) => p.updated)!; |
| 350 | expect(xml).toContain(`<url><loc>${ORIGIN}/docs/${dated.slug}</loc><lastmod>${dated.updated}</lastmod></url>`); |
| 351 | }); |
| 352 | |
| 353 | it("returns 404 for an unknown section's llms files", async () => { |
| 354 | expect((await get("/docs/no-such-section/llms-full.txt")).status).toBe(404); |
| 355 | expect((await get("/docs/no-such-section/llms.txt")).status).toBe(404); |
| 356 | }); |
| 357 | |
| 358 | it("gives every page structured data: a TechArticle with breadcrumbs, its review date and its questions", async () => { |
| 359 | for (const p of pages) { |
| 360 | const html = await (await get(p.slug === "index" ? "/docs" : `/docs/${p.slug}`)).text(); |
| 361 | const m = /<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/.exec(html); |
| 362 | expect(m, p.slug).toBeTruthy(); |
| 363 | const g = (JSON.parse(m![1]!) as { "@graph": Array<Record<string, unknown>> })["@graph"]; |
| 364 | const article = g.find((x) => x["@type"] === "TechArticle")!; |
| 365 | expect(article.headline, p.slug).toBe(p.title); |
| 366 | expect(article.dateModified, p.slug).toBe(p.updated); |
| 367 | expect(g.some((x) => x["@type"] === "BreadcrumbList"), p.slug).toBe(true); |
| 368 | expect(g.some((x) => x["@type"] === "FAQPage"), p.slug).toBe(/^## Frequently asked questions\s*$/m.test(p.markdown)); |
| 369 | } |
| 370 | }, 30_000); |
| 371 | }); |