import { test, expect, type Page } from "@playwright/test"; import { activeSiteCookieName } from "../app/lib/activeSite"; import { baseUrl } from "./baseUrl"; import { readJson, resetData, writeSettings, writeSite } from "./helpers"; type SiteFile = { channels: { slug: string }[] }; // Two sites over the shared two-channel pool: alpha exposes slow-a, beta slow-b. async function twoSites() { await resetData("two-slow-channels"); await writeSite("alpha", { siteTitle: "Alpha", channels: [{ slug: "slow-a" }], }); await writeSite("beta", { siteTitle: "Beta", channels: [{ slug: "slow-b" }], }); } test("defaults to all sites and shows the full pool", async ({ page }) => { await twoSites(); await page.goto("/channels"); await expect(page.getByLabel("Active site")).toHaveValue("__all__"); await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); }); test("selecting a site scopes the channels list and persists", async ({ page, }) => { await twoSites(); await page.goto("/channels"); await page.getByLabel("Active site").selectOption("alpha"); // The choice is a cookie (release 15 SS): the URL is left alone, where it // used to gain ?site=alpha. await expect(page).toHaveURL(/\/channels$/); await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); await expectNoSiteParam(page); // The cookie persists the choice: revisiting with no param re-applies it. await page.goto("/channels"); await expect(page.getByLabel("Active site")).toHaveValue("alpha"); await expect(page).toHaveURL(/\/channels$/); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); await expectNoSiteParam(page); // Switching to beta flips the scope. await page.getByLabel("Active site").selectOption("beta"); await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-a" })).toHaveCount(0); }); test("dashboard stats and table scope to the active site", async ({ page }) => { await twoSites(); await page.goto("/?site=alpha"); // The dashboard's stat tiles were replaced by the mission-control cockpit // (c63f5cb), which has no "channels" group; the scoped count is now observable // as the number of rows in its channels table. await expect(page.getByLabel(/^channel row /)).toHaveCount(1); await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); }); test("charts is a site's tab, and the picker follows the path", async ({ page, }) => { await twoSites(); await page.goto("/sites/alpha/charts"); await expect(page.getByText(/author the default dashboard/i)).toBeVisible(); await expect(page.getByLabel("Active site")).toHaveValue("alpha"); // Hydrated first: a pick before React attaches is lost (it failed this way // in long runs, release 18). await expect(page.getByLabel("Active site")).toHaveAttribute("data-hydrated", "true"); await page.getByLabel("Active site").selectOption("beta"); await expect(page).toHaveURL(/\/sites\/beta\/charts$/); await expect(page.getByText(/author the default dashboard/i)).toContainText( "beta", ); // The path wrote the cookie, so the scoped pages follow — with no ?site= // seeded onto the URL any more (release 15 SS). await page.goto("/channels"); await expect(page.getByLabel("Active site")).toHaveValue("beta"); await expect(page).toHaveURL(/\/channels$/); await expectNoSiteParam(page); // "All sites" is the family page. await page.goto("/sites/beta/charts"); await expect(page.getByLabel("Active site")).toHaveAttribute("data-hydrated", "true"); await page.getByLabel("Active site").selectOption("__all__"); await expect(page).toHaveURL(/\/sites$/); }); test("publish targets the site in the path, and says when it cannot deploy", async ({ page, }) => { await twoSites(); // alpha has no cloudflareProject await page.goto("/sites/alpha/publish"); await expect( page.getByRole("heading", { name: "Build & deploy", exact: true }), ).toBeVisible(); await expect( page.getByRole("button", { name: "Build & deploy", exact: true }), ).toBeDisabled(); await expect( page.getByText(/has no Cloudflare Pages project configured/i), ).toBeVisible(); await expect(page.getByText(/select a specific site/i)).toHaveCount(0); await expect(page.getByLabel("Active site")).toHaveValue("alpha"); await page.getByLabel("Active site").selectOption("beta"); await expect(page).toHaveURL(/\/sites\/beta\/publish$/); }); test("pool views show every site regardless of the selector", async ({ page, }) => { await twoSites(); // /cleanup is a shared-pool view and lists every channel, snapshot or not: // both appear even when a single site is active. It reads no `site` // searchParam at all, which is the property under test. await page.goto("/cleanup?site=alpha"); await expect( page.getByRole("region", { name: "cleanup slow-a", exact: true }), ).toBeVisible(); await expect( page.getByRole("region", { name: "cleanup slow-b", exact: true }), ).toBeVisible(); }); test("creating a channel under a site adds it to that site's membership", async ({ page, }) => { await twoSites(); await page.goto("/channels/new?site=alpha"); // The Sites section pre-checks the active site — that's what carries the // membership now (the old hidden activeSite field is gone). await expect(page.getByLabel("Include on Alpha")).toBeChecked(); await page.getByLabel(/^name/i).fill("Gamma Channel"); await page.getByLabel(/^slug/i).fill("gamma"); await page.getByLabel(/^url/i).fill("https://www.youtube.com/@gamma/videos"); await page.getByRole("button", { name: /create channel/i }).click(); await page.waitForURL("**/channels/gamma", { timeout: 10_000 }); const site = await readJson( "test-transcripts/sites/alpha/site.json", ); expect(site.channels.map((c) => c.slug)).toContain("gamma"); await page.goto("/channels?site=alpha"); await expect(page.getByRole("link", { name: "gamma" })).toBeVisible(); }); // ── No wrong paint (release 15 slice SS) ───────────────────────────────────── // // The picker used to render "All sites" from the URL on every navigation and // then snap to the stored site once an effect had read localStorage and // rewritten the URL. Playwright's auto-retrying `toHaveValue` CANNOT see that: // it polls until the value is right and passes, flash or no flash. So these // cases read the value ONCE, with no retry, right after `domcontentloaded`, and // a script installed before any page script records every value the select // ever had — at every DOM mutation and every animation frame, i.e. everything // that could have been painted — and "__all__" must never be among them. const PICKER = 'select[aria-label="Active site"]'; // The cookie this test server's requests use (its name carries the port). const COOKIE = activeSiteCookieName(new URL(baseUrl).host); // How long a page is watched after it hydrates. There is nothing to wait FOR: // the assertion is that nothing happens, and the old snap landed within one // server round trip of hydration. const SETTLE_MS = 750; async function recordPickerValues(page: Page) { await page.addInitScript((selector) => { const seen: string[] = []; (window as unknown as { __pickerValues: string[] }).__pickerValues = seen; const read = () => { const el = document.querySelector(selector); if (el && seen[seen.length - 1] !== el.value) seen.push(el.value); }; new MutationObserver(read).observe(document, { subtree: true, childList: true, attributes: true, }); const frame = () => { read(); requestAnimationFrame(frame); }; requestAnimationFrame(frame); }, PICKER); } function pickerValues(page: Page): Promise { return page.evaluate( () => (window as unknown as { __pickerValues?: string[] }).__pickerValues ?? [], ); } // Hydrated = React has attached its props to the select. It is the one React // internal this spec peeks at; there is no public signal. Effects follow it. async function hydrated(page: Page) { await page.waitForFunction((selector) => { const el = document.querySelector(selector); return !!el && Object.keys(el).some((k) => k.startsWith("__reactProps")); }, PICKER); } // A hydration mismatch is logged by React in development; fail on any. function watchHydration(page: Page): string[] { const seen: string[] = []; page.on("console", (msg) => { if (/hydrat/i.test(msg.text())) seen.push(msg.text()); }); page.on("pageerror", (err) => { if (/hydrat/i.test(err.message)) seen.push(err.message); }); return seen; } // No `?site=` on the URL once the page has hydrated and settled, read with no // retry. `toHaveURL` alone would pass on its first poll, before a `replace` from // an effect (how the old picker seeded the param) could land. async function expectNoSiteParam(page: Page) { await hydrated(page); await page.waitForTimeout(SETTLE_MS); expect(new URL(page.url()).searchParams.has("site"), page.url()).toBe(false); } async function storedCookie(page: Page): Promise { const cookies = await page.context().cookies(); return cookies.find((c) => c.name === COOKIE)?.value; } // A full load, read at its first paint. async function firstPaint(page: Page, path: string): Promise { await page.goto(path, { waitUntil: "commit" }); await page.waitForLoadState("domcontentloaded"); return page.locator(PICKER).inputValue(); } test("a stored site is the picker's first paint on every page, never All sites", async ({ page, }) => { test.setTimeout(90_000); await twoSites(); const hydration = watchHydration(page); await recordPickerValues(page); const picker = page.getByLabel("Active site"); // Store alpha the way a person does, and wait for it to land: the re-render // that drops slow-b is the one the cookie write triggers. await page.goto("/channels"); await picker.selectOption("alpha"); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); expect(await storedCookie(page)).toBe("alpha"); // Full loads: the server renders the stored site, and nothing moves it. for (const path of ["/", "/channels", "/jobs", "/settings", "/sites/alpha", "/"]) { expect(await firstPaint(page, path), `${path}: first paint`).toBe("alpha"); await hydrated(page); await page.waitForTimeout(SETTLE_MS); expect(await pickerValues(page), `${path}: every value`).toEqual(["alpha"]); expect(new URL(page.url()).searchParams.has("site"), page.url()).toBe(false); } // Client-side navigations: the layout persists, the picker re-renders. const nav = page.locator("aside nav"); for (const [name, url] of [ ["Channels", /\/channels$/], [/^Jobs/, /\/jobs$/], ["Settings", /\/settings$/], ["Sites", /\/sites$/], ["Dashboard", /\/$/], ] as const) { await nav.getByRole("link", { name, exact: typeof name === "string" }).click(); await expect(page).toHaveURL(url); expect(await picker.inputValue(), `${String(name)}: first render`).toBe("alpha"); } await page.waitForTimeout(SETTLE_MS); expect(await pickerValues(page), "every value across the navigations").toEqual([ "alpha", ]); expect(new URL(page.url()).searchParams.has("site"), page.url()).toBe(false); // Visiting another site's page records it; Dashboard then paints it at once. expect(await firstPaint(page, "/sites/beta"), "/sites/beta: first paint").toBe( "beta", ); await expect.poll(() => storedCookie(page), { timeout: 15_000 }).toBe("beta"); expect(await firstPaint(page, "/"), "/ after /sites/beta").toBe("beta"); await hydrated(page); await page.waitForTimeout(SETTLE_MS); expect(await pickerValues(page)).toEqual(["beta"]); expect(hydration, "hydration warnings").toEqual([]); }); test("a ?site= link scopes its own page and is not stored; a choice there drops it", async ({ page, }) => { test.setTimeout(60_000); await twoSites(); const picker = page.getByLabel("Active site"); await page.goto("/channels"); await picker.selectOption("alpha"); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); // The link's scope, on the first paint, for this page only. expect(await firstPaint(page, "/channels?site=beta")).toBe("beta"); await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-a" })).toHaveCount(0); expect(await storedCookie(page)).toBe("alpha"); // Not written: the next plain visit is the stored site's. expect(await firstPaint(page, "/channels")).toBe("alpha"); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); // Choosing on a link's page replaces the link's scope: stored, and the param // goes, so the page and the picker agree. await page.goto("/channels?site=beta"); await hydrated(page); await picker.selectOption("__all__"); await expect(page).toHaveURL(/\/channels$/); await expect(picker).toHaveValue("__all__"); await expect(page.getByRole("link", { name: "slow-a" })).toBeVisible(); await expect(page.getByRole("link", { name: "slow-b" })).toBeVisible(); expect(await storedCookie(page)).toBe("__all__"); expect(await firstPaint(page, "/channels")).toBe("__all__"); }); test("a choice the old picker kept in localStorage moves to the cookie once", async ({ page, }) => { test.setTimeout(60_000); await twoSites(); const picker = page.getByLabel("Active site"); const legacy = () => page.evaluate(() => localStorage.getItem("activeSite")); // Seed the old key on the editor's origin from a route that mounts no app. await page.goto("/api/pulse"); await page.evaluate(() => localStorage.setItem("activeSite", "beta")); // The one visit that may flash: the server had no cookie to render from. await page.goto("/channels"); await expect(picker).toHaveValue("beta"); await expect(page.getByRole("link", { name: "slow-a" })).toHaveCount(0); await expect.poll(() => storedCookie(page), { timeout: 15_000 }).toBe("beta"); await expect.poll(legacy, { timeout: 15_000 }).toBeNull(); // From then on it is the first paint. expect(await firstPaint(page, "/")).toBe("beta"); // With a cookie, a leftover key is removed and never read. await page.evaluate(() => localStorage.setItem("activeSite", "alpha")); expect(await firstPaint(page, "/channels")).toBe("beta"); await expect.poll(legacy, { timeout: 15_000 }).toBeNull(); expect(await storedCookie(page)).toBe("beta"); }); test("Back to a site's page shows that site, not the choice made there", async ({ page, }) => { await twoSites(); const picker = page.getByLabel("Active site"); await page.goto("/sites/alpha/charts"); await hydrated(page); // The choice is held on the page it was made on until the push lands… await picker.selectOption("beta"); await expect(page).toHaveURL(/\/sites\/beta\/charts$/); // …and dropped once the URL has moved: back on alpha's page, the path rules. await page.goBack(); await expect(page).toHaveURL(/\/sites\/alpha\/charts$/); await expect(picker).toHaveValue("alpha"); }); // A site picked in one tab reaches the others. The cookie is shared, but a // tab's picker reads its root layout, which a client-side navigation does not // re-render; without the broadcast, tab A kept showing alpha over beta's pages // and skipped recording a visit to alpha's page. test("a site picked in another tab reaches this one", async ({ context }) => { await twoSites(); // Passive refresh off: a tree refresh whenever the pulse moves would also // re-read the layout, and only the broadcast may move tab A here. await writeSettings({ autoRefreshIntervalSeconds: 0 }); const a = await context.newPage(); const pickerA = a.getByLabel("Active site"); await a.goto("/channels"); await pickerA.selectOption("alpha"); await expect(a.getByRole("link", { name: "slow-b" })).toHaveCount(0); await a.goto("/settings"); await hydrated(a); const b = await context.newPage(); await b.goto("/channels"); await b.getByLabel("Active site").selectOption("beta"); await expect(b.getByRole("link", { name: "slow-a" })).toHaveCount(0); expect(await storedCookie(b)).toBe("beta"); // Tab A follows without a reload, and its next page agrees with its picker. await expect(pickerA).toHaveValue("beta"); await a.locator("aside nav").getByRole("link", { name: "Channels", exact: true }).click(); await expect(a).toHaveURL(/\/channels$/); await expect(pickerA).toHaveValue("beta"); await expect(a.getByRole("link", { name: "slow-b" })).toBeVisible(); await expect(a.getByRole("link", { name: "slow-a" })).toHaveCount(0); // And a client-side visit to alpha's page is recorded again: tab A no longer // believes alpha is stored. await a.locator("aside nav").getByRole("link", { name: "Sites", exact: true }).click(); await expect(a).toHaveURL(/\/sites$/); await a.locator('main a[href="/sites/alpha"]').click(); await expect(a).toHaveURL(/\/sites\/alpha$/); await expect.poll(() => storedCookie(a), { timeout: 15_000 }).toBe("alpha"); // Tab B hears of that write too. await expect(b.getByLabel("Active site")).toHaveValue("alpha"); }); // A new channel starts checked on the active site: the stored one, or a // `?site=` link's instead (not both). It is the form's initial state, so it is // in the first paint, read here with no retry, and a refresh later (a pick in // another tab) does not re-check a box the user cleared. test("a new channel starts checked on the stored site, or on a ?site= link's", async ({ context, }) => { test.setTimeout(60_000); await twoSites(); await writeSettings({ autoRefreshIntervalSeconds: 0 }); const a = await context.newPage(); await a.goto("/channels"); await a.getByLabel("Active site").selectOption("alpha"); await expect(a.getByRole("link", { name: "slow-b" })).toHaveCount(0); const alpha = a.getByLabel("Include on Alpha"); const beta = a.getByLabel("Include on Beta"); await a.goto("/channels/new", { waitUntil: "commit" }); await a.waitForLoadState("domcontentloaded"); expect(await alpha.isChecked(), "stored site, first paint").toBe(true); expect(await beta.isChecked()).toBe(false); await a.goto("/channels/new?site=beta", { waitUntil: "commit" }); await a.waitForLoadState("domcontentloaded"); expect(await beta.isChecked(), "the link's site, first paint").toBe(true); expect(await alpha.isChecked(), "not the stored one as well").toBe(false); // Cleared by hand, it stays cleared when another tab's pick refreshes this one. await a.goto("/channels/new"); await expect .poll(() => alpha.evaluate((el) => Object.keys(el).some((k) => k.startsWith("__reactProps")), ), ) .toBe(true); await alpha.click(); await expect(a.getByLabel("Group for Alpha")).toHaveCount(0); const b = await context.newPage(); await b.goto("/channels"); await b.getByLabel("Active site").selectOption("beta"); await expect(b.getByRole("link", { name: "slow-a" })).toHaveCount(0); await expect(a.getByLabel("Active site")).toHaveValue("beta"); await a.waitForTimeout(SETTLE_MS); expect(await alpha.isChecked()).toBe(false); expect(await beta.isChecked()).toBe(false); });