/* Unit tests for robots covering buildRobotsTxt; buildRobotsTxt — AI-training derivation (RFC-1161). Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-1311: Compass header sweep — declares the robots suite's contract for test-file scaffold coverage. */ import { describe, expect, it } from "vitest"; import { AI_TRAINING_CRAWLER_REGISTRY, buildRobotsTxt, deriveAiTrainingUserAgents, ROBOTS_DIRECTIVE_ALLOWLIST, } from "../robots.ts"; describe("buildRobotsTxt", () => { it("generates default allow policy", () => { const result = buildRobotsTxt({}); expect(result).toContain("User-agent: *"); expect(result).not.toContain("Disallow: /"); expect(result).toContain("Allow: /robots.txt"); expect(result).toContain("Sitemap: /sitemap.xml"); }); it("generates disallow-all for defaultPolicy: disallow", () => { const result = buildRobotsTxt({ defaultPolicy: "disallow" }); expect(result).toContain("Disallow: /"); }); it("generates specific disallow paths", () => { const result = buildRobotsTxt({ disallowedPaths: ["/admin", "/private"] }); expect(result).toContain("Disallow: /admin"); expect(result).toContain("Disallow: /private"); expect(result).not.toContain("Disallow: /\n"); }); it("generates allow paths in allow mode", () => { const result = buildRobotsTxt({ allowedPaths: ["/public"] }); expect(result).toContain("Allow: /public"); }); it("emits Content-Signal directive when provided", () => { const result = buildRobotsTxt({ contentSignal: ["ai-train", "search"] }); expect(result).toContain("Content-Signal: ai-train, search"); }); it("emits crawler blocklist", () => { const result = buildRobotsTxt({ crawlerBlocklist: ["BadBot", "ScraperBot"] }); expect(result).toContain("User-agent: BadBot"); expect(result).toContain("User-agent: ScraperBot"); expect(result).toContain("Disallow: /"); }); it("emits crawler allowlist", () => { const result = buildRobotsTxt({ crawlerAllowlist: ["FriendlyBot"] }); expect(result).toContain("User-agent: FriendlyBot"); expect(result).toContain("Allow: /"); }); it("emits custom per-user-agent rules", () => { const result = buildRobotsTxt({ customRules: [{ userAgent: "GoogleBot", disallow: ["/no-google"], allow: ["/yes-google"] }], }); expect(result).toContain("User-agent: GoogleBot"); expect(result).toContain("Disallow: /no-google"); expect(result).toContain("Allow: /yes-google"); }); it("uses custom sitemap URL", () => { const result = buildRobotsTxt({ sitemap: "https://example.com/sitemap.xml" }); expect(result).toContain("Sitemap: https://example.com/sitemap.xml"); }); it("never emits a Schemamap directive — head links own that discovery surface (RFC-1175)", () => { const result = buildRobotsTxt({ sitemap: "https://example.com/sitemap.xml", contentSignal: ["ai-train=no"], crawlerAllowlist: ["FriendlyBot"], crawlerBlocklist: ["BadBot"], customRules: [{ userAgent: "GoogleBot", disallow: ["/no"], allow: ["/yes"] }], }); expect(result).toContain("Sitemap: https://example.com/sitemap.xml"); expect(result).not.toContain("Schemamap"); }); it("emits only directives inside the recognized surface allowlist (RFC-1175)", () => { const allowlist = new Set(ROBOTS_DIRECTIVE_ALLOWLIST); const result = buildRobotsTxt({ contentSignal: ["ai-train=no", "search=yes", "ai-input=yes"], crawlerAllowlist: ["FriendlyBot"], crawlerBlocklist: ["BadBot"], customRules: [{ userAgent: "GoogleBot", disallow: ["/no"], allow: ["/yes"] }], }); const emitted = result .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")) .map((line) => line.split(":", 1)[0]!.trim().toLowerCase()); for (const directive of emitted) { expect(allowlist.has(directive)).toBe(true); } }); it("always allows auxiliary discovery files", () => { const result = buildRobotsTxt({ defaultPolicy: "disallow" }); expect(result).toContain("Allow: /robots.txt"); expect(result).toContain("Allow: /llms.txt"); expect(result).toContain("Allow: /llms-full.txt"); expect(result).toContain("Allow: /sitemap.xml"); expect(result).toContain("Allow: /sitemap-content.xml"); }); }); describe("buildRobotsTxt — AI-training derivation (RFC-1161)", () => { const aiTrainNo = ["ai-train=no", "search=yes", "ai-input=yes"]; it("emits the derived group with all registry tokens when ai-train=no", () => { const result = buildRobotsTxt({ contentSignal: aiTrainNo }); expect(result).toContain( "# AI-training crawlers — derived from Content-Signal ai-train=no (RFC-1161)", ); for (const token of AI_TRAINING_CRAWLER_REGISTRY) { expect(result).toContain(`User-agent: ${token.userAgent}`); } // The derived group ends with a single Disallow: / line. expect(result).toMatch(/VelenPublicWebCrawler\nDisallow: \/\n/); }); it("produces byte-identical output without ai-train=no (AC-2 regression)", () => { const withoutSignal = buildRobotsTxt({}); const otherSignals = buildRobotsTxt({ contentSignal: ["search=yes", "ai-input=yes"] }); const aiTrainYes = buildRobotsTxt({ contentSignal: ["ai-train=yes", "search=yes"] }); expect(withoutSignal).not.toContain("AI-training crawlers"); expect(otherSignals).not.toContain("AI-training crawlers"); expect(aiTrainYes).not.toContain("AI-training crawlers"); // Other signals only add the Content-Signal line; body is identical. expect(otherSignals.replace(/^Content-Signal: .*\n\n/m, "")).toBe(withoutSignal); }); it("places the derived group after the complete default group and before allowlist", () => { const result = buildRobotsTxt({ contentSignal: aiTrainNo, crawlerAllowlist: ["FriendlyBot"], }); const auxIdx = result.indexOf("Allow: /sitemap-content.xml"); const markerIdx = result.indexOf("AI-training crawlers"); const allowIdx = result.indexOf("User-agent: FriendlyBot"); expect(auxIdx).toBeGreaterThan(-1); expect(markerIdx).toBeGreaterThan(auxIdx); expect(allowIdx).toBeGreaterThan(markerIdx); }); it("matches ai-train=no case-insensitively with trimmed whitespace", () => { const result = buildRobotsTxt({ contentSignal: [" AI-Train=No ", "search=yes"] }); expect(result).toContain("AI-training crawlers"); }); it("dedupes tokens already covered by crawlerBlocklist", () => { const result = buildRobotsTxt({ contentSignal: aiTrainNo, crawlerBlocklist: ["GPTBot"], }); // GPTBot appears exactly once — in the blocklist group, not the derived group. expect(result.match(/User-agent: GPTBot/g)).toHaveLength(1); expect(result).toContain("AI-training crawlers"); expect(result).toContain("User-agent: ClaudeBot"); }); it("dedupes tokens covered by a customRules entry", () => { const result = buildRobotsTxt({ contentSignal: aiTrainNo, customRules: [{ userAgent: "CCBot", disallow: ["/tmp"], allow: [] }], }); expect(result.match(/User-agent: CCBot/g)).toHaveLength(1); expect(result).toContain("AI-training crawlers"); }); it("emits no derived group when every token is explicitly covered", () => { const result = buildRobotsTxt({ contentSignal: aiTrainNo, crawlerBlocklist: AI_TRAINING_CRAWLER_REGISTRY.map((e) => e.userAgent), }); expect(result).not.toContain("AI-training crawlers"); }); it("exposes deriveAiTrainingUserAgents for validators", () => { expect(deriveAiTrainingUserAgents({ contentSignal: aiTrainNo })).toHaveLength( AI_TRAINING_CRAWLER_REGISTRY.length, ); expect(deriveAiTrainingUserAgents({})).toHaveLength(0); expect( deriveAiTrainingUserAgents({ contentSignal: aiTrainNo, crawlerAllowlist: ["GPTBot"], // allowlist does NOT dedupe — it is a contradiction (AITRAIN-02) }), ).toContain("GPTBot"); }); });