diff --git a/.gitignore b/.gitignore index 6710e89..4d92360 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md new file mode 100644 index 0000000..f461999 --- /dev/null +++ b/frontend/e2e/README.md @@ -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). diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts new file mode 100644 index 0000000..9a4313a --- /dev/null +++ b/frontend/e2e/channel.spec.ts @@ -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(); + }); +}); diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts new file mode 100644 index 0000000..95de5ce --- /dev/null +++ b/frontend/e2e/feed.spec.ts @@ -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
(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); + }); +}); diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts new file mode 100644 index 0000000..ba904a9 --- /dev/null +++ b/frontend/e2e/global.setup.ts @@ -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.`. + 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 }); +}); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts new file mode 100644 index 0000000..e69ae93 --- /dev/null +++ b/frontend/e2e/helpers.ts @@ -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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/e2e/nav.spec.ts b/frontend/e2e/nav.spec.ts new file mode 100644 index 0000000..9d8b37a --- /dev/null +++ b/frontend/e2e/nav.spec.ts @@ -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"); + }); +}); diff --git a/frontend/e2e/player.spec.ts b/frontend/e2e/player.spec.ts new file mode 100644 index 0000000..a059939 --- /dev/null +++ b/frontend/e2e/player.spec.ts @@ -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); + }); +}); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts new file mode 100644 index 0000000..98797b4 --- /dev/null +++ b/frontend/e2e/settings.spec.ts @@ -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); + }); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..05fda24 --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -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"] +} diff --git a/frontend/knip.json b/frontend/knip.json new file mode 100644 index 0000000..03dea5b --- /dev/null +++ b/frontend/knip.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "playwright": { + "config": ["playwright.config.ts"], + "entry": ["e2e/**/*.@(spec|setup).ts"] + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 813b685..2b68764 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 897d71f..26c103b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..5aa2730 --- /dev/null +++ b/frontend/playwright.config.ts @@ -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"], + }, + ], +}); diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 6709e68..545e46c 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -16,13 +16,19 @@ export default function ActiveFilterChips({ if (filters.length === 0) return null; return ( -
+
{t("feed.chips.label")} {filters.map((f) => (