pages.test.ts

23.8 KB · raw

1import type { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { mkdir, mkdtemp, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import type { Hono } from "hono";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import { fileAt, objectBytes, type Save } from "../src/mirror/queries.ts";10import { openMirror } from "../src/mirror/store.ts";11import { timelineSaves, timelineSvg } from "../src/pages/timeline.ts";12import { createApp } from "../src/server/app.ts";13import { openKeprDb } from "../src/server/db.ts";14import { type Fixture, makeKohBucket } from "./fixture.ts";1516let fx: Fixture;17let app: Hono;18let kepr: Database;19let mirrorDir: string;20let tailSaves: string[];21const get = (path: string) => app.request(path);22const body = async (path: string) => (await get(path)).text();23// History's IDs only, not the Atlas picker's, which lists every save.24const historyIds = (page: string) => {25	const list = /data-list="history">(.*?)<\/div>/s.exec(page)?.[1] ?? "";26	return [...list.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);27};2829beforeAll(async () => {30	fx = await makeKohBucket();31	const secretRepo = await makeKohBucket("hidden");32	const large = await makeKohBucket("large");33	// Over 1 MB each: text with a two-byte "é" split by the 1 MB mark, and invalid UTF-8 with no NUL.34	await writeFile(35		join(large.repoDir, "big.txt"),36		`${"a".repeat(1024 * 1024 - 1)}é${"b".repeat(1000)}\n`,37	);38	await writeFile(join(large.repoDir, "big.bin"), new Uint8Array(1024 * 1024 + 500).fill(0xff));39	Bun.spawnSync(["koh", "save", "add large files"], {40		cwd: large.repoDir,41		env: { PATH: process.env.PATH, HOME: join(large.repoDir, "../../home"), NO_COLOR: "1" },42	});43	// A file that gains its final newline, then loses it again.44	const tail = await makeKohBucket("tail");45	const tailEnv = { PATH: process.env.PATH, HOME: join(tail.repoDir, "../../home"), NO_COLOR: "1" };46	for (const [text, message] of [47		["a\nb", "add tail"],48		["a\nb\n", "end tail with a newline"],49		["a\nb", "drop the newline"],50	] as const) {51		await writeFile(join(tail.repoDir, "tail.txt"), text);52		Bun.spawnSync(["koh", "save", message], { cwd: tail.repoDir, env: tailEnv });53	}54	tailSaves = JSON.parse(55		Bun.spawnSync(["koh", "view", "--json"], {56			cwd: tail.repoDir,57			env: tailEnv,58		}).stdout.toString(),59	).saves.map((save: { id: string }) => save.id);60	const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));61	await mkdir(join(dataDir, "assets"));62	await writeFile(join(dataDir, "assets", "plain.txt"), "asset\n");63	kepr = openKeprDb(dataDir);64	mirrorDir = join(dataDir, "mirror");65	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {66		const bucket = openBucket({ kind: "folder", path: bucketDir });67		await syncAll({ bucket, kepr, mirrorDir });68	}69	kepr.run("UPDATE repos SET public = 1, homepage = 'https://demo.example' WHERE name = 'demo'");70	kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");71	app = createApp(72		{73			domain: "kepr.example",74			dataDir,75			adminPasswordHash: "$argon2id$x",76			bucket: { kind: "folder", path: fx.bucketDir },77		},78		kepr,79	);80});8182const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);8384describe("the visibility gate", () => {85	test("a private repo and an unknown one are the same 404, everywhere", async () => {86		for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {87			const hidden = await get(`/hidden${suffix}`);88			const missing = await get(`/nowhere${suffix}`);89			expect(hidden.status).toBe(404);90			expect(missing.status).toBe(404);91			expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());92		}93	});94});9596describe("the repo page", () => {97	test("renders header, timeline, history and README at HEAD with a short cache", async () => {98		const response = await get("/demo");99		expect(response.status).toBe(200);100		expect(response.headers.get("cache-control")).toBe("public, max-age=60");101		const page = await response.text();102		expect(page).toContain('<h1 class="repo-name">demo</h1>');103		// The README opens with an HTML comment and a raw <p align>; neither is the intro.104		expect(/<div class="repo-desc"><p>(.*?)<\/p>/.exec(page)?.[1]).toBe(105			"A small repo for <strong>Kepr's</strong> tests.",106		);107		expect(page).toContain(108			'<meta property="og:description" content="A small repo for Kepr&#39;s tests.">',109		);110		expect(page).toContain('href="https://demo.example"');111		expect(page).toContain('<span class="lang-badge">TypeScript</span>');112		expect(page).toContain("3 saves");113		expect(page).not.toContain('class="install-line"');114		expect(page.match(/<circle class="dot d\d"/g)?.length).toBe(3);115		expect(page).not.toContain("density = activity");116		expect(historyIds(page)).toEqual([head(), second(), first()]);117		expect(page).toContain('data-copy="koh steal kepr.example/demo"');118		expect(page).toContain('href="/demo/releases" data-panel="releases">builds</a>');119	});120121	test("the issues tab counts only this repo's visible open issues and hides zero", async () => {122		kepr.run("BEGIN");123		try {124			kepr.run(`INSERT INTO issues (repo, number, title, body, type, filed_at, hidden, status) VALUES125('demo', 1, 'one', 'body', 'bug', 1, 0, 'open'),126('demo', 2, 'two', 'body', 'bug', 1, 0, 'open'),127('demo', 3, 'three', 'body', 'bug', 1, 0, 'open'),128('demo', 4, 'hidden', 'body', 'bug', 1, 1, 'open'),129('demo', 5, 'closed', 'body', 'bug', 1, 0, 'closed'),130('large', 1, 'another repo', 'body', 'bug', 1, 0, 'open')`);131			expect(await body("/demo")).toContain(132				'<span class="repo-issue-count" aria-label="3 open issues">3</span>',133			);134			kepr.run("UPDATE issues SET status = 'closed' WHERE repo = 'demo' AND number = 1");135			expect(await body("/demo")).toContain(136				'<span class="repo-issue-count" aria-label="2 open issues">2</span>',137			);138			kepr.run("UPDATE issues SET status = 'closed' WHERE repo = 'demo' AND number = 2");139			expect(await body("/demo")).toContain(140				'<span class="repo-issue-count" aria-label="1 open issue">1</span>',141			);142			kepr.run("UPDATE issues SET status = 'closed' WHERE repo = 'demo'");143			expect(await body("/demo")).not.toContain('class="repo-issue-count"');144		} finally {145			kepr.run("ROLLBACK");146		}147	});148149	test("a page past the last, or too large to bind, is page 1", async () => {150		for (const query of ["?page=2", `?page=${"9".repeat(400)}`, "?page=1e999", "?page=x"]) {151			const response = await get(`/demo${query}`);152			expect([query, response.status]).toEqual([query, 200]);153			expect(historyIds(await response.text()).length).toBe(3);154		}155	});156157	test("checkpoints never appear", async () => {158		expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);159	});160161	test("the README points relative links and images at raw files and drops unsafe links", async () => {162		const page = await body("/demo");163		expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');164		expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');165		expect(page).toContain('<a href="https://example.com">the site</a>');166		expect(page).not.toContain('href="javascript:');167		expect(page).toContain("&lt;script&gt;alert");168		expect(page).not.toContain("<script>alert");169	});170});171172describe("the timeline", () => {173	// A replayed save keeps its original time, so newest-first order is not time order.174	const saves = [300, 100, 500, 200].map(175		(created_at, i): Save => ({176			id: `00000000000${i}`,177			parent: "",178			message: "m",179			created_at: created_at * 86400,180			machine: "m",181			agent: "",182			session: "",183			is_checkpoint: false,184		}),185	);186187	test("span from the earliest to the latest save, whatever the line's order", () => {188		const dots = [...timelineSvg("demo", saves).value.matchAll(/cx="([\d.]+)%"/g)].map((m) => m[1]);189		expect(dots).toEqual(["50.000", "1.000", "99.000", "25.500"]);190	});191192	test("a dot's neighbours are counted within a fractional reach exactly", () => {193		// A span of 144,001 s makes the reach 3,600.025 s: the save at 3,601 s is not within it.194		const at = (created_at: number) => ({ ...(saves[0] as Save), created_at });195		const svg = timelineSvg("demo", [at(0), at(3601), at(144001)]).value;196		expect(svg.match(/class="dot d(\d)"/g)).toEqual([197			'class="dot d0"',198			'class="dot d0"',199			'class="dot d0"',200		]);201	});202203	test("a dot's tooltip keeps a long message's first 120 characters", () => {204		const long = { ...(saves[0] as Save), message: `${"x".repeat(150)}\nsecond line` };205		expect(timelineSaves([long])[0]?.message).toBe(`${"x".repeat(119)}…`);206	});207208	test("the page carries plain dots; their words come from /<repo>/timeline", async () => {209		const page = await (await get("/demo")).text();210		expect(page).toContain('data-src="/demo/timeline"');211		expect(/<svg class="timeline"[\s\S]*?<\/svg>/.exec(page)?.[0]).not.toContain("<title>");212		const response = await get("/demo/timeline");213		expect(response.headers.get("cache-control")).toBe("public, max-age=60");214		const saves = (await response.json()) as { id: string; message: string }[];215		expect(saves.map((save) => save.message)).toContain("say hello");216		expect(saves.length).toBe(3);217		expect((await get("/nope/timeline")).status).toBe(404);218	});219});220221describe("the file viewer", () => {222	test("shows a file at HEAD and at an old save, with line anchors", async () => {223		const now = await get("/demo/files/src/main.ts");224		expect(now.headers.get("cache-control")).toBe("public, max-age=60");225		const nowPage = await now.text();226		expect(nowPage).toContain("hello");227		expect(nowPage).toContain(228			'<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',229		);230		expect(nowPage).toContain('class="tk-');231232		const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);233		expect(then.headers.get("cache-control")).toBe("private, max-age=31536000");234		const thenPage = await then.text();235		expect(thenPage).toContain("&#39;hi&#39;");236		expect(thenPage).not.toContain("hello");237		expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);238	});239240	test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {241		expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);242		expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);243		expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);244		expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);245		expect((await get("/demo/files/nothing.txt")).status).toBe(404);246	});247248	test("Markdown renders, with a source toggle", async () => {249		const rendered = await body("/demo/files/docs/guide.md");250		expect(rendered).toContain("<h1>Guide</h1>");251		const source = await body("/demo/files/docs/guide.md?view=source");252		expect(source).toContain('id="L1"');253		expect(source).not.toContain("<h1>Guide</h1>");254	});255256	test("an image displays inline from its raw file", async () => {257		expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');258	});259});260261describe("files over 1 MB", () => {262	test("text shows its first 1 MB with a raw link, even with a character cut at the mark", async () => {263		const page = await body("/large/files/big.txt");264		expect(page).toContain("showing the first 1 MB");265		expect(page).toContain('href="/large/raw/big.txt"');266		expect(page).not.toContain("bbb");267		const raw = await get("/large/raw/big.txt");268		expect(raw.headers.get("content-type")).toBe("text/plain; charset=utf-8");269	});270271	test("invalid UTF-8 without a NUL is binary, not text", async () => {272		const page = await body("/large/files/big.bin");273		expect(page).toContain("binary file");274		expect(page).not.toContain('class="code"');275		const raw = await get("/large/raw/big.bin");276		expect(raw.headers.get("content-type")).toBe("application/octet-stream");277	});278});279280describe("the Atlas", () => {281	// A row's detail panel, fetched on hover or select; a folder's path ends in a slash.282	const panelOf = (path: string, at?: string) =>283		body(`/demo/atlas/panel?${new URLSearchParams(at ? { path, at } : { path })}`);284	const rowOf = (page: string, name: string) =>285		new RegExp(`data-name="${name}"[^>]*>(.*?)</a>`, "s").exec(page)?.[1] ?? "";286287	test("sits between the timeline and History", async () => {288		const page = await body("/demo");289		const atlas = page.indexOf('id="atlas"');290		expect(page.indexOf('class="timeline"')).toBeLessThan(atlas);291		expect(atlas).toBeLessThan(page.indexOf('id="history"'));292	});293294	test("is a folder tree, folders first, then files, alphabetical", async () => {295		const names = [...(await body("/demo")).matchAll(/class="atlas-name">([^<]*)</g)].map(296			(m) => m[1],297		);298		expect(names).toEqual([299			"docs/",300			"guide.md",301			"logo.png",302			"src/",303			"lib/",304			"deep/",305			"util.ts",306			"main.ts",307			"vendor/",308			"big.c",309			"README.md",310			"secret.txt",311		]);312	});313314	test("rows carry the first sentence of the file's own comment, rendered, and link to the file", async () => {315		const page = await body("/demo");316		expect(rowOf(page, "util.ts")).toContain(317			'<span class="atlas-desc">The <em>one</em> helper.</span>',318		);319		expect(rowOf(page, "main.ts")).toContain("Entry point.");320		expect(rowOf(page, "guide.md")).toContain('<span class="atlas-desc">Read me.</span>');321		expect(rowOf(page, "secret.txt")).toContain('<span class="atlas-desc"></span>');322		expect(page).toContain(323			'<a class="atlas-row atlas-file" href="/demo/files/src/main.ts" data-name="main.ts" data-path="src/main.ts">',324		);325		const util = await panelOf("src/lib/deep/util.ts");326		expect(util).toContain("<p>The <em>one</em> helper. Deep down.</p>");327		expect(util).toContain("<dt>exports</dt><dd>one</dd>");328		expect(await panelOf("src/main.ts")).not.toContain("atlas-map");329	});330331	test("the panel counts only real saves, crediting a checkpoint's change to the next save", async () => {332		const page = await body("/demo");333		const main = await panelOf("src/main.ts");334		expect(main).toContain("<dt>lines</dt><dd>2</dd>");335		expect(main).toContain("<dt>saves</dt><dd>2</dd>");336		expect(main).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${second()}">`);337		expect(main).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);338		const guide = await panelOf("docs/guide.md");339		expect(guide).toContain("<dt>saves</dt><dd>2</dd>");340		expect(guide).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);341		expect(await panelOf("docs/logo.png")).toContain("<dt>lines</dt><dd>binary</dd>");342		expect(page).not.toContain(fx.saveIds[1] as string);343	});344345	test("a folder's panel sums its files and counts the saves that touched anything under it", async () => {346		const docs = await panelOf("docs/");347		expect(docs).toContain('<p class="atlas-info-path mono">docs</p>');348		expect(docs).toContain("<dt>lines</dt><dd>3</dd>");349		expect(docs).toContain("<dt>files</dt><dd>2</dd>");350		expect(docs).toContain("<dt>saves</dt><dd>2</dd>");351		expect(docs).toContain(`<dt>first save</dt><dd><a href="/demo/saves/${first()}">`);352		expect(docs).toContain(`<dt>last save</dt><dd><a href="/demo/saves/${head()}">`);353		const src = await panelOf("src/");354		expect(src).toContain("<dt>saves</dt><dd>3</dd>");355	});356357	test("can be viewed at an older save, and an unknown save is a 404", async () => {358		const page = await body(`/demo?at=${second().slice(0, 8)}`);359		expect(page).toContain(`title="explore through time">${second()}</summary>`);360		const picker = await body(`/demo/atlas/saves?at=${second()}`);361		expect(picker).toContain(362			`<a class="atlas-picker-row active" href="/demo?at=${second()}#atlas"`,363		);364		expect(picker).toContain(`<a class="atlas-picker-row" href="/demo?at=${head()}#atlas"`);365		expect(page).toContain('<a class="atlas-head-link mono" href="/demo#atlas">back to HEAD</a>');366		expect(page).toContain(`href="/demo/files/src/main.ts?at=${second()}"`);367		const main = await panelOf("src/main.ts", second().slice(0, 8));368		expect(main).toContain("<dt>saves</dt><dd>1</dd>");369		expect(main).not.toContain(head());370		const oldest = await body(`/demo?at=${first()}`);371		expect(oldest).not.toContain('data-name="main.ts"');372		expect((await get("/demo?at=abcdef")).status).toBe(404);373		expect((await get("/demo?at=../x")).status).toBe(404);374	});375376	test("the page carries no panels; each is fetched alone, and anything unknown is a 404", async () => {377		const page = await body("/demo");378		expect(page).not.toContain("<template>");379		expect(page).not.toContain('class="atlas-picker-row" href="/demo?at=');380		expect(page).toContain('data-src="/demo/atlas/saves"');381		expect((await get("/demo/atlas/saves?at=abcdef")).status).toBe(404);382		expect((await get("/nobody/atlas/saves")).status).toBe(404);383		const panel = await get("/demo/atlas/panel?path=src/main.ts");384		expect(panel.status).toBe(200);385		expect(panel.headers.get("content-type")).toContain("text/html");386		for (const query of [387			"path=nope.ts",388			"path=src/main.ts&at=abcdef",389			"path=../secret.txt",390			"path=",391			"",392			"path=nope/",393		]) {394			expect(`${query} ${(await get(`/demo/atlas/panel?${query}`)).status}`).toBe(`${query} 404`);395		}396		expect((await get("/nobody/atlas/panel?path=README.md")).status).toBe(404);397	});398});399400describe("the save page", () => {401	test("shows the save's facts and diffs against the previous real save, skipping the checkpoint", async () => {402		const response = await get(`/demo/saves/${head()}`);403		expect(response.status).toBe(200);404		expect(response.headers.get("cache-control")).toBe("public, max-age=60");405		const page = await response.text();406		expect(page).toContain('<h1 class="save-message-head">say hello</h1>');407		expect(page).toContain(408			`<dt>parent</dt><dd><a href="/demo/saves/${second()}">${second()}</a></dd>`,409		);410		expect(page).not.toContain(fx.saveIds[1] as string);411		expect(page).not.toContain("save-flag");412		// The guide's edit was first recorded by the checkpoint; it belongs to this save.413		const rows = [414			...page.matchAll(415				/class="change-row change-(\w+)" href="#diff-\d"><span class="change-path">([^<]+)</g,416			),417		];418		expect(rows.map((m) => [m[1], m[2]])).toEqual([419			["modified", "docs/guide.md"],420			["modified", "src/main.ts"],421		]);422		expect(page).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');423		expect(page).toContain('<span class="dh">@@ -1,2 +1,2 @@</span>');424		expect(page).toContain(425			'<span class="dl dl-del"><span class="ln">2</span><span class="ln"></span><span class="ds">-</span>',426		);427		expect(page).toContain('class="tk-');428		expect(page).toContain(`href="/demo/archive/${head()}.tar.gz"`);429		expect(page).toContain(`href="/demo?at=${head()}#atlas"`);430	});431432	test("the first save diffs against nothing, notes binaries, and shows its flag and agent", async () => {433		const page = await body(`/demo/saves/${first()}`);434		expect(page).toContain('<span class="save-flag-label mono">flagged</span> holds a secret');435		expect(page).toContain("<dt>parent</dt><dd>none, the first save</dd>");436		expect(page).toContain(437			'change-added" href="#diff-2"><span class="change-path">docs/logo.png</span><span class="change-stat mono"><span class="diff-note">binary</span>',438		);439		expect(page).toContain("binary file, not shown");440		expect(page).toContain('<span class="dh">@@ -0,0 +1 @@</span>');441		const second_ = await body(`/demo/saves/${second()}`);442		expect(second_).toContain("<dt>agent</dt><dd>test-agent</dd><dt>session</dt><dd>fixture</dd>");443		expect(second_).not.toContain("save-flag");444	});445446	test("a change to the final newline alone is a changed last line, marked as in GNU diff", async () => {447		const marker = '<span class="dh">\\ No newline at end of file</span>';448		const row = (kind: string, a: string, b: string, sign: string) =>449			`<span class="dl dl-${kind}"><span class="ln">${a}</span><span class="ln">${b}</span><span class="ds">${sign}</span><span class="lc">b</span></span>`;450		const gained = await body(`/tail/saves/${tailSaves[1]}`);451		expect(gained).toContain('<span class="diff-add">+1</span> <span class="diff-del">−1</span>');452		expect(gained).toContain(`${row("del", "2", "", "-")}${marker}${row("add", "", "2", "+")}<`);453		const lost = await body(`/tail/saves/${tailSaves[0]}`);454		expect(lost).toContain(`${row("del", "2", "", "-")}${row("add", "", "2", "+")}${marker}`);455	});456457	test("a prefix redirects, and a checkpoint, an unknown or a private save is a 404", async () => {458		const short = await get(`/demo/saves/${head().slice(0, 6)}`);459		expect(short.status).toBe(302);460		expect(short.headers.get("location")).toBe(`/demo/saves/${head()}`);461		expect((await get(`/demo/saves/${fx.saveIds[1]}`)).status).toBe(404);462		expect((await get("/demo/saves/abcdef")).status).toBe(404);463		expect((await get("/demo/saves/abc")).status).toBe(404);464		expect((await get(`/hidden/saves/${head()}`)).status).toBe(404);465	});466});467468describe("raw files", () => {469	test("are the exact object bytes, text as text/plain", async () => {470		const mirror = openMirror(mirrorDir, "demo");471		for (const [path, type] of [472			["src/main.ts", "text/plain; charset=utf-8"],473			["docs/logo.png", "image/png"],474			["README.md", "text/plain; charset=utf-8"],475		] as const) {476			const response = await get(`/demo/raw/${path}`);477			const file = fileAt(mirror, head(), path);478			expect(response.headers.get("content-type")).toBe(type);479			expect(new Uint8Array(await response.arrayBuffer())).toEqual(480				objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,481			);482		}483		expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");484	});485});486487test("static assets and raw files carry route-based sandboxing", async () => {488	const css = await get("/static/css/kepr.css");489	expect(css.status).toBe(200);490	expect(css.headers.get("content-type")).toContain("text/css");491	const raw = await get("/demo/raw/README.md");492	expect(raw.headers.get("content-security-policy")).toContain("sandbox");493	const asset = await get("/static/assets/plain.txt");494	expect(asset.headers.get("content-security-policy")).toContain("sandbox");495	const favicon = await get("/favicon.svg");496	expect(favicon.headers.get("content-type")).toBe("image/svg+xml");497	expect(favicon.headers.get("content-security-policy")).not.toContain("sandbox");498	expect((await get("/static/../package.json")).status).toBe(404);499	const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";500	expect(csp).toContain("style-src 'self';");501	expect(csp).toContain("font-src 'self';");502	expect(csp).not.toContain("unsafe-inline");503});504505describe("housekeeping routes", () => {506	test("robots.txt points at the sitemap, which lists public repos only", async () => {507		expect(await body("/robots.txt")).toBe(508			"User-agent: *\nAllow: /\n\nSitemap: https://kepr.example/sitemap.xml\n",509		);510		const sitemap = await get("/sitemap.xml");511		expect(sitemap.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");512		const xml = await sitemap.text();513		expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>\n<urlset');514		for (const repo of ["demo", "large", "tail"]) {515			expect(xml).toContain(`<loc>https://kepr.example/${repo}</loc>`);516		}517		expect(xml).not.toContain("hidden");518	});519520	test("favicon.svg is Kepr 2's mark, and every page links it", async () => {521		const res = await get("/favicon.svg");522		expect(res.headers.get("Content-Type")).toBe("image/svg+xml");523		expect(await res.text()).toContain('<polygon points="16,3 27,23 5,23"');524		expect(await body("/demo")).toContain(525			'<link rel="icon" href="/favicon.svg" type="image/svg+xml">',526		);527	});528});529530test("pages are prefetched on hover, never a link whose GET does something", async () => {531	const page = await get("/demo");532	expect(page.headers.get("speculation-rules")).toBe('"/speculation.json"');533	const rules = await get("/speculation.json");534	expect(rules.headers.get("content-type")).toBe("application/speculationrules+json");535	const text = await rules.text();536	for (const pattern of [537		"/*/archive/*",538		"/*/raw/*",539		"/*/releases/*",540		"/*/koh/*",541		"/api/*",542		"/admin/backup*",543		"/admin/notifications/*",544		"/*/issues/*",545		"/sign-in*",546		"/connect*",547	]) {548		expect(text).toContain(JSON.stringify(pattern));549	}550});