// Tests for the app/sitemap.ts → /sitemap.xml and app/robots.ts → /robots.txt
// data-route conventions: the pure serializers (where escaping / shape bugs
// live) plus handleDataRoute end-to-end (import a temp module, serialize, emit
// the response_start/chunk/done protocol with the right content-type).
import { afterEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
handleDataRoute,
serializeRobots,
serializeSitemap,
type RenderRouteMessage,
} from "./ssr-runtime";
describe("serializeSitemap", () => {
test("empty / undefined → valid empty urlset", () => {
for (const v of [[], undefined as any]) {
const xml = serializeSitemap(v);
expect(xml).toContain('');
expect(xml).toContain(
'',
);
expect(xml).toContain("");
}
});
test("emits loc + optional fields in order", () => {
const xml = serializeSitemap([
{
url: "https://x.com/blog",
lastModified: "2026-01-02",
changeFrequency: "weekly",
priority: 0.8,
},
]);
expect(xml).toContain(
"https://x.com/blog2026-01-02weekly0.8",
);
});
test("Date lastModified is ISO-serialized", () => {
const d = new Date("2026-06-12T10:00:00.000Z");
const xml = serializeSitemap([{ url: "https://x.com/", lastModified: d }]);
expect(xml).toContain("2026-06-12T10:00:00.000Z");
});
test("XML-escapes loc and values", () => {
const xml = serializeSitemap([
{ url: "https://x.com/s?a=1&b=2'\"" },
]);
expect(xml).toContain(
"https://x.com/s?a=1&b=2<c>'"",
);
// raw unescaped specials must not leak into the document body
expect(xml.includes("a=1&b=2")).toBe(false);
});
test("hreflang alternates add the xhtml namespace + link tags", () => {
const xml = serializeSitemap([
{
url: "https://x.com/en",
alternates: {
languages: { "en-US": "https://x.com/en", "es-ES": "https://x.com/es" },
},
},
]);
expect(xml).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
expect(xml).toContain(
'',
);
expect(xml).toContain('hreflang="es-ES"');
});
test("no xhtml namespace when no alternates present", () => {
const xml = serializeSitemap([{ url: "https://x.com/" }]);
expect(xml.includes("xmlns:xhtml")).toBe(false);
});
test("skips entries without a string url", () => {
const xml = serializeSitemap([
{ url: "https://x.com/ok" },
{ url: undefined as any },
null as any,
]);
expect((xml.match(//g) || []).length).toBe(1);
});
});
describe("serializeRobots", () => {
test("single rule with default user-agent", () => {
const txt = serializeRobots({ rules: { allow: "/", disallow: "/admin" } });
expect(txt).toContain("User-agent: *");
expect(txt).toContain("Allow: /");
expect(txt).toContain("Disallow: /admin");
expect(txt.endsWith("\n")).toBe(true);
});
test("array userAgent + array allow/disallow + crawlDelay", () => {
const txt = serializeRobots({
rules: {
userAgent: ["Googlebot", "Bingbot"],
disallow: ["/a", "/b"],
crawlDelay: 5,
},
});
expect(txt).toContain("User-agent: Googlebot");
expect(txt).toContain("User-agent: Bingbot");
expect(txt).toContain("Disallow: /a");
expect(txt).toContain("Disallow: /b");
expect(txt).toContain("Crawl-delay: 5");
});
test("multiple rules, sitemap (array) + host", () => {
const txt = serializeRobots({
rules: [
{ userAgent: "*", disallow: "/private" },
{ userAgent: "BadBot", disallow: "/" },
],
sitemap: ["https://x.com/sitemap.xml", "https://x.com/news.xml"],
host: "https://x.com",
});
expect(txt).toContain("User-agent: BadBot");
expect(txt).toContain("Sitemap: https://x.com/sitemap.xml");
expect(txt).toContain("Sitemap: https://x.com/news.xml");
expect(txt).toContain("Host: https://x.com");
});
test("undefined robots → just a trailing newline", () => {
expect(serializeRobots(undefined)).toBe("\n");
});
});
describe("handleDataRoute (end-to-end import + serialize)", () => {
const tmpdirs: string[] = [];
const prevCwd = process.cwd();
afterEach(() => {
process.chdir(prevCwd);
for (const d of tmpdirs.splice(0)) {
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {
/* best effort */
}
}
});
function fixture(file: string, src: string): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-data-route-"));
tmpdirs.push(dir);
fs.mkdirSync(path.join(dir, "app"), { recursive: true });
fs.writeFileSync(path.join(dir, "app", file), src);
process.chdir(dir);
}
const collect = async (
component: string,
kind: "sitemap" | "robots",
): Promise => {
const sent: any[] = [];
const msg = { component, call_id: "c1" } as unknown as RenderRouteMessage;
await handleDataRoute(msg, kind, (m) => sent.push(m));
return sent;
};
test("async app/sitemap.ts → 200 application/xml with the rendered urls", async () => {
fixture(
"sitemap.ts",
`export default async function sitemap() {
return [{ url: "https://x.com/", priority: 1.0, changeFrequency: "daily" }];
}`,
);
const sent = await collect("app/sitemap", "sitemap");
const start = sent.find((m) => m.type === "response_start");
const chunk = sent.find((m) => m.type === "render_chunk");
expect(start.status).toBe(200);
expect(start.headers["content-type"]).toContain("application/xml");
expect(start.headers["cache-control"]).toBe("public, max-age=3600");
const body = Buffer.from(chunk.data, "base64").toString("utf8");
expect(body).toContain("https://x.com/");
expect(body).toContain("1");
expect(sent.some((m) => m.type === "render_done")).toBe(true);
});
test("app/robots.ts → 200 text/plain", async () => {
fixture(
"robots.ts",
`export default function robots() {
return { rules: { userAgent: "*", allow: "/", disallow: "/dashboard" }, sitemap: "https://x.com/sitemap.xml" };
}`,
);
const sent = await collect("app/robots", "robots");
const start = sent.find((m) => m.type === "response_start");
const body = Buffer.from(
sent.find((m) => m.type === "render_chunk").data,
"base64",
).toString("utf8");
expect(start.headers["content-type"]).toContain("text/plain");
expect(body).toContain("Disallow: /dashboard");
expect(body).toContain("Sitemap: https://x.com/sitemap.xml");
});
test("a throwing sitemap surfaces as a 500 (does not wedge the runner)", async () => {
fixture(
"sitemap.ts",
`export default function sitemap() { throw new Error("boom"); }`,
);
const sent = await collect("app/sitemap", "sitemap");
const start = sent.find((m) => m.type === "response_start");
expect(start.status).toBe(500);
const body = Buffer.from(
sent.find((m) => m.type === "render_chunk").data,
"base64",
).toString("utf8");
expect(body).toContain("boom");
expect(sent.some((m) => m.type === "render_done")).toBe(true);
});
});