import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createApiError, jsonError } from "./api-error";
import {
getMarkdownRepresentation,
homepageMarkdown,
htmlToMarkdown,
isSafeMarkdownPath,
markdownSourceUrl,
} from "./agent-markdown";
import { appendVary, markdownProxyTarget, preferredType } from "./content-negotiation";
import { buildOpenApiDocument } from "./openapi";
describe("agent-readable HTTP behavior", () => {
it("honors q-values, specificity, and explicit rejection", () => {
assert.equal(preferredType(null), "text/html");
assert.equal(preferredType("*/*"), "text/html");
assert.equal(preferredType("text/markdown"), "text/markdown");
assert.equal(preferredType("text/html;q=0.8, text/markdown"), "text/markdown");
assert.equal(preferredType("text/html, text/markdown;q=0.5"), "text/html");
assert.equal(preferredType("text/*;q=0.8, text/html;q=0.8"), "text/html");
assert.equal(preferredType("text/html;q=0, */*;q=1"), "text/markdown");
assert.equal(preferredType("text/markdown;q=0, */*;q=1"), "text/html");
assert.equal(preferredType("text/markdown;q=1.5, text/html;q=0.5"), "text/html");
assert.equal(preferredType("text/html;q=0, text/markdown;q=0"), null);
assert.equal(preferredType("application/pdf"), null);
});
it("preserves framework Vary tokens and adds negotiated request headers once", () => {
const headers = new Headers({ Vary: "rsc, next-router-state-tree" });
appendVary(headers, "Accept", "Accept-Encoding");
appendVary(headers, "accept");
assert.equal(headers.get("Vary"), "rsc, next-router-state-tree, Accept, Accept-Encoding");
});
it("forwards the original path and query to the Markdown renderer", () => {
const { rewriteUrl, requestHeaders } = markdownProxyTarget(
"https://rybbit.com/docs/api/getting-started?source=audit",
{ Accept: "text/markdown" }
);
assert.equal(
rewriteUrl.toString(),
"https://rybbit.com/api/agent/markdown?path=%2Fdocs%2Fapi%2Fgetting-started&query=%3Fsource%3Daudit"
);
assert.equal(requestHeaders.get("X-Rybbit-Markdown-Path"), "/docs/api/getting-started");
assert.equal(requestHeaders.get("X-Rybbit-Markdown-Query"), "?source=audit");
});
it("serves substantial homepage Markdown with a real heading hierarchy", () => {
assert.ok(homepageMarkdown.length >= 500);
assert.match(homepageMarkdown, /^# Rybbit/m);
assert.match(homepageMarkdown, /^## /m);
assert.match(homepageMarkdown, /^### /m);
assert.match(homepageMarkdown, /https:\/\/rybbit\.com\/docs/);
});
it("returns a recoverable Markdown 404 for an unknown page", async () => {
const response = await getMarkdownRepresentation(
"/definitely-not-a-real-page",
async () =>
new Response("
Page not found
", {
status: 404,
headers: { "Content-Type": "text/html; charset=utf-8" },
})
);
assert.equal(response.status, 404);
assert.equal(response.headers.get("Content-Type"), "text/markdown; charset=utf-8");
assert.match(response.headers.get("Vary") ?? "", /Accept/);
const body = await response.text();
assert.match(body, /^# Page not found/m);
assert.match(body, /\[Sitemap\]\(https:\/\/rybbit\.com\/sitemap\.xml\)/);
assert.match(body, /\[Agent index\]\(https:\/\/rybbit\.com\/llms\.txt\)/);
assert.match(body, /\[API specification\]\(https:\/\/rybbit\.com\/openapi\.json\)/);
});
it("preserves upstream redirects for Markdown clients", async () => {
const response = await getMarkdownRepresentation(
"/retired-page",
async () =>
new Response(null, {
status: 308,
headers: { Location: "/replacement" },
})
);
assert.equal(response.status, 308);
assert.equal(response.headers.get("Location"), "/replacement");
});
it("rejects oversized HTML sources before converting them to Markdown", async () => {
const response = await getMarkdownRepresentation(
"/large-page",
async () =>
new Response("Large page
", {
headers: {
"Content-Length": "2000001",
"Content-Type": "text/html; charset=utf-8",
},
})
);
assert.equal(response.status, 502);
assert.match(await response.text(), /exceeded the safe conversion limit/i);
});
it("keeps Markdown source fetches on the configured origin", () => {
assert.equal(isSafeMarkdownPath("/docs/api/getting-started"), true);
assert.equal(isSafeMarkdownPath("//127.0.0.1/internal"), false);
assert.equal(isSafeMarkdownPath("/\\attacker.example/internal"), false);
assert.equal(isSafeMarkdownPath("/bad\npath"), false);
assert.equal(isSafeMarkdownPath("https://attacker.example/internal"), false);
assert.equal(isSafeMarkdownPath("/api/agent/markdown"), false);
assert.equal(isSafeMarkdownPath("/_next/image"), false);
const source = markdownSourceUrl("https://rybbit.com", "/docs/api/getting-started", "?source=agent");
assert.equal(source.origin, "https://rybbit.com");
assert.equal(source.pathname, "/docs/api/getting-started");
assert.equal(source.search, "?source=agent");
});
it("preserves indentation inside fenced code", () => {
const markdown = htmlToMarkdown("if (ready) {\n run();\n}
");
assert.match(markdown, /if \(ready\) \{\n run\(\);\n\}/);
});
it("exposes stable JSON API error fields while preserving compatibility", async () => {
const body = createApiError({
code: "API_ROUTE_NOT_FOUND",
message: "No API route exists at this path.",
resolution: "Read the OpenAPI document and choose a listed operation.",
details: { path: "/api/missing" },
});
assert.deepEqual(body, {
error: "No API route exists at this path.",
code: "API_ROUTE_NOT_FOUND",
message: "No API route exists at this path.",
resolution: "Read the OpenAPI document and choose a listed operation.",
details: { path: "/api/missing" },
});
const response = jsonError(404, body);
assert.equal(response.status, 404);
assert.match(response.headers.get("Content-Type") ?? "", /^application\/json/);
assert.equal(response.headers.get("X-Content-Type-Options"), "nosniff");
assert.equal(((await response.json()) as { code: string }).code, "API_ROUTE_NOT_FOUND");
});
it("generates OpenAPI 3.1 for the documented API and structured errors", () => {
const document = buildOpenApiDocument();
const operations = Object.values(document.paths).flatMap(pathItem =>
Object.values(pathItem).filter(value => value && typeof value === "object" && "responses" in value)
);
assert.equal(document.openapi, "3.1.1");
assert.equal(document.servers[0].url, "https://app.rybbit.io");
assert.ok(Object.keys(document.paths).length >= 50);
assert.ok(operations.length >= 60);
assert.ok(document.paths["/api/sites/{site}/overview"]?.get);
assert.ok(document.paths["/api/track"]?.post);
assert.ok(document.components.schemas.ApiError.required.includes("resolution"));
assert.ok(document.tags.every(tag => tag.description.length > 0));
const license = document.info.license as { identifier?: string; url?: string };
assert.ok(!(license.identifier && license.url), "OpenAPI 3.1 license identifier and URL are mutually exclusive");
assert.deepEqual(document.paths["/api/track"]?.post?.security?.[0], {});
assert.deepEqual(document.paths["/api/auth/api-key/list"]?.get?.security, [{ CookieSession: [] }]);
assert.equal(
(document.components.securitySchemes.CookieSession as { name: string }).name,
"better-auth.session_token"
);
assert.deepEqual(
(document.paths["/api/sites/{site}/retention"]?.get?.parameters?.[0] as { schema: unknown }).schema,
{
oneOf: [
{ type: "integer", minimum: 1 },
{ type: "string", minLength: 1 },
],
}
);
assert.equal(new Set(operations.map(operation => operation.operationId)).size, operations.length);
assert.doesNotThrow(() => JSON.stringify(document));
});
});