test(e2e): golden-master Playwright net for the Platform refactor

Locks the five flows the refactor must not break (feed, channel, player,
settings, nav) so later sprints can prove they changed nothing. Adds a thin
data-testid layer and a dedicated seeded non-demo account; the suite runs on
the baked :8080 stack (authoritative) or :5173. No product-behaviour change.
This commit is contained in:
2026-07-18 00:35:57 +02:00
parent bc5f95f807
commit e7091158fd
25 changed files with 709 additions and 8 deletions
+49
View File
@@ -0,0 +1,49 @@
# E2E characterization (golden-master) net
Playwright tests that lock the app's five core flows — **feed** (scroll + filter), **channel**
(open → back), **player** (open/close), **settings** (dirty-save + guard), and **page switching**
so the Platform refactor can *prove* it changed nothing. They assert current, correct behavior;
they deliberately do **not** encode the known Platform bugs (those get their own specs when the
sprints that fix them land).
## Run
```sh
siftlode e2e # against the baked localdev :8080 stack (authoritative)
siftlode e2e-ui # same, Playwright UI mode
siftlode e2e-seed # (re)seed the test account only, no tests
```
The localdev stack must be up (`siftlode dev`). `global.setup.ts` reseeds the account and logs in
once, sharing the session via `e2e/.auth/state.json` (git-ignored).
**Target**`PW_BASE_URL` (default `http://127.0.0.1:8080`):
- **`:8080`** (baked SPA) is authoritative and same-origin. After a **frontend change you must
`siftlode dev` (rebuild)** for its baked SPA to carry the latest test hooks.
- **`:5173`** (Vite dev) serves source live — the fast refactor loop; it proxies `/api` to `:8080`,
so the stack must still be up. All five flows are proxy-safe (none use the WebSocket):
```sh
PW_BASE_URL=http://localhost:5173 siftlode e2e
```
## Account & fixture
Tests run as a dedicated, **non-demo** account `e2e@siftlode.local` (never a real account), reset to
a deterministic baseline every run by [`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come
from `E2E_EMAIL` / `E2E_PASSWORD` (local-only defaults). Override the container name with
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
## Selectors
The app has no `data-testid`s of its own and all copy is i18n (en/hu), so the specs key on a thin
`data-testid` layer (added with this sprint) plus the locale pinned to `en`. Stable ARIA hooks that
already existed are used directly (`role="dialog"` on the player, `aria-current="page"` on the
active nav row, `aria-pressed` on the feed toggles).
## Typecheck
`npx tsc -p e2e/tsconfig.json --noEmit` (the specs are outside the app's `src` tsconfig).
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
// is exactly what the Platform refactor folds into the PageShell contract.)
test.describe("channel view", () => {
test("open from a card, then in-page Back returns to the feed", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("channel-back")).toBeVisible();
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("video-card").first()).toBeVisible();
});
test("browser Back also closes the channel page", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.goBack();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("video-card").first()).toBeVisible();
});
});
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
// Locks the feed's core contract: it renders cards, a filter re-queries it and surfaces a
// removable chip, and its virtualized list windows in fresh rows as you scroll.
test.describe("feed", () => {
test("a filter re-queries the feed and surfaces a removable chip", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const count = page.getByTestId("feed-result-count");
await expect(count).toHaveText(/\d/);
const baseline = await count.innerText();
// Clean baseline: default filters, so no chips and "unwatched" is the active show.
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change.
await page.getByTestId("feed-show-watched").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
await expect(count).not.toHaveText(baseline);
// Remove the chip → default show restored, chips gone.
await page.getByTestId("active-filter-chip").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
});
test("scrolling virtualizes in fresh cards", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const renderedIds = () =>
page
.getByTestId("video-card")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-video-id")));
const before = await renderedIds();
expect(before.length).toBeGreaterThan(0);
// The feed scrolls inside <main> (role="main"), not the window.
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 5000));
// A windowed list renders different rows once scrolled.
await expect
.poll(async () => (await renderedIds()).some((id) => id && !before.includes(id)))
.toBe(true);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { test as setup, expect } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* One-time setup for the characterization net (the `setup` project the tests depend on):
* 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`);
* 2. seed — reset the dedicated E2E account to a known baseline, in-container (idempotent);
* 3. authenticate ONCE via password-login and persist the session + `en` locale to
* storageState, so the real specs never re-auth (auth endpoints are rate-limited).
*/
const STORAGE = "e2e/.auth/state.json";
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
const PASSWORD = process.env.E2E_PASSWORD || "e2e-golden-master";
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
const HERE = dirname(fileURLToPath(import.meta.url));
const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py");
// The in-container seed only makes sense when the target is the local stack.
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
setup("seed + authenticate", async ({ page }) => {
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
const health = await page.request.get("/healthz");
expect(
health.ok(),
`localdev stack not reachable at ${BASE_URL} — start it with \`siftlode dev\``,
).toBeTruthy();
// 2. Idempotent seed inside the api container (skippable / local-only).
if (!process.env.E2E_SKIP_SEED && isLocalTarget) {
try {
const out = execFileSync(
"docker",
["exec", "-i", "-e", `E2E_EMAIL=${EMAIL}`, "-e", `E2E_PASSWORD=${PASSWORD}`, CONTAINER, "python", "-"],
{ input: readFileSync(SEED_SCRIPT) },
);
console.log(`[e2e seed] ${out.toString().trim()}`);
} catch (err: unknown) {
const e = err as { stderr?: Buffer; message?: string };
throw new Error(`E2E seed failed: ${e.stderr?.toString() || e.message}`);
}
}
// 3. Authenticate. page.request shares the browser context's cookie jar, so the `session`
// cookie it receives is captured by storageState below.
const login = await page.request.post("/auth/password-login", {
data: { email: EMAIL, password: PASSWORD },
});
expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy();
const me = await (await page.request.get("/api/me")).json();
expect(me.authenticated, "session not established after login").toBeTruthy();
// 4. Pin the UI locale and suppress the first-login onboarding wizard (a full-screen overlay
// that would intercept every click — the test account has no YouTube connection, so it
// auto-opens). The dismissed flag is per-account: `siftlode.onboarding.dismissed.<id>`.
await page.goto("/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.lang", "en");
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
}, me.id);
mkdirSync(dirname(STORAGE), { recursive: true });
await page.context().storageState({ path: STORAGE });
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, type Page } from "@playwright/test";
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> {
await page.goto(path);
await expect(page.getByTestId("nav-feed")).toBeVisible();
}
/** Click a top-level module in the nav rail and confirm it becomes the active page. */
export async function gotoPage(page: Page, id: string): Promise<void> {
await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
}
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
export async function openFirstVideo(page: Page): Promise<void> {
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card").first().getByRole("link").first().click();
await expect(page.getByTestId("player-modal")).toBeVisible();
}
+31
View File
@@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
// Locks top-level navigation: each rail item activates its page (aria-current), and browser
// Back steps through the history the app pushes. The render ternary + prop-drilled page state
// this exercises is precisely what the module-registry refactor replaces.
test.describe("page switching", () => {
// Core, non-admin modules that always exist for the E2E account.
const RAIL = ["channels", "playlists", "stats", "settings", "feed"];
test("each rail item activates its page", async ({ page }) => {
await openApp(page);
for (const id of RAIL) {
await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
}
});
test("browser Back steps through visited pages", async ({ page }) => {
await openApp(page);
await page.getByTestId("nav-channels").click();
await expect(page.getByTestId("nav-channels")).toHaveAttribute("aria-current", "page");
await page.getByTestId("nav-playlists").click();
await expect(page.getByTestId("nav-playlists")).toHaveAttribute("aria-current", "page");
await page.goBack();
await expect(page.getByTestId("nav-channels")).toHaveAttribute("aria-current", "page");
});
});
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { openApp, openFirstVideo } from "./helpers";
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
// inconsistent mounting caused an E3 regression — exactly the kind of thing the refactor's
// overlay-root convention must not break. We assert the modal shell, not YouTube playback.
test.describe("player modal", () => {
test.beforeEach(async ({ page }) => {
// Hermetic: don't actually load the YouTube iframe/player.
await page.route(/youtube\.com/, (route) => route.abort());
});
test("opens from a card and closes via the close button", async ({ page }) => {
await openApp(page);
await openFirstVideo(page);
await page.getByTestId("player-close").click();
await expect(page.getByTestId("player-modal")).toHaveCount(0);
});
test("closes on Escape", async ({ page }) => {
await openApp(page);
await openFirstVideo(page);
await page.keyboard.press("Escape");
await expect(page.getByTestId("player-modal")).toHaveCount(0);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from "@playwright/test";
import { openApp, gotoPage } from "./helpers";
// Locks the Settings draft/dirty-save machine: an edit raises the save bar, Save persists via
// PUT /me/preferences, and leaving with unsaved changes is guarded. The dirty-guard is one of
// the behaviors the god-component refactor must preserve exactly.
test.describe("settings dirty-save", () => {
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
async function putPrefs(page: import("@playwright/test").Page) {
return page.waitForResponse(
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
);
}
test("edit raises the bar and Save persists via PUT /me/preferences", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
const bar = page.getByTestId("draft-save-bar");
const status = page.getByTestId("draft-save-status");
const toggle = page.getByRole("switch", { name: TOGGLE });
// Clean: the bar renders nothing.
await expect(bar).toHaveCount(0);
// Edit → the bar appears.
await toggle.click();
await expect(bar).toBeVisible();
// Save → the PUT fires and the status flips to "saved".
const [saved] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
expect(saved.ok()).toBeTruthy();
await expect(status).toHaveAttribute("data-state", "saved");
// Restore the baseline (toggle back and save) so the shared account stays clean. Assert the
// restore's own PUT (not the lingering "saved" from the first save, which shows for 2.5s).
await toggle.click();
const [restored] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
expect(restored.ok()).toBeTruthy();
});
test("leaving with unsaved changes is guarded", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
await page.getByRole("switch", { name: TOGGLE }).click();
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
// Navigating away raises the confirm guard instead of leaving.
await page.getByTestId("nav-feed").click();
await expect(page.getByText("You have unsaved settings")).toBeVisible();
// Cancel → stay on Settings, still dirty.
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
// Discard → clean, so the account is left at baseline.
await page.getByTestId("draft-discard").click();
await expect(page.getByTestId("draft-save-bar")).toHaveCount(0);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node", "@playwright/test"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": [".", "../playwright.config.ts"]
}