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:
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,