console.test.ts

12.7 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import { fileIssue } from "../src/issues/store.ts";3import { Browser, type Instance, instance } from "./browser.ts";4import { makeRepos, writeRelease } from "./fixture.ts";56let clock = Date.parse("2026-09-26T12:00:00Z");7const tick = (seconds: number) => {8	clock += seconds * 1000;9	setSystemTime(new Date(clock));10};1112describe("repos and visibility", () => {13	let inst: Instance;14	let browser: Browser;15	let bucketDir: string;16	let save: string;17	const publicPaths = () => [18		"/app",19		"/app/files/README.md",20		"/app/raw/README.md",21		`/app/saves/${save}`,22		`/app/archive/${save}.tar.gz`,23		"/app/archive/HEAD.tar.gz",24		"/app/archives",25		"/app/releases",26		"/app/releases/latest",27		"/app/releases/latest.json",28		"/app/releases/history.json",29		"/app/releases/latest/version.txt",30		"/app/releases/latest/linux-x86_64",31		"/app/releases/1.0.0/checksums.txt",32		"/app/releases/1.0.0/linux-x86_64",33		"/app/install.sh",34		"/api/app/releases/latest",35		"/api/app/releases/history",36		"/app/update-info",37		"/app/meta",38		"/app/issues",39		"/app/issues/new",40		"/app/issues/1",41	];42	const statuses = async () => {43		const out: string[] = [];44		for (const path of publicPaths()) out.push(`${path} ${(await inst.app.request(path)).status}`);45		return out;46	};47	const text = async (path: string) => (await inst.app.request(path)).text();48	const publish = (repo: string, fields: Record<string, string>) =>49		browser.post(`/admin/repos/${repo}`, { sort_order: "0", ...fields });5051	beforeAll(async () => {52		setSystemTime(new Date(clock));53		const repos = await makeRepos(["app", "other"]);54		bucketDir = repos.bucketDir;55		save = repos.saves.app as string;56		await writeRelease(bucketDir, "app", {57			version: "1.0.0",58			save,59			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],60		});61		inst = await instance(bucketDir);62		inst.kepr.run("INSERT INTO settings (key, value) VALUES ('handle', 'june')");63		fileIssueDirect(inst, "app");64		browser = new Browser(inst.app);65		await browser.signIn();66	});67	afterAll(() => setSystemTime());6869	test("every repo the sync found is listed, private, with its last sync", async () => {70		const page = await (await browser.get("/admin/repos")).text();71		expect(page).toContain('id="repo-app"');72		expect(page).toContain('id="repo-other"');73		expect(page.match(/<span>private<\/span>/g)?.length).toBe(2);74		expect(page).toContain("last sync 2026-09-26 12:00 UTC");75		expect((await statuses()).every((line) => line.endsWith(" 404"))).toBe(true);76	});7778	test("bad settings are refused and change nothing", async () => {79		for (const fields of <Record<string, string>[]>[80			{ homepage: "javascript:alert(1)" },81			{ sort_order: "first" },82			{ install1: "one\ntwo" },83			{ category: "x".repeat(501) },84		]) {85			expect((await publish("app", { public: "1", ...fields })).status).toBe(400);86		}87		expect((await inst.app.request("/app")).status).toBe(404);88		expect((await browser.post("/admin/repos/nowhere", { public: "1" })).status).toBe(404);89	});9091	test("publishing opens every public surface, with the settings shown", async () => {92		await publish("other", { public: "1" });93		const response = await publish("app", {94			public: "1",95			category: "tools",96			homepage: "https://app.example",97			install1: "nix profile add app",98		});99		expect(response.status).toBe(303);100		const lines = await statuses();101		expect(lines.filter((line) => line.endsWith(" 404"))).toEqual([]);102		expect(await text("/app")).toContain("https://app.example");103		expect(await text("/app")).toContain("nix profile add app");104		expect(await text("/")).toContain('href="/app"');105		expect(await text("/browse")).toContain('href="/app"');106		expect(await text("/@june")).toContain('href="/app"');107		expect(await text("/sitemap.xml")).toContain("/app</loc>");108	});109110	test("flipping a repo private 404s every surface and drops it from lists and counts", async () => {111		const stat = async () =>112			[...(await text("/@june")).matchAll(/profile-stat-number">([^<]*)</g)].map((m) => m[1]);113		// Both download routes above counted; the issue is the repo's one.114		expect(await stat()).toEqual(["2", "2", "1", "2"]);115		await publish("app", { category: "tools" });116		expect((await statuses()).filter((line) => !line.endsWith(" 404"))).toEqual([]);117		for (const path of ["/", "/browse", "/@june"]) expect(await text(path)).not.toContain("/app");118		expect(await text("/sitemap.xml")).not.toContain("/app<");119		expect(await text("/")).not.toContain("tools");120		expect(await stat()).toEqual(["1", "1", "0", "0"]);121		// Settings survive the flip.122		expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');123	});124125	test("sync now reads the bucket at once and records the time", async () => {126		await writeRelease(bucketDir, "other", {127			version: "2.0.0",128			save: save,129			files: [{ platform: "linux-x86_64", name: "other", bytes: new Uint8Array([2]) }],130		});131		tick(60);132		expect((await inst.app.request("/other/releases/latest/version.txt")).status).toBe(404);133		const response = await browser.post("/admin/sync", {});134		expect(response.status).toBe(303);135		expect(await text("/other/releases/latest/version.txt")).toBe("2.0.0");136		expect(await (await browser.get("/admin/repos")).text()).toContain(137			"last sync 2026-09-26 12:01 UTC",138		);139	});140});141142describe("issues: filed, answered and closed end to end through real routes", () => {143	let inst: Instance;144	let operator: Browser;145	let save: string;146	const text = async (path: string) => (await inst.app.request(path)).text();147	const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/1", fields);148149	beforeAll(async () => {150		setSystemTime(new Date(clock));151		const repos = await makeRepos(["app", "hush"]);152		save = repos.saves.app as string;153		inst = await instance(repos.bucketDir);154		operator = new Browser(inst.app);155		await operator.signIn();156		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });157		// A reporter files through the public form, as a browser would.158		const reporter = new Browser(inst.app, "198.51.100.40");159		const form = await (await reporter.request("/app/issues/new")).text();160		const stamp = /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "";161		tick(4);162		const filed = await reporter.request("/app/issues/new", {163			method: "POST",164			body: new URLSearchParams({165				stamp,166				website: "",167				title: "Crash <on> start",168				body: "It crashed.",169				type: "bug",170				version: "",171				name: "Ada",172				email: "[email protected]",173			}),174		});175		expect(filed.status).toBe(200);176		fileIssueDirect(inst, "hush");177	});178	afterAll(() => setSystemTime());179180	test("the console lists issues across repos, private ones too, with the contact detail", async () => {181		const list = await (await operator.get("/admin/issues")).text();182		expect(list).toContain('href="/admin/issues/app/1"');183		expect(list).toContain('href="/admin/issues/hush/1"');184		expect(list).toContain("Crash &lt;on&gt; start");185		const page = await (await operator.get("/admin/issues/app/1")).text();186		expect(page).toContain("contact: [email protected]");187		expect(await text("/app/issues/1")).not.toContain("[email protected]");188	});189190	test("an operator reply shows on the public thread, marked", async () => {191		expect((await act({ action: "reply", body: "Fixed <soon>." })).status).toBe(303);192		expect((await act({ action: "reply", body: "  " })).status).toBe(400);193		const thread = await text("/app/issues/1");194		expect(thread).toContain('<span class="issue-badge">operator</span>');195		expect(thread).toContain("Fixed &lt;soon&gt;.");196	});197198	test("close with a fixed-in save prefix, then reopen", async () => {199		expect((await act({ action: "close", fixed_in: "zzzzzz" })).status).toBe(400);200		expect((await act({ action: "close", fixed_in: "0123456789ab" })).status).toBe(400);201		expect((await act({ action: "close", fixed_in: save.slice(0, 6) })).status).toBe(303);202		const thread = await text("/app/issues/1");203		expect(thread).toContain("◈ closed");204		expect(thread).toContain(`fixed in <a class="history-id" href="/app/saves/${save}">`);205		expect(await text("/app/issues?status=closed")).toContain('href="/app/issues/1"');206		expect(await (await operator.get("/admin/issues?filter=closed")).text()).toContain(207			'href="/admin/issues/app/1"',208		);209		expect((await act({ action: "reopen" })).status).toBe(303);210		expect(await text("/app/issues/1")).toContain("◉ open");211		expect(await text("/app/issues/1")).not.toContain("fixed in");212	});213214	test("set type, and hide for spam: gone publicly, kept in the console", async () => {215		expect((await act({ action: "type", type: "nonsense" })).status).toBe(400);216		expect((await act({ action: "type", type: "idea" })).status).toBe(303);217		expect(await text("/app/issues")).toContain("#1 · idea");218		expect((await act({ action: "hide" })).status).toBe(303);219		expect((await inst.app.request("/app/issues/1")).status).toBe(404);220		expect(await (await operator.get("/admin/issues")).text()).not.toContain("/admin/issues/app/1");221		expect(await (await operator.get("/admin/issues?filter=hidden")).text()).toContain(222			'href="/admin/issues/app/1"',223		);224		expect((await act({ action: "unhide" })).status).toBe(303);225		expect((await inst.app.request("/app/issues/1")).status).toBe(200);226		expect((await act({ action: "delete" })).status).toBe(400);227		expect((await operator.post("/admin/issues/app/9", { action: "reopen" })).status).toBe(404);228	});229});230231function fileIssueDirect(inst: Instance, repo: string): void {232	fileIssue(inst.kepr, {233		repo,234		title: `A ${repo} issue`,235		body: "b",236		type: "bug",237		version: "",238		reporterName: "",239		contactEmail: "",240		ipHash: "x",241		filedAt: 1,242	});243}244245describe("releases: counts, displayed notes and hiding", () => {246	let inst: Instance;247	let operator: Browser;248	const text = async (path: string) => (await inst.app.request(path)).text();249	const edit = (version: string, fields: Record<string, string>) =>250		operator.post(`/admin/releases/app/${version}`, fields);251252	beforeAll(async () => {253		const repos = await makeRepos(["app"]);254		const save = repos.saves.app as string;255		for (const version of ["1.0.0", "1.1.0"]) {256			await writeRelease(repos.bucketDir, "app", {257				version,258				save,259				notes: `notes for ${version}`,260				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],261			});262		}263		inst = await instance(repos.bucketDir);264		operator = new Browser(inst.app);265		await operator.signIn();266		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });267		await inst.app.request("/app/releases/1.1.0/linux-x86_64");268	});269270	test("every version is listed with its download counts and notes", async () => {271		const page = await (await operator.get("/admin/releases")).text();272		expect(page).toContain('id="app-1.1.0"');273		expect(page).toContain('id="app-1.0.0"');274		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));275		expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');276		expect(page).toContain(">notes for 1.1.0</textarea>");277	});278279	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {280		expect((await edit("1.1.0", { notes: "Better <notes>." })).status).toBe(303);281		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");282		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");283		expect(await (await operator.get("/admin/releases")).text()).toContain("(edited)");284		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);285		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();286		expect(row).toEqual({ notes: null });287		expect((await edit("1.1.0", { notes: "x".repeat(20_001) })).status).toBe(400);288	});289290	test("a hidden version leaves pages, latest and every feed, and stays in the console", async () => {291		expect((await edit("1.1.0", { notes: "notes for 1.1.0", hidden: "1" })).status).toBe(303);292		expect(await text("/app/releases/latest/version.txt")).toBe("1.0.0");293		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");294		expect(await text("/api/app/releases/history")).not.toContain("1.1.0");295		expect(await text("/app/releases")).not.toContain("1.1.0");296		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);297		const page = await (await operator.get("/admin/releases")).text();298		expect(page).toContain('id="app-1.1.0"');299		expect(page).toContain('value="1" checked> hidden');300		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);301		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");302		expect((await edit("9.9.9", { notes: "" })).status).toBe(404);303		expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);304	});305});