Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit e052cbe4a21d81145844b19b7f161f25b555e7b0
parent db57de0ac47061b0a73b83d1295224717b745a80
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 23:02:18 -0400

editor(e2e): site-scope — the picker's first paint, a ?site= link's scope, and the localStorage migration

The three existing assertions that the URL gained `?site=` now assert it did
not (the choice is a cookie; the editor no longer seeds the param), and two
comments say the cookie where they said localStorage. Every other case is
unchanged.

New cases:
- the stored site is the select's value read ONCE after domcontentloaded on
  /, /channels, /jobs, /settings and /sites/alpha, and on client-side
  navigations through the sidebar; an init script records every value the
  select ever had (per DOM mutation and per animation frame) and "__all__" is
  never among them; visiting /sites/beta records beta for the next page; no
  hydration warning. Playwright's retrying toHaveValue cannot see a one-paint
  flash, which the spec says;
- a `?site=` link scopes its own page and is not stored; choosing on it drops
  the param and stores the choice;
- a localStorage choice with no cookie moves to the cookie once and the key is
  removed; with a cookie, a leftover key is removed and never read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/e2e/site-scope.spec.ts | 214++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 208 insertions(+), 6 deletions(-)

diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts @@ -1,4 +1,6 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; +import { activeSiteCookieName } from "../app/lib/activeSite"; +import { baseUrl } from "./baseUrl"; import { readJson, resetData, writeSite } from "./helpers"; type SiteFile = { channels: { slug: string }[] }; @@ -31,14 +33,16 @@ test("selecting a site scopes the channels list and persists", async ({ await page.goto("/channels"); await page.getByLabel("Active site").selectOption("alpha"); - await expect(page).toHaveURL(/site=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); - // localStorage persists the choice: revisiting with no param re-applies it. + // 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(/site=alpha/); + await expect(page).toHaveURL(/\/channels$/); await expect(page.getByRole("link", { name: "slow-b" })).toHaveCount(0); // Switching to beta flips the scope. @@ -70,10 +74,11 @@ test("charts is a site's tab, and the picker follows the path", async ({ await expect(page.getByText(/author the default dashboard/i)).toContainText( "beta", ); - // The path wrote localStorage, so the scoped pages follow. + // 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(/site=beta/); + await expect(page).toHaveURL(/\/channels$/); // "All sites" is the family page. await page.goto("/sites/beta/charts"); await page.getByLabel("Active site").selectOption("__all__"); @@ -138,3 +143,200 @@ test("creating a channel under a site adds it to that site's membership", async 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<HTMLSelectElement>(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<string[]> { + 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; +} + +async function storedCookie(page: Page): Promise<string | undefined> { + 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<string> { + 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"]); + } + + // 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", + ]); + + // 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"); +});