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
+7
View File
@@ -16,6 +16,13 @@ node_modules/
frontend/dist/ frontend/dist/
*.log *.log
# Playwright E2E (browsers install globally; these are per-run artifacts + the auth state)
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
frontend/playwright/.cache/
frontend/e2e/.auth/
# Data / dumps # Data / dumps
*.dump *.dump
*.sql.gz *.sql.gz
+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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"playwright": {
"config": ["playwright.config.ts"],
"entry": ["e2e/**/*.@(spec|setup).ts"]
}
}
+82
View File
@@ -20,6 +20,8 @@
"react-i18next": "^14.1.2" "react-i18next": "^14.1.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
@@ -866,6 +868,22 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1328,6 +1346,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2198,6 +2226,53 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2778,6 +2853,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+2
View File
@@ -22,6 +22,8 @@
"react-i18next": "^14.1.2" "react-i18next": "^14.1.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
+51
View File
@@ -0,0 +1,51 @@
import { defineConfig, devices } from "@playwright/test";
/**
* E2E characterization (golden-master) net for the Platform refactor.
*
* Target (PW_BASE_URL): defaults to the baked localdev stack on :8080 (production-faithful,
* same-origin, no HMR/WS gaps). Point it at http://localhost:5173 for the fast Vite loop — all
* five characterization flows are proxy-safe (none use the WebSocket).
*
* The stack must already be UP (owned by `siftlode dev`); there is deliberately no `webServer`.
* `e2e/global.setup.ts` seeds the dedicated test account and logs in once, sharing the resulting
* session via storageState so the real tests never re-authenticate (auth endpoints are rate-limited).
*
* Runs serial / single-worker on purpose: the specs share ONE seeded account, and a couple of
* them mutate server state (Settings save), so determinism beats parallelism here.
*/
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
const STORAGE_STATE = "e2e/.auth/state.json";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: [["list"], ["html", { open: "never" }]],
timeout: 30_000,
expect: { timeout: 7_000 },
use: {
baseURL: BASE_URL,
storageState: STORAGE_STATE,
locale: "en-US",
// retain-on-failure (not on-first-retry): with retries:0 a "first retry" never happens, so a
// trace would never be captured — this keeps a full trace for any failure, discarded on success.
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "setup",
testMatch: /global\.setup\.ts/,
use: { storageState: { cookies: [], origins: [] } },
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
dependencies: ["setup"],
},
],
});
@@ -16,13 +16,19 @@ export default function ActiveFilterChips({
if (filters.length === 0) return null; if (filters.length === 0) return null;
return ( return (
<div className="flex flex-wrap items-center gap-1.5 pb-3" aria-label={t("feed.chips.label")}> <div
data-testid="active-filter-chips"
className="flex flex-wrap items-center gap-1.5 pb-3"
aria-label={t("feed.chips.label")}
>
<span className="text-[11px] uppercase tracking-wider text-muted mr-0.5"> <span className="text-[11px] uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")} {t("feed.chips.label")}
</span> </span>
{filters.map((f) => ( {filters.map((f) => (
<button <button
key={f.key} key={f.key}
data-testid="active-filter-chip"
data-filter-key={f.key}
onClick={f.remove} onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })} title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })} aria-label={t("feed.chips.remove", { name: f.label })}
@@ -34,6 +40,7 @@ export default function ActiveFilterChips({
))} ))}
{filters.length > 1 && ( {filters.length > 1 && (
<button <button
data-testid="active-filter-clear-all"
onClick={onClearAll} onClick={onClearAll}
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition" className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
> >
+2 -1
View File
@@ -244,6 +244,7 @@ export default function ChannelPage({
/> />
)} )}
<button <button
data-testid="channel-back"
onClick={onBack} onClick={onBack}
className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm" className="absolute top-5 left-7 sm:left-9 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-sm"
> >
@@ -263,7 +264,7 @@ export default function ChannelPage({
/> />
<div className="flex-1 min-w-0 pb-1"> <div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold truncate">{name}</h1> <h1 data-testid="channel-title" className="text-xl font-semibold truncate">{name}</h1>
{ch?.blocked ? ( {ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger"> <span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")} {t("channel.blockedBadge")}
+2 -1
View File
@@ -538,6 +538,7 @@ export default function Feed({
{SHOW_IDS.map((id) => ( {SHOW_IDS.map((id) => (
<button <button
key={id} key={id}
data-testid={`feed-show-${id}`}
onClick={() => setFilters({ ...filters, show: id })} onClick={() => setFilters({ ...filters, show: id })}
aria-pressed={filters.show === id} aria-pressed={filters.show === id}
className={`text-xs px-3 py-1.5 rounded-full border transition ${ className={`text-xs px-3 py-1.5 rounded-full border transition ${
@@ -595,7 +596,7 @@ export default function Feed({
</> </>
)} )}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span className="text-xs text-muted whitespace-nowrap"> <span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
{countQuery.data {countQuery.data
? t("feed.videoCount", { ? t("feed.videoCount", {
count: countQuery.data.count, count: countQuery.data.count,
+2
View File
@@ -236,6 +236,7 @@ export default function NavSidebar({
return ( return (
<button <button
key={p} key={p}
data-testid={`nav-${p}`}
onClick={() => setPage(p)} onClick={() => setPage(p)}
title={slim ? label : undefined} title={slim ? label : undefined}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
@@ -350,6 +351,7 @@ export default function NavSidebar({
</button> </button>
</div> </div>
<button <button
data-testid="nav-settings"
onClick={() => setPage("settings")} onClick={() => setPage("settings")}
title={slim ? t("header.account.settings") : undefined} title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined} aria-current={page === "settings" ? "page" : undefined}
+2
View File
@@ -591,6 +591,7 @@ export default function PlayerModal({
return createPortal( return createPortal(
<div <div
ref={dialogRef} ref={dialogRef}
data-testid="player-modal"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
@@ -801,6 +802,7 @@ export default function PlayerModal({
document.body document.body
)} )}
<button <button
data-testid="player-close"
onClick={onClose} onClick={onClose}
title={t("player.closeEsc")} title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition" className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
@@ -69,6 +69,7 @@ export default function SettingsPanel({
return ( return (
<button <button
key={tabItem.id} key={tabItem.id}
data-testid={`settings-tab-${tabItem.id}`}
onClick={() => setTab(tabItem.id)} onClick={() => setTab(tabItem.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${ className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active active
+2
View File
@@ -37,6 +37,8 @@ function TagChip({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<button <button
data-testid="filter-tag-chip"
data-tag={tag.name}
onClick={onClick} onClick={onClick}
title={t("sidebar.channelCount", { count })} title={t("sidebar.channelCount", { count })}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${ className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
+5 -2
View File
@@ -448,6 +448,7 @@ function VideoCard({
const channelButton = ( const channelButton = (
<button <button
ref={channelRef} ref={channelRef}
data-testid="video-card-channel"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel")); onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
@@ -497,7 +498,7 @@ function VideoCard({
const pct = resumePct(video); const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs; const fits = (needs: number) => width === 0 || width >= needs;
return ( return (
<div className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}> <div data-testid="video-card" data-video-id={video.id} className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so {/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. */} they're the shortest pointer trip from it. */}
<div className="shrink-0 w-48">{actions}</div> <div className="shrink-0 w-48">{actions}</div>
@@ -550,7 +551,7 @@ function VideoCard({
if (spec.family === "row") { if (spec.family === "row") {
return ( return (
<div className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}> <div data-testid="video-card" data-video-id={video.id} className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
<Thumb <Thumb
video={video} video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")} className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
@@ -580,6 +581,8 @@ function VideoCard({
// by a pixel or two forever, which shows up as the whole page juddering. // by a pixel or two forever, which shows up as the whole page juddering.
return ( return (
<div <div
data-testid="video-card"
data-video-id={video.id}
className={clsx( className={clsx(
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1", "cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
"flex flex-col", "flex flex-col",
+4 -2
View File
@@ -44,8 +44,8 @@ export function DraftSaveBar({
// mask) scopes this z-40 and fades the bar's bottom edge. The inline variant is in normal flow // mask) scopes this z-40 and fades the bar's bottom edge. The inline variant is in normal flow
// and stays put. // and stays put.
const bar = ( const bar = (
<div className={wrap}> <div data-testid="draft-save-bar" className={wrap}>
<span className="text-sm text-muted"> <span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
{state === "saved" ? ( {state === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5"> <span className="text-emerald-500 inline-flex items-center gap-1.5">
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
@@ -59,6 +59,7 @@ export function DraftSaveBar({
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
data-testid="draft-discard"
onClick={onDiscard} onClick={onDiscard}
disabled={saving || !dirty} disabled={saving || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition" className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
@@ -67,6 +68,7 @@ export function DraftSaveBar({
{labels.discard} {labels.discard}
</button> </button>
<button <button
data-testid="draft-save"
onClick={onSave} onClick={onSave}
disabled={saving || !dirty} disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ActiveFilter {
} }
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */ /** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
export const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = { const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [], tags: [],
tagMode: "or", tagMode: "or",
channelIds: [], channelIds: [],
+174
View File
@@ -0,0 +1,174 @@
"""Idempotent seed for the E2E characterization (golden-master) account.
Run INSIDE the api container — it needs the app's models + the argon2 hasher:
docker exec -i siftlode-api-1 python - < scripts/e2e_seed.py
Resets a dedicated, NON-demo test account to a known baseline every run, so the Playwright
golden-master net is deterministic and never touches a real account. Self-healing: a crashed
spec that left the account dirty is cleaned on the next run. Prints one JSON line describing
what it seeded (the wrapper / global-setup reads it).
Credentials come from env (E2E_EMAIL / E2E_PASSWORD) with local-only defaults; the account
lives only in the localdev DB.
"""
import json
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, "/app")
from sqlalchemy import delete, func, select # noqa: E402
from app.db import SessionLocal # noqa: E402
from app.models import ( # noqa: E402
Channel,
Playlist,
PlaylistItem,
Subscription,
User,
Video,
VideoState,
)
from app.security import hash_password # noqa: E402
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
PASSWORD = os.environ.get("E2E_PASSWORD", "e2e-golden-master")
N_SUBS = 3
def _now() -> datetime:
return datetime.now(timezone.utc)
def main() -> None:
db = SessionLocal()
try:
# 1. Upsert the account and always reset it to a clean, loginable baseline
# (email_verified + is_active + not suspended + not demo + password set).
user = db.scalar(select(User).where(User.email == EMAIL))
if user is None:
user = User(email=EMAIL)
db.add(user)
user.password_hash = hash_password(PASSWORD)
user.email_verified = True
user.is_active = True
user.is_suspended = False
user.is_demo = False
user.google_sub = None
user.role = "user"
user.display_name = "E2E Tester"
user.preferences = {"language": "en"}
db.commit()
# 2. Subscriptions: the top-N organic channels by catalog size — deterministic, and
# >1 so a channel filter visibly narrows the feed. Reset each run.
top = db.execute(
select(Video.channel_id, func.count().label("n"))
.join(Channel, Channel.id == Video.channel_id)
.where(Channel.from_search.is_(False), Channel.from_explore.is_(False))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(N_SUBS)
).all()
channel_ids = [row[0] for row in top]
db.execute(delete(Subscription).where(Subscription.user_id == user.id))
for cid in channel_ids:
db.add(Subscription(user_id=user.id, channel_id=cid, subscribed_at=_now()))
db.commit()
# 3. Deterministic watch state (reset each run):
# - one in-progress video (drives the resume bar + "in progress" filter) from the
# first channel — status stays "new" so it still counts as unwatched;
# - one WATCHED video from the second channel, so the "watched" show-filter is a
# real, non-empty narrowing of the feed (and the "all" vs "unwatched" split exists).
db.execute(delete(VideoState).where(VideoState.user_id == user.id))
def _newest(channel_id: str, exclude: set[str]) -> Video | None:
conds = [
Video.channel_id == channel_id,
Video.duration_seconds.is_not(None),
Video.live_status == "none",
]
if exclude:
conds.append(Video.id.not_in(exclude))
return db.scalar(
select(Video)
.where(*conds)
.order_by(Video.published_at.desc())
.limit(1)
)
used: set[str] = set()
in_progress = None
watched = None
if channel_ids:
vid = _newest(channel_ids[0], used)
if vid is not None:
db.add(
VideoState(
user_id=user.id,
video_id=vid.id,
status="new",
position_seconds=42,
progress_updated_at=_now(),
)
)
in_progress = vid.id
used.add(vid.id)
if len(channel_ids) > 1:
wv = _newest(channel_ids[1], used)
if wv is not None:
db.add(
VideoState(
user_id=user.id,
video_id=wv.id,
status="watched",
watched_at=_now(),
)
)
watched = wv.id
used.add(wv.id)
# 4. One "saved" video (Watch-later membership drives the saved marker) from the last
# subscribed channel. Reset the built-in playlist's items each run.
wl = db.scalar(
select(Playlist).where(
Playlist.user_id == user.id, Playlist.kind == "watch_later"
)
)
if wl is None:
wl = Playlist(user_id=user.id, name="Watch later", kind="watch_later")
db.add(wl)
db.commit()
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == wl.id))
saved = None
if channel_ids:
svid = _newest(channel_ids[-1], used)
if svid is not None:
db.add(PlaylistItem(playlist_id=wl.id, video_id=svid.id, position=0))
saved = svid.id
db.commit()
print(
json.dumps(
{
"ok": True,
"user_id": user.id,
"email": EMAIL,
"channels": channel_ids,
"in_progress": in_progress,
"watched": watched,
"saved": saved,
}
)
)
finally:
db.close()
if __name__ == "__main__":
main()