import fs from "node:fs"; import path from "node:path"; import { test, expect } from "@playwright/test"; import { FIXTURE_SUMMARY_NAME } from "./fixture-summary"; import { INSTANCES_URL, PROJECT_URL } from "../../common/lib/project"; // The home page's job is to say what Archilyzer is and offer the download. // Nothing asserted here names a count, a site or a headline number. The numeric // parts are gated on corpus data, so the specs that touch them read the summary // the server reads — the e2e's synthetic one (fixture-summary.ts) — and branch // on it, as a source-only build (no summary) is legal too. test.beforeEach(async ({ page }) => { await page.goto("/"); }); test("says what it is and offers the source", async ({ page }) => { await expect(page.getByRole("heading", { level: 1 })).toContainText( /searchable to the second/i, ); await expect( page.getByRole("link", { name: /download the source/i }), ).toHaveAttribute("href", "/downloads/"); await expect( page.getByRole("link", { name: /read the setup guide/i }), ).toHaveAttribute("href", "/docs/install/"); }); // The hub is a separate app on its own Pages project, linked from the hero // when homepage.json names its URL. Data-dependent like the numbers below, so the // assertion is conditional the same way: absent is a legal state (a // source-only build has no hub), but PRESENT means an absolute link off-site — // never a relative path into this site, which has no search. test("the hub link, when there is one, leaves for the hub", async ({ page }) => { const hub = page.getByRole("link", { name: /search all archives/i }); if ((await hub.count()) === 0) return; await expect(hub).toHaveCount(1); await expect(hub).toHaveAttribute("href", /^https?:\/\/[^/]/); }); // The header carries four destinations; the footer's "Sections" carries the // same four and Changelog, which is in the footer only (app/lib/nav.ts). const HEADER_LINKS = ["Docs", "Source", "Downloads", "Stats"]; const FOOTER_LINKS = [...HEADER_LINKS, "Changelog"]; const linkNames = (nav: import("@playwright/test").Locator) => nav.getByRole("link").evaluateAll((els) => els.map((e) => e.textContent?.trim())); test("the header nav has four links and no Changelog; the footer's has five", async ({ page, }) => { for (const width of [360, 1280]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const main = page.getByRole("navigation", { name: width < 768 ? "Main, compact" : "Main", exact: true }); expect(await linkNames(main)).toEqual(HEADER_LINKS); await expect(page.locator("header").getByRole("link", { name: "Changelog" })).toHaveCount(0); expect(await linkNames(page.getByRole("navigation", { name: "Footer" }))).toEqual(FOOTER_LINKS); } }); test("Changelog is reachable from the footer on every page", async ({ page }) => { for (const path of ["/", "/docs/", "/source/", "/downloads/", "/stats/", "/changelog/", "/no-such-page/"]) { await page.goto(path); await expect( page.getByRole("navigation", { name: "Footer" }).getByRole("link", { name: "Changelog" }), path, ).toHaveAttribute("href", "/changelog/"); await expect(page.locator("header").getByRole("link", { name: "Changelog" }), path).toHaveCount(0); } }); test("every nav destination resolves", async ({ page }) => { // The nav must never point at a 404, including on a build with no corpus // data — which is why /stats/ always exists and degrades in place. for (const [label, path] of [ ["Docs", "/docs/"], ["Source", "/source/"], ["Downloads", "/downloads/"], ["Stats", "/stats/"], ["Changelog", "/changelog/"], ]) { const res = await page.request.get(path); expect(res.status(), `${label} → ${path}`).toBe(200); } }); // The numeric parts of the page are gated on the build-time summary, so each // assertion reads the summary the server reads and expects exactly what it // implies: the part present with data, absent without. A source-only build (no // summary) is a legal state and must render none of them rather than zeroes. function readSummary(): { monthly?: unknown[]; sites?: unknown[]; official?: { sites: number }; generatedAt?: string; } | null { try { const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME); return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } test("the growth chart renders from the summary, or not at all", async ({ page, }) => { const summary = readSummary(); const chart = page.getByRole("img", { name: /transcripts by the month/i }); if (!summary?.monthly?.length || !summary.sites?.length) { await expect(chart).toHaveCount(0); return; } await expect(chart).toBeVisible(); // The caption; with sites folded into Other (lib/growthGaps.ts) a second // sentence says what Other is. await expect( page.getByText(/all official instances\.( |$)/i), ).toBeVisible(); }); test("official instances link out, and the numbers carry their build date", async ({ page, }) => { const summary = readSummary(); const heading = page.getByRole("heading", { level: 2, name: "Official Instances", exact: true, }); if (!summary?.sites?.length) { await expect(heading).toHaveCount(0); return; } await expect(heading).toBeVisible(); // No subtitle under the heading, and no description under a card's title. await expect(page.getByText("The archives I run")).toHaveCount(0); const section = page.locator("section").filter({ has: heading }); const links = section.getByRole("link"); expect(await links.count()).toBeGreaterThanOrEqual(1); for (const href of await links.evaluateAll((els) => els.map((e) => e.getAttribute("href") ?? ""), )) { expect(href).toMatch(/^https?:\/\/[^/]/); } if (summary.official && summary.official.sites > 0) { const time = page.locator("time[datetime]"); await expect(time).toHaveCount(1); expect(await time.getAttribute("datetime")).toMatch(/^\d{4}-\d{2}-\d{2}$/); } }); // Every archive's header links here (common/lib/project.ts INSTANCES_URL): // `/#instances` brings the section's heading into view, clear of the sticky // header. test("/#instances brings Official Instances into view below the sticky header", async ({ page, }) => { expect(INSTANCES_URL).toBe(`${PROJECT_URL}/#instances`); await page.setViewportSize({ width: 390, height: 700 }); await page.goto("/#instances"); const heading = page.getByRole("heading", { level: 2, name: "Official Instances", exact: true }); await expect(heading).toBeInViewport(); const [h, bar] = await Promise.all([ heading.boundingBox(), page.locator("header").first().boundingBox(), ]); expect(h!.y).toBeGreaterThanOrEqual(bar!.y + bar!.height); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); }); test("the headings after the H1 are in Title Case", async ({ page }) => { await expect( page.getByRole("heading", { level: 2, name: "What It Does", exact: true }), ).toBeVisible(); }); test("the home page has no link to ko-fi.com", async ({ page }) => { await expect(page.locator("footer")).toBeVisible(); await expect(page.locator('a[href*="ko-fi.com"]')).toHaveCount(0); }); test("branded 404 for an unknown path", async ({ page }) => { await page.goto("/no-such-page/"); await expect(page.getByRole("heading", { name: /no such page/i })).toBeVisible(); // Branded, not bare: the site chrome is still there to get you somewhere. await expect(page.getByRole("link", { name: /Archilyzer home/i })).toBeVisible(); });