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/
|
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
|
||||||
|
|||||||
@@ -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"
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ${
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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