Archilyzer · Source

archilyzer

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

commit 19a207d355c2b58fd165622556b65676118a6014
parent d961a45282005b0b0c52f6a8147b6513a9c8454b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  2 Jul 2026 12:51:57 -0400

Fix three flaky/broken e2e tests: charts hydration, sites nav, PWA sw.js staging

Three e2e failures, all independent of the features they touch:

1. charts.spec.ts — the metadata-path tests clicked the "Chart" view toggle
   after only an SSR-content check, with no runSearch to prove React had
   hydrated. Under dev-server compile load the click could land before the
   onClick attached and be lost, so the view never switched and .recharts-surface
   never mounted → full 20s timeout (non-deterministic, load-dependent). Add an
   openChart() helper that retries the toggle click until chart-options appears
   (the Chart button is an idempotent setView("chart"), so re-clicking is safe).

2. SiteScopeSelect — the sidebar selector's mount effect seeded ?site= via
   router.replace on every route, including the /sites CRUD pages that never read
   it. On /sites the replace to /sites?site=<id> raced and clobbered the in-flight
   push to /sites/<id> from a list-link click, bouncing the user back to the list
   (and failing sites-crud.spec.ts:50). Skip the seed on /sites* routes; scoped-
   page (Dashboard/Channels/Charts/Deploy) seeding is unchanged. Real UX bug fix.

3. pwa.spec.ts — public/sw.js is a generated, gitignored artifact that
   compose:site copies from service-worker/site-sw.js only for a PWA site. The
   e2e harness runs `next dev` without compose, so GET /sw.js passed only when a
   stale build artifact happened to be on disk. Stage it at playwright.config
   load (mirroring compose, like the committed public/icons/*) so the test is
   deterministic on a clean checkout.

Verified: export 84/84 (charts 8/8 ×3, pwa 3/3 from clean), editor sites-crud
10/10 ×2, site-scope 18/18, settings/dashboard/channels green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Meditor/app/components/SiteScopeSelect.tsx | 7+++++++
Mexport/e2e/charts.spec.ts | 32++++++++++++++++++++++++--------
Mexport/playwright.config.ts | 11+++++++++++
4 files changed, 43 insertions(+), 8 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Clicking a site on the Sites list now opens its edit page instead of bouncing back to the list.** The sidebar site selector seeds the active site into the URL (`?site=`) on mount so the scoped server pages (Dashboard, Channels, Charts, Deploy) can read it — but it was also firing on the `/sites` CRUD pages, where a mount-time `router.replace("/sites?site=<id>")` raced and clobbered the in-flight navigation to `/sites/<id>` from a list link, dumping you back on the list. The seed is now skipped on `/sites*` routes (which never consume `?site=`), so site links navigate straight to the editor; scoped-page seeding is unchanged. See `editor/app/components/SiteScopeSelect.tsx`. - **"Build static export" can now skip the data rebuild and compose from existing staging.** The `export/` build normally regenerates the pool-wide data first (its npm `prebuild` hook runs `build:data` = `build:index && build:stats && build:templates`), and the index step (heavy transcript → page-tree + LMDB processing) dominates build time. A new **Skip data rebuild (index, stats, charts)** checkbox on the Build static export control lets you rebuild a site *without* that work — it runs only `compose:site && next build` against the current `.export-index/` staging, which is exactly what you want when re-composing after a code/theme/template change or building a different site from already-staged data. It routes to a new `build:nodata` export script (same body as `build`, but a distinct name so npm's `prebuild` hook doesn't fire); the checkbox reuses the streamed-log/queue/cancel machinery of the existing managed build. Assumes a prior full build produced the staging (do a full build first if the data is stale). Only the build-only control is affected — the one-click **Build & deploy** button always does a full build. See `export/package.json` (`build:nodata`), `editor/app/deploy/buildDeployCore.ts` (`runBuildPhase` `skipData`), `editor/app/build/buildAction.ts` (`buildExportAction`), and `editor/app/deploy/components/BuildExportButton.tsx`. - **The neutral Base theme is now all-sans.** The default Base family previously inherited the shared Source **Serif** display face for the wordmark and page headings; it now uses the sans face instead, matching the pre-theme all-sans look. This is a shared token change (`html:not([data-theme])` in `common/styles/tokens.css`), so any surface on the Base family — including the editor's default — reads sans; the Archive/Selenized/Swiss families keep their own type voices. - **A dedicated Cleanup page with a live "reclaimable" sidebar badge and per-channel include/exclude.** Space-reclaim cleaning now has its own home (`/cleanup`, in the Pool nav) instead of being scattered across each channel's detail page. It opens with a serif **reclamation console** readout — the total reclaimable audio across channels, a token-colored breakdown bar (transcribed audio / extra formats / wrong-format), and a per-channel ledger that runs the same `cleanAudioAction` / `cleanExtraAudioFormatsAction` / `removeWrongFormatAudioAction` sweeps (plus failed-list housekeeping) the channel page does — the per-channel `CleanupStage` stays put. The sidebar **Cleanup** item carries an amber badge with the running reclaimable total (e.g. `12.4 GB`), recomputed on each auto-refresh tick. A per-channel **Counted / Excluded** toggle (new `ChannelConfig.excludeFromCleanup`, persisted in `config.json` like `excludeFromBuild`/`excludeFromSync`) holds a channel's space back from the total without disabling its sweeps — e.g. keep a finicky-to-redownload channel's audio around for now. The headline total uses the primary "clean audio" reclaim only (the three sweeps overlap, so they aren't summed). See `editor/app/cleanup/{page.tsx,lib/loadCleanup.ts,components/{ChannelCleanupCard,ChannelCleanupToggle}.tsx}`, `editor/app/channels/actions.ts` (`toggleChannelCleanupInclusionAction`), `editor/app/{lib/nav.ts,layout.tsx}`, and `common/lib/channelConfig.ts`. diff --git a/editor/app/components/SiteScopeSelect.tsx b/editor/app/components/SiteScopeSelect.tsx @@ -22,6 +22,12 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { const urlValue = searchParams.get("site"); const resolved = resolveActiveSite(urlValue, siteIds); const multi = sites.length > 1; + // The /sites CRUD pages manage every site and never read ?site=; seeding it + // there would only let the mount-effect replace() below clobber an in-flight + // push to /sites/<id> (a link click on the list), bouncing the user back to + // /sites?site=<id>. Only the scoped server pages (Dashboard, Channels, Charts, + // Deploy) consume the param, so restrict the seed to non-/sites routes. + const seedsSiteParam = !pathname.startsWith("/sites"); function setParam(value: string) { const params = new URLSearchParams(searchParams.toString()); @@ -44,6 +50,7 @@ export function SiteScopeSelect({ sites }: { sites: SiteScopeOption[] }) { } return; } + if (!seedsSiteParam) return; let stored: string | null = null; try { stored = window.localStorage.getItem(ACTIVE_SITE_KEY); diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts @@ -20,6 +20,22 @@ function chartTab(page: Page) { return page.getByTestId("view-toggle").getByRole("button", { name: "Chart" }); } +// Switch to the chart view, absorbing a pre-hydration lost click. Playwright's +// click() waits for actionability but not React hydration, so under dev-server +// compile load the first click can land before the toggle's onClick is attached +// and be a no-op — the view never switches and .recharts-surface never mounts. +// chart-options renders immediately once view === "chart", so it confirms the +// switch took; the Chart button is an idempotent setter (setView("chart")), so +// re-clicking is safe. +async function openChart(page: Page) { + await expect(async () => { + await chartTab(page).click(); + await expect(page.getByTestId("chart-options")).toBeVisible({ + timeout: 1_000, + }); + }).toPass({ timeout: 20_000 }); +} + test.describe("charts (search view mode)", () => { test.beforeEach(async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); @@ -29,7 +45,7 @@ test.describe("charts (search view mode)", () => { test("Chart toggle plots the current search", async ({ page }) => { await page.goto("/"); await runSearch(page, "alpha"); - await chartTab(page).click(); + await openChart(page); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); @@ -47,7 +63,7 @@ test.describe("charts (search view mode)", () => { test("Chart toggle with no query plots metadata", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("results-summary")).toContainText("All videos"); - await chartTab(page).click(); + await openChart(page); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); @@ -64,7 +80,7 @@ test.describe("charts (search view mode)", () => { }) => { await page.goto("/"); await runSearch(page, "alpha"); - await chartTab(page).click(); + await openChart(page); const opts = page.getByTestId("chart-options"); // Active query → defaults to Search matches: the Plot metric select shows. await expect(opts.getByText("Plot")).toBeVisible(); @@ -79,7 +95,7 @@ test.describe("charts (search view mode)", () => { }) => { await page.goto("/"); await expect(page.getByTestId("results-summary")).toContainText("All videos"); - await chartTab(page).click(); + await openChart(page); const opts = page.getByTestId("chart-options"); await opts.locator('label:has-text("Dimension") select').selectOption("category"); await opts.locator('label:has-text("Field") select').selectOption("type"); @@ -95,7 +111,7 @@ test.describe("charts (search view mode)", () => { }) => { await page.goto("/"); await expect(page.getByTestId("results-summary")).toContainText("All videos"); - await chartTab(page).click(); + await openChart(page); const opts = page.getByTestId("chart-options"); const dimension = opts.locator('label:has-text("Dimension") select'); const field = opts.locator('label:has-text("Field") select'); @@ -123,7 +139,7 @@ test.describe("charts (search view mode)", () => { }) => { await page.goto("/"); await expect(page.getByTestId("results-summary")).toContainText("All videos"); - await chartTab(page).click(); + await openChart(page); await expect(page.locator(".recharts-surface")).toBeVisible({ timeout: 20_000, }); @@ -148,7 +164,7 @@ test.describe("charts (search view mode)", () => { test("Examples menu sets the chart shape", async ({ page }) => { await page.goto("/"); await runSearch(page, "alpha"); - await chartTab(page).click(); + await openChart(page); const chartType = page .getByTestId("chart-options") .locator("select") @@ -168,7 +184,7 @@ test.describe("charts (search view mode)", () => { }) => { await page.goto("/"); await runSearch(page, "alpha"); - await chartTab(page).click(); + await openChart(page); const chartType = page .getByTestId("chart-options") .locator("select") diff --git a/export/playwright.config.ts b/export/playwright.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; import { buildFixtureSettings } from "./e2e/fixtures/data"; @@ -16,6 +17,16 @@ const TEST_SITES_DIR = path.resolve(process.cwd(), "e2e", "fixtures", "sites"); // spec via `installRoutes`. buildFixtureSettings(TEST_SETTINGS_FILE); +// `public/sw.js` is a generated, gitignored artifact: `compose:site` copies it +// from `export/service-worker/site-sw.js` only for a PWA-shipping site (see +// common/bin/compose-site.ts). The e2e harness runs `next dev` without a compose +// step, so pwa.spec's `GET /sw.js` would depend on a stale build artifact being +// left on disk. Stage it here (mirroring compose) so the service-worker test is +// deterministic on a clean checkout, exactly like the committed public/icons/*. +const SW_SRC = path.resolve(process.cwd(), "service-worker", "site-sw.js"); +const SW_DEST = path.resolve(process.cwd(), "public", "sw.js"); +if (fs.existsSync(SW_SRC)) fs.copyFileSync(SW_SRC, SW_DEST); + export default defineConfig({ testDir: "./e2e", timeout: 30_000,