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:
@@ -16,6 +16,13 @@ node_modules/
|
||||
frontend/dist/
|
||||
*.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
|
||||
*.dump
|
||||
*.sql.gz
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"playwright": {
|
||||
"config": ["playwright.config.ts"],
|
||||
"entry": ["e2e/**/*.@(spec|setup).ts"]
|
||||
}
|
||||
}
|
||||
Generated
+82
@@ -20,6 +20,8 @@
|
||||
"react-i18next": "^14.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -866,6 +868,22 @@
|
||||
"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": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1328,6 +1346,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -2198,6 +2226,53 @@
|
||||
"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": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -2778,6 +2853,13 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"react-i18next": "^14.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
@@ -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;
|
||||
|
||||
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">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
data-testid="active-filter-chip"
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={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 && (
|
||||
<button
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
|
||||
@@ -244,6 +244,7 @@ export default function ChannelPage({
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
data-testid="channel-back"
|
||||
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"
|
||||
>
|
||||
@@ -263,7 +264,7 @@ export default function ChannelPage({
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<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 ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||
{t("channel.blockedBadge")}
|
||||
|
||||
@@ -538,6 +538,7 @@ export default function Feed({
|
||||
{SHOW_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`feed-show-${id}`}
|
||||
onClick={() => setFilters({ ...filters, show: id })}
|
||||
aria-pressed={filters.show === id}
|
||||
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="text-xs text-muted whitespace-nowrap">
|
||||
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||
{countQuery.data
|
||||
? t("feed.videoCount", {
|
||||
count: countQuery.data.count,
|
||||
|
||||
@@ -236,6 +236,7 @@ export default function NavSidebar({
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
data-testid={`nav-${p}`}
|
||||
onClick={() => setPage(p)}
|
||||
title={slim ? label : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
@@ -350,6 +351,7 @@ export default function NavSidebar({
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
data-testid="nav-settings"
|
||||
onClick={() => setPage("settings")}
|
||||
title={slim ? t("header.account.settings") : undefined}
|
||||
aria-current={page === "settings" ? "page" : undefined}
|
||||
|
||||
@@ -591,6 +591,7 @@ export default function PlayerModal({
|
||||
return createPortal(
|
||||
<div
|
||||
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"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
@@ -801,6 +802,7 @@ export default function PlayerModal({
|
||||
document.body
|
||||
)}
|
||||
<button
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
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"
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function SettingsPanel({
|
||||
return (
|
||||
<button
|
||||
key={tabItem.id}
|
||||
data-testid={`settings-tab-${tabItem.id}`}
|
||||
onClick={() => setTab(tabItem.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
||||
active
|
||||
|
||||
@@ -37,6 +37,8 @@ function TagChip({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
data-testid="filter-tag-chip"
|
||||
data-tag={tag.name}
|
||||
onClick={onClick}
|
||||
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 ${
|
||||
|
||||
@@ -448,6 +448,7 @@ function VideoCard({
|
||||
const channelButton = (
|
||||
<button
|
||||
ref={channelRef}
|
||||
data-testid="video-card-channel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
@@ -497,7 +498,7 @@ function VideoCard({
|
||||
const pct = resumePct(video);
|
||||
const fits = (needs: number) => width === 0 || width >= needs;
|
||||
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
|
||||
they're the shortest pointer trip from it. */}
|
||||
<div className="shrink-0 w-48">{actions}</div>
|
||||
@@ -550,7 +551,7 @@ function VideoCard({
|
||||
|
||||
if (spec.family === "row") {
|
||||
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
|
||||
video={video}
|
||||
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.
|
||||
return (
|
||||
<div
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
className={clsx(
|
||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
|
||||
"flex flex-col",
|
||||
|
||||
@@ -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
|
||||
// and stays put.
|
||||
const bar = (
|
||||
<div className={wrap}>
|
||||
<span className="text-sm text-muted">
|
||||
<div data-testid="draft-save-bar" className={wrap}>
|
||||
<span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
|
||||
{state === "saved" ? (
|
||||
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
||||
<Check className="w-4 h-4" />
|
||||
@@ -59,6 +59,7 @@ export function DraftSaveBar({
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-testid="draft-discard"
|
||||
onClick={onDiscard}
|
||||
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"
|
||||
@@ -67,6 +68,7 @@ export function DraftSaveBar({
|
||||
{labels.discard}
|
||||
</button>
|
||||
<button
|
||||
data-testid="draft-save"
|
||||
onClick={onSave}
|
||||
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"
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ActiveFilter {
|
||||
}
|
||||
|
||||
/** 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: [],
|
||||
tagMode: "or",
|
||||
channelIds: [],
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user