import { test, expect } from "@playwright/test"; import { pathExists, readJson, resetData, writeSite } from "./helpers"; type SiteFile = { siteId: string; siteTitle: string; headerTitle: string; channels: { slug: string; groupId?: string }[]; }; type SocialSiteFile = { socialLinks?: { label: string; url: string; svg: string }[]; }; type RelatedSiteFile = { siteUrl?: string; relatedSites?: { label?: string; siteIds: string[] }[]; }; // Valid (un-normalized) SVG the server accepts: starts with . normalizeSocialSvg adds fill/aria-hidden on save. const VALID_SVG = ''; const INHERIT_TOGGLE = /use global default social links/i; test("create a site with channel membership via the UI", async ({ page }) => { await resetData("one-youtube-channel-with-data"); await page.goto("/sites/new"); await page.getByLabel(/site id/i).fill("jeralyzer"); await page.getByLabel(/site title/i).fill("Jeralyzer"); await page.getByLabel(/header title/i).fill("Jeralyzer"); // Include the fixture channel. await page .getByRole("checkbox", { name: /include test youtube channel/i }) .check(); await page.getByRole("button", { name: /create site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const site = await readJson( "test-transcripts/sites/jeralyzer/site.json", ); expect(site.siteTitle).toBe("Jeralyzer"); expect(site.channels.map((c) => c.slug)).toContain("test-youtube"); }); test("list shows configured sites and edit updates branding", async ({ page, }) => { await resetData("empty"); await writeSite("alpha", { siteTitle: "Alpha Site" }); await page.goto("/sites"); // Scope to the sites list link (the site title also appears in the sidebar // site selector now, so a bare getByText would match two elements). await expect(page.getByRole("link", { name: /Alpha Site/ })).toBeVisible(); // Drive the navigation with waitForURL alongside the click so a re-render // between the click and a separate URL assertion can't drop it under load. await Promise.all([ page.waitForURL(/\/sites\/alpha/), page.getByRole("link", { name: /Alpha Site/ }).click(), ]); await page.getByLabel(/site title/i).fill("Alpha Renamed"); await page.getByRole("button", { name: /save site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); // The "Saved" status and the on-disk write can land slightly apart; poll the // persisted file until the rename completes rather than reading it once. await expect(async () => { const site = await readJson( "test-transcripts/sites/alpha/site.json", ); expect(site.siteTitle).toBe("Alpha Renamed"); }).toPass({ timeout: 10_000 }); }); test("delete removes the site config", async ({ page }) => { await resetData("empty"); await writeSite("doomed", { siteTitle: "Doomed" }); await page.goto("/sites"); page.once("dialog", (d) => d.accept()); await page.getByRole("button", { name: /delete/i }).click(); await expect(page.getByText("Doomed")).toHaveCount(0); expect(await pathExists("test-transcripts/sites/doomed/site.json")).toBe( false, ); }); test("new site inherits the global social links by default (no socialLinks key)", async ({ page, }) => { await resetData("empty"); await page.goto("/sites/new"); await page.getByLabel(/site id/i).fill("inheritsite"); await page.getByLabel(/site title/i).fill("Inherit Site"); await page.getByLabel(/header title/i).fill("Inherit Site"); await expect(page.getByRole("checkbox", { name: INHERIT_TOGGLE })).toBeChecked(); await page.getByRole("button", { name: /create site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const site = await readJson( "test-transcripts/sites/inheritsite/site.json", ); // Inheriting = the key is absent so resolveSocialLinks falls back to global. expect("socialLinks" in site).toBe(false); }); test("unchecking inherit and adding a link writes the site's own socialLinks", async ({ page, }) => { await resetData("empty"); await page.goto("/sites/new"); await page.getByLabel(/site id/i).fill("ownlinks"); await page.getByLabel(/site title/i).fill("Own Links"); await page.getByLabel(/header title/i).fill("Own Links"); await page.getByRole("checkbox", { name: INHERIT_TOGGLE }).uncheck(); await page.getByRole("button", { name: /add link/i }).click(); await page.getByPlaceholder(/label \(e\.g\. github\)/i).fill("Mastodon"); await page.getByPlaceholder(/https:\/\//).fill("https://example.social/@me"); await page.getByPlaceholder(/( "test-transcripts/sites/ownlinks/site.json", ); expect(site.socialLinks).toHaveLength(1); expect(site.socialLinks?.[0].label).toBe("Mastodon"); expect(site.socialLinks?.[0].url).toBe("https://example.social/@me"); expect(site.socialLinks?.[0].svg).toContain("M0 0h24v24H0z"); }); test("unchecking inherit with no links writes an empty socialLinks override", async ({ page, }) => { await resetData("empty"); await page.goto("/sites/new"); await page.getByLabel(/site id/i).fill("nolinks"); await page.getByLabel(/site title/i).fill("No Links"); await page.getByLabel(/header title/i).fill("No Links"); await page.getByRole("checkbox", { name: INHERIT_TOGGLE }).uncheck(); await page.getByRole("button", { name: /create site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const site = await readJson( "test-transcripts/sites/nolinks/site.json", ); // Explicit empty override = show no links even if a global default exists. expect(site.socialLinks).toEqual([]); }); test("a site with its own links opens with the inherit toggle off", async ({ page, }) => { await resetData("empty"); await writeSite("hasown", { siteTitle: "Has Own", socialLinks: [ { label: "GitHub", url: "https://github.com/example", svg: VALID_SVG }, ], }); await page.goto("/sites/hasown"); await expect( page.getByRole("checkbox", { name: INHERIT_TOGGLE }), ).not.toBeChecked(); await expect(page.getByPlaceholder(/label \(e\.g\. github\)/i)).toHaveValue( "GitHub", ); }); test("public URL and a featured related-sites group round-trip to disk", async ({ page, }) => { await resetData("empty"); // A sibling to feature in the related group. await writeSite("alpha", { siteTitle: "Alpha Site" }); await page.goto("/sites/new"); await page.getByLabel(/site id/i).fill("beta"); await page.getByLabel(/site title/i).fill("Beta Site"); await page.getByLabel(/header title/i).fill("Beta Site"); await page.getByLabel(/public url/i).fill("https://beta.example"); await page.getByRole("button", { name: /add related group/i }).click(); await page.getByPlaceholder(/heading \(optional\)/i).fill("Friends"); await page.getByRole("checkbox", { name: /Alpha Site/ }).check(); await page.getByRole("button", { name: /create site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); const site = await readJson( "test-transcripts/sites/beta/site.json", ); expect(site.siteUrl).toBe("https://beta.example"); expect(site.relatedSites).toEqual([ { label: "Friends", siteIds: ["alpha"] }, ]); }); test("edit page loads an existing public URL and featured site", async ({ page, }) => { await resetData("empty"); await writeSite("alpha", { siteTitle: "Alpha Site" }); await writeSite("beta", { siteTitle: "Beta Site", siteUrl: "https://beta.example", relatedSites: [{ label: "Friends", siteIds: ["alpha"] }], }); await page.goto("/sites/beta"); await expect(page.getByLabel(/public url/i)).toHaveValue( "https://beta.example", ); await expect(page.getByPlaceholder(/heading \(optional\)/i)).toHaveValue( "Friends", ); await expect( page.getByRole("checkbox", { name: /Alpha Site/ }), ).toBeChecked(); }); test("inline-chips flag round-trips; seeded default group has it on", async ({ page, }) => { await resetData("empty"); await page.goto("/sites/new"); await page.getByLabel(/site id/i).fill("chipsite"); await page.getByLabel(/site title/i).fill("Chip Site"); await page.getByLabel(/header title/i).fill("Chip Site"); const chipToggles = page.getByRole("checkbox", { name: /show channels as individual chips/i, }); // The seeded default group (FALLBACK_GROUP) ships with the flag on. await expect(chipToggles).toHaveCount(1); await expect(chipToggles.first()).toBeChecked(); // Manually added groups default off. await page.getByRole("button", { name: /\+ add group/i }).click(); await expect(chipToggles).toHaveCount(2); await expect(chipToggles.nth(1)).not.toBeChecked(); await page.getByLabel("ID", { exact: true }).nth(1).fill("extra"); await page.getByLabel("Name", { exact: true }).nth(1).fill("Extra"); await page.getByRole("button", { name: /create site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); type GroupsSiteFile = { groups: { id: string; inline?: boolean }[]; }; // Poll: the "Saved" status and the on-disk write can land slightly apart. await expect(async () => { const site = await readJson( "test-transcripts/sites/chipsite/site.json", ); expect(site.groups[0].inline).toBe(true); // Flag-off groups serialize without the key at all. expect("inline" in site.groups[1]).toBe(false); }).toPass({ timeout: 10_000 }); // Reload the edit page — checkbox states restore from disk. await page.goto("/sites/chipsite"); await expect(chipToggles).toHaveCount(2); await expect(chipToggles.first()).toBeChecked(); await expect(chipToggles.nth(1)).not.toBeChecked(); }); test("archives + per-video transcript downloads opt-outs round-trip", async ({ page, }) => { await resetData("empty"); await writeSite("optout", { siteTitle: "Opt Out" }); type OptOutSiteFile = { archives?: boolean; transcriptDownloads?: boolean }; const file = "test-transcripts/sites/optout/site.json"; const archives = page.getByRole("checkbox", { name: /generate downloadable archive zips/i, }); const downloads = page.getByRole("checkbox", { name: /per-video transcript downloads/i, }); const save = async () => { await page.getByRole("button", { name: /save site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); }; // Both default ON: absent from the file, ticked in the form. await page.goto("/sites/optout"); await expect(archives).toBeChecked(); await expect(downloads).toBeChecked(); await archives.uncheck(); await downloads.uncheck(); await save(); // Poll: the "Saved" status and the on-disk write can land slightly apart. await expect(async () => { const site = await readJson(file); expect(site.archives).toBe(false); expect(site.transcriptDownloads).toBe(false); }).toPass({ timeout: 10_000 }); await page.goto("/sites/optout"); await expect(archives).not.toBeChecked(); await expect(downloads).not.toBeChecked(); // Ticking back on removes the key — only the non-default is persisted. await archives.check(); await downloads.check(); await save(); await expect(async () => { const site = await readJson(file); expect("archives" in site).toBe(false); expect("transcriptDownloads" in site).toBe(false); }).toPass({ timeout: 10_000 }); await page.goto("/sites/optout"); await expect(archives).toBeChecked(); await expect(downloads).toBeChecked(); }); test("the search switch round-trips; a legacy publish: cited reads as off and saves as search: false", async ({ page, }) => { await resetData("empty"); await writeSite("reportonly", { siteTitle: "Report Only", publish: "cited" }); type SearchSiteFile = { search?: boolean; publish?: string }; const file = "test-transcripts/sites/reportonly/site.json"; const search = page.getByRole("checkbox", { name: "Search", exact: true }); const save = async () => { await page.getByRole("button", { name: /save site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); }; // The legacy key reads as search off; saving rewrites it as search: false. await page.goto("/sites/reportonly"); await expect(search).not.toBeChecked(); await save(); await expect(async () => { const site = await readJson(file); expect(site.search).toBe(false); expect("publish" in site).toBe(false); }).toPass({ timeout: 10_000 }); await page.goto("/sites/reportonly/reports"); await expect(page.getByText("Reports only — search is off")).toBeVisible(); // Ticking it back on removes the key — only the non-default is persisted. await page.goto("/sites/reportonly"); await search.check(); await save(); await expect(async () => { const site = await readJson(file); expect("search" in site).toBe(false); }).toPass({ timeout: 10_000 }); await page.goto("/sites/reportonly"); await expect(search).toBeChecked(); await page.goto("/sites/reportonly/reports"); await expect(page.getByText("Full archive — search is on")).toBeVisible(); }); test("the listed opt-out round-trips, and a save of another field keeps it", async ({ page, }) => { await resetData("empty"); // An invented fixture id: no real site is named in a test. await writeSite("fixture-unlisted", { siteTitle: "Unlisted Fixture", siteUrl: "https://fixture-unlisted.example", listed: false, }); type ListedSiteFile = { siteTitle?: string; listed?: boolean }; const file = "test-transcripts/sites/fixture-unlisted/site.json"; const listed = page.getByRole("checkbox", { name: "List on the Archilyzer homepage and hub", }); const save = async () => { await page.getByRole("button", { name: /save site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); }; // `false` on disk opens unticked, and a save that changes only the title // keeps it: the action rebuilds the site from the form. await page.goto("/sites/fixture-unlisted"); await expect(listed).not.toBeChecked(); await page.getByLabel(/site title/i).fill("Unlisted Fixture Renamed"); await save(); await expect(async () => { const site = await readJson(file); expect(site.siteTitle).toBe("Unlisted Fixture Renamed"); expect(site.listed).toBe(false); }).toPass({ timeout: 10_000 }); // Ticking it removes the key — listed is the default. await page.goto("/sites/fixture-unlisted"); await expect(listed).not.toBeChecked(); await listed.check(); await save(); await expect(async () => { const site = await readJson(file); expect("listed" in site).toBe(false); }).toPass({ timeout: 10_000 }); // And unticking writes the explicit false again. await page.goto("/sites/fixture-unlisted"); await expect(listed).toBeChecked(); await listed.uncheck(); await save(); await expect(async () => { const site = await readJson(file); expect(site.listed).toBe(false); }).toPass({ timeout: 10_000 }); }); test("the hub form's per-video transcript downloads opt-out round-trips to homepage.json", async ({ page, }) => { await resetData("empty"); type HomepageFile = { siteTitle?: string; transcriptDownloads?: boolean }; const file = "test-transcripts/sites/_homepage/homepage.json"; // The hub form is the one on /sites whose submit is "Save config". const form = page .locator("form") .filter({ has: page.getByRole("button", { name: "Save config" }) }); const downloads = form.getByRole("checkbox", { name: /per-video transcript downloads/i, }); const save = async () => { await form.getByRole("button", { name: "Save config" }).click(); await expect(form.getByText("Saved.")).toBeVisible(); }; await page.goto("/sites"); await expect(downloads).toBeChecked(); await downloads.uncheck(); await save(); await expect(async () => { const hub = await readJson(file); expect(hub.transcriptDownloads).toBe(false); }).toPass({ timeout: 10_000 }); await page.goto("/sites"); await expect(downloads).not.toBeChecked(); await downloads.check(); await save(); await expect(async () => { const hub = await readJson(file); expect("transcriptDownloads" in hub).toBe(false); }).toPass({ timeout: 10_000 }); await page.goto("/sites"); await expect(downloads).toBeChecked(); }); test("first-run migrate button appears only when no sites exist", async ({ page, }) => { await resetData("empty"); await page.goto("/sites"); await expect( page.getByRole("button", { name: /migrate existing settings/i }), ).toBeVisible(); await writeSite("already", {}); await page.reload(); await expect( page.getByRole("button", { name: /migrate existing settings/i }), ).toHaveCount(0); }); test("brand accent radio group + wordmark lead round-trip to site.json", async ({ page, }) => { await resetData("empty"); await writeSite("brandy", { siteTitle: "Brandyalyzer" }); type BrandSiteFile = { accent?: string; wordmarkLead?: string }; const file = "test-transcripts/sites/brandy/site.json"; const accents = page.getByRole("group", { name: "Brand accent" }); const radio = (name: string | RegExp) => accents.getByRole("radio", { name, exact: typeof name === "string" }); const hex = page.getByRole("textbox", { name: "Custom hex" }); const lead = page.getByLabel(/wordmark lead/i); const save = async () => { await page.getByRole("button", { name: /save site/i }).click(); await expect( page.getByRole("status").filter({ hasText: "Saved" }), ).toBeVisible(); }; // No accent on disk reads as the default, Signal; seven swatches + Custom. await page.goto("/sites/brandy"); await expect(accents.getByRole("radio")).toHaveCount(8); await expect(radio(/^Signal/)).toBeChecked(); await expect(lead).toHaveValue(""); await radio("Brass").check(); await lead.fill("Brandy"); await save(); await expect(async () => { const site = await readJson(file); expect(site.accent).toBe("brass"); expect(site.wordmarkLead).toBe("Brandy"); }).toPass({ timeout: 10_000 }); await page.goto("/sites/brandy"); await expect(radio("Brass")).toBeChecked(); await expect(lead).toHaveValue("Brandy"); // Typing a hex selects Custom; it is stored normalized. await hex.fill("#CC3366"); await expect(radio("Custom")).toBeChecked(); await save(); await expect(async () => { const site = await readJson(file); expect(site.accent).toBe("#cc3366"); }).toPass({ timeout: 10_000 }); await page.goto("/sites/brandy"); await expect(radio("Custom")).toBeChecked(); await expect(hex).toHaveValue("#cc3366"); // A lead that is not how the header title starts is refused, not dropped. await lead.fill("brandy"); await page.getByRole("button", { name: /save site/i }).click(); // Filtered: Next's route announcer is a second (empty) role=alert. await expect( page.getByRole("alert").filter({ hasText: /wordmark lead must be/i }), ).toBeVisible(); expect((await readJson(file)).wordmarkLead).toBe("Brandy"); // Back to the default: stored as absent; a blank lead removes the key. await lead.fill(""); await radio(/^Signal/).check(); await save(); await expect(async () => { const site = await readJson(file); expect("accent" in site).toBe(false); expect("wordmarkLead" in site).toBe(false); }).toPass({ timeout: 10_000 }); }); // Who a site is built for — `site.json` `audience` (release 17 slice XP). A // private site is the operator's own reading copy: saved from the form, and // never deployed — the deploy actions refuse it before a job exists, in words // naming the audience. (The ops routes call the same actions the Publish tab's // buttons do.) test("a private site saves its audience, and every deploy refuses it before any job", async ({ page, request, }) => { await resetData("empty"); await writeSite("privsite", { siteTitle: "Private Site", cloudflareProject: "never-real" }); await page.goto("/sites/privsite"); const audience = page.getByLabel("Audience", { exact: true }); await expect(audience).toHaveValue("public"); await audience.selectOption("private"); await page.getByRole("button", { name: /save site/i }).click(); await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible(); await expect(async () => { const site = await readJson<{ audience?: string; cloudflareProject?: string }>( "test-transcripts/sites/privsite/site.json", ); expect(site.audience).toBe("private"); expect(site.cloudflareProject).toBe("never-real"); }).toPass({ timeout: 10_000 }); await page.reload(); await expect(audience).toHaveValue("private"); const refusal = 'Site "privsite" is private (audience: private): it is built for reading on this machine and is never deployed. Build it without deploying, or set its audience to public on its Settings tab.'; for (const action of ["build-deploy", "deploy-site"]) { const res = await request.post(`/api/ops/${action}`, { headers: { authorization: "Bearer test-worker-token" }, data: { siteId: "privsite" }, }); expect(res.status(), action).toBe(400); expect(((await res.json()) as { error?: string }).error, action).toBe(refusal); } // Back to public: the key is gone from the file (public is the default). await audience.selectOption("public"); await page.getByRole("button", { name: /save site/i }).click(); await expect(async () => { const site = await readJson>("test-transcripts/sites/privsite/site.json"); expect("audience" in site).toBe(false); }).toPass({ timeout: 10_000 }); });