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:
@@ -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).
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user