diff --git a/web/package.json b/web/package.json index 2334eb3..4566757 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,12 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test tests/e2e/", + "test:e2e:ui": "playwright test tests/e2e/ --ui", + "test:e2e:headed": "playwright test tests/e2e/ --headed", + "test:api": "playwright test tests/api/ --project=chromium", + "test:all": "playwright test" }, "dependencies": { "@headlessui/vue": "^1.7.0", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..f61bff0 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,60 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * CookieBridge Admin Frontend - Playwright E2E Test Configuration + * + * Prerequisites: RCA-12 (scaffold), RCA-13 (API), RCA-14 (login), + * RCA-15 (dashboard), RCA-16 (cookies), RCA-17 (devices), RCA-18 (settings) + * must all be complete before running these tests. + * + * Usage: + * npm run test:e2e — run all tests headless + * npm run test:e2e:ui — interactive UI mode + * npm run test:e2e:headed — run with browser visible + */ +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:5173", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "mobile-safari", + use: { ...devices["iPhone 12"] }, + }, + { + name: "tablet", + use: { ...devices["iPad Pro 11"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/web/tests/api/admin-api.spec.ts b/web/tests/api/admin-api.spec.ts new file mode 100644 index 0000000..1955f03 --- /dev/null +++ b/web/tests/api/admin-api.spec.ts @@ -0,0 +1,273 @@ +import { test, expect } from "@playwright/test"; + +/** + * Admin REST API integration tests (RCA-13) + * + * These tests call the relay server's /admin/* endpoints directly + * via Playwright's APIRequestContext, without a browser. + * + * Run with: npx playwright test tests/api/ --project=chromium + * + * Requires: + * - Relay server running at BASE_URL (default http://localhost:8100) + * - TEST_ADMIN_USER and TEST_ADMIN_PASS env vars (or defaults admin/testpassword123) + * + * NOTE: These tests assume a clean server state. Run against a dedicated + * test instance, not production. + */ + +const API_BASE = process.env.RELAY_BASE_URL ?? "http://localhost:8100"; +const ADMIN_USER = process.env.TEST_ADMIN_USER ?? "admin"; +const ADMIN_PASS = process.env.TEST_ADMIN_PASS ?? "testpassword123"; + +let adminToken = ""; + +test.describe("Admin Auth API", () => { + test("POST /admin/auth/login — valid credentials returns JWT", async ({ request }) => { + const res = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("token"); + expect(typeof body.token).toBe("string"); + expect(body.token.length).toBeGreaterThan(10); + expect(body).toHaveProperty("expiresAt"); + adminToken = body.token; + }); + + test("POST /admin/auth/login — wrong password returns 401", async ({ request }) => { + const res = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: "wrongpassword" }, + }); + expect(res.status()).toBe(401); + const body = await res.json(); + expect(body).toHaveProperty("error"); + }); + + test("POST /admin/auth/login — missing fields returns 400", async ({ request }) => { + const res = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER }, + }); + expect(res.status()).toBe(400); + }); + + test("GET /admin/auth/me — valid token returns user info", async ({ request }) => { + // Ensure we have a token + if (!adminToken) { + const login = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + adminToken = (await login.json()).token; + } + + const res = await request.get(`${API_BASE}/admin/auth/me`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("username", ADMIN_USER); + }); + + test("GET /admin/auth/me — no token returns 401", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/auth/me`); + expect(res.status()).toBe(401); + }); + + test("POST /admin/auth/logout — clears session", async ({ request }) => { + const login = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + const token = (await login.json()).token; + + const res = await request.post(`${API_BASE}/admin/auth/logout`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect([200, 204]).toContain(res.status()); + + // Token should now be invalid + const me = await request.get(`${API_BASE}/admin/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(me.status()).toBe(401); + }); +}); + +test.describe("Setup API", () => { + test("GET /admin/setup/status returns initialised flag", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/setup/status`); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("initialised"); + expect(typeof body.initialised).toBe("boolean"); + }); +}); + +test.describe("Dashboard API", () => { + test.beforeAll(async ({ request }) => { + const login = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + adminToken = (await login.json()).token; + }); + + test("GET /admin/dashboard — returns stats shape", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/dashboard`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("devices"); + expect(body).toHaveProperty("cookies"); + expect(body).toHaveProperty("syncCount"); + expect(body).toHaveProperty("uptimeSeconds"); + expect(typeof body.syncCount).toBe("number"); + expect(typeof body.uptimeSeconds).toBe("number"); + }); + + test("GET /admin/dashboard — unauthenticated returns 401", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/dashboard`); + expect(res.status()).toBe(401); + }); +}); + +test.describe("Cookies API", () => { + test.beforeAll(async ({ request }) => { + const login = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + adminToken = (await login.json()).token; + }); + + test("GET /admin/cookies — returns list with pagination fields", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/cookies`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("cookies"); + expect(Array.isArray(body.cookies)).toBe(true); + expect(body).toHaveProperty("total"); + expect(typeof body.total).toBe("number"); + }); + + test("GET /admin/cookies?domain=xxx — filters by domain", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/cookies?domain=nonexistent.example`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + // All returned cookies should match the domain filter + for (const cookie of body.cookies) { + expect(cookie.domain).toBe("nonexistent.example"); + } + }); + + test("DELETE /admin/cookies/:id — removes specific cookie", async ({ request }) => { + // First: push a cookie via the device API so we have something to delete + // (Depends on RCA-13 admin API — if there's a test cookie fixture, use that) + // This test is a placeholder that verifies the endpoint contract: + const res = await request.delete(`${API_BASE}/admin/cookies/nonexistent-id`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + // 404 for nonexistent, or 200 if the implementation ignores missing IDs + expect([200, 404]).toContain(res.status()); + }); + + test("DELETE /admin/cookies — bulk delete requires body", async ({ request }) => { + const res = await request.delete(`${API_BASE}/admin/cookies`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { ids: [] }, + }); + expect([200, 400]).toContain(res.status()); + }); + + test("GET /admin/cookies — unauthenticated returns 401", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/cookies`); + expect(res.status()).toBe(401); + }); +}); + +test.describe("Devices API", () => { + test.beforeAll(async ({ request }) => { + const login = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + adminToken = (await login.json()).token; + }); + + test("GET /admin/devices — returns list of devices", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/devices`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("devices"); + expect(Array.isArray(body.devices)).toBe(true); + // Each device should have the expected shape + for (const device of body.devices) { + expect(device).toHaveProperty("id"); + expect(device).toHaveProperty("name"); + expect(device).toHaveProperty("platform"); + expect(device).toHaveProperty("online"); + expect(typeof device.online).toBe("boolean"); + } + }); + + test("POST /admin/devices/:id/revoke — returns 404 for unknown device", async ({ + request, + }) => { + const res = await request.post(`${API_BASE}/admin/devices/nonexistent/revoke`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect([404, 400]).toContain(res.status()); + }); + + test("GET /admin/devices — unauthenticated returns 401", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/devices`); + expect(res.status()).toBe(401); + }); +}); + +test.describe("Settings API", () => { + test.beforeAll(async ({ request }) => { + const login = await request.post(`${API_BASE}/admin/auth/login`, { + data: { username: ADMIN_USER, password: ADMIN_PASS }, + }); + adminToken = (await login.json()).token; + }); + + test("GET /admin/settings — returns settings object", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/settings`, { + headers: { Authorization: `Bearer ${adminToken}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("sync"); + expect(body).toHaveProperty("security"); + expect(body).toHaveProperty("appearance"); + }); + + test("PATCH /admin/settings — partial update is accepted", async ({ request }) => { + const res = await request.patch(`${API_BASE}/admin/settings`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { sync: { autoSync: true } }, + }); + expect([200, 204]).toContain(res.status()); + }); + + test("PATCH /admin/settings — unknown fields are ignored or rejected gracefully", async ({ + request, + }) => { + const res = await request.patch(`${API_BASE}/admin/settings`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { unknownField: "value" }, + }); + expect([200, 204, 400]).toContain(res.status()); + }); + + test("GET /admin/settings — unauthenticated returns 401", async ({ request }) => { + const res = await request.get(`${API_BASE}/admin/settings`); + expect(res.status()).toBe(401); + }); +}); diff --git a/web/tests/e2e/01-login.spec.ts b/web/tests/e2e/01-login.spec.ts new file mode 100644 index 0000000..ef55ec4 --- /dev/null +++ b/web/tests/e2e/01-login.spec.ts @@ -0,0 +1,239 @@ +import { test, expect } from "@playwright/test"; + +/** + * RCA-14: Login page + first-run setup wizard + * + * Covers: + * - Login / logout flow + * - Form validation and error display + * - Route guard: unauthenticated redirect to /login + * - Route guard: authenticated redirect away from /login → dashboard + * - First-run setup wizard (GET /admin/setup/status → redirect to /setup) + */ + +test.describe("Login page", () => { + test("shows username and password fields", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByLabel(/username/i)).toBeVisible(); + await expect(page.getByLabel(/password/i)).toBeVisible(); + await expect( + page.getByRole("button", { name: /log in|sign in/i }), + ).toBeVisible(); + }); + + test("disables submit while fields are empty", async ({ page }) => { + await page.goto("/login"); + const btn = page.getByRole("button", { name: /log in|sign in/i }); + // Should either be disabled or clicking it shows a validation error + const isEmpty = (await btn.getAttribute("disabled")) !== null; + if (!isEmpty) { + await btn.click(); + // At least one validation error should appear + const hasError = await page + .getByRole("alert") + .or(page.locator("[class*=error]")) + .or(page.locator("[class*=invalid]")) + .count(); + expect(hasError).toBeGreaterThan(0); + } + }); + + test("shows error on invalid credentials", async ({ page }) => { + await page.route("**/admin/auth/login", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "Invalid credentials" }), + }), + ); + + await page.goto("/login"); + await page.getByLabel(/username/i).fill("wrong"); + await page.getByLabel(/password/i).fill("wrong"); + await page.getByRole("button", { name: /log in|sign in/i }).click(); + + await expect(page.getByText(/invalid credentials|wrong|incorrect/i)).toBeVisible(); + // Should remain on /login + await expect(page).toHaveURL(/\/login/); + }); + + test("submits form on Enter key", async ({ page }) => { + await page.route("**/admin/auth/login", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }), + }), + ); + + await page.goto("/login"); + await page.getByLabel(/username/i).fill("admin"); + await page.getByLabel(/password/i).fill("password"); + await page.getByLabel(/password/i).press("Enter"); + + await expect(page).toHaveURL(/\/dashboard/); + }); + + test("redirects to dashboard on successful login", async ({ page }) => { + await page.route("**/admin/auth/login", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }), + }), + ); + + await page.goto("/login"); + await page.getByLabel(/username/i).fill("admin"); + await page.getByLabel(/password/i).fill("password"); + await page.getByRole("button", { name: /log in|sign in/i }).click(); + + await expect(page).toHaveURL(/\/dashboard/); + }); +}); + +test.describe("Route guards", () => { + test("unauthenticated user is redirected to /login from protected routes", async ({ + page, + }) => { + for (const route of ["/dashboard", "/cookies", "/devices", "/settings"]) { + await page.goto(route); + await expect(page).toHaveURL(/\/login/); + } + }); + + test("authenticated user visiting /login is redirected to /dashboard", async ({ + page, + }) => { + // Seed a token so the app thinks we're logged in + await page.goto("/login"); + await page.evaluate(() => localStorage.setItem("admin_token", "fake-jwt")); + + // Mock /admin/auth/me to return a valid user + await page.route("**/admin/auth/me", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ username: "admin" }), + }), + ); + + await page.goto("/login"); + await expect(page).toHaveURL(/\/dashboard/); + }); +}); + +test.describe("First-run setup wizard", () => { + test("redirects to /setup when not yet initialised", async ({ page }) => { + await page.route("**/admin/setup/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ initialised: false }), + }), + ); + + await page.goto("/"); + await expect(page).toHaveURL(/\/setup/); + }); + + test("wizard has 4 steps and can be completed", async ({ page }) => { + await page.route("**/admin/setup/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ initialised: false }), + }), + ); + await page.route("**/admin/setup/init", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }), + ); + + await page.goto("/setup"); + + // Step 1: Welcome + await expect(page.getByText(/welcome|cookiebridge/i)).toBeVisible(); + await page.getByRole("button", { name: /next|continue/i }).click(); + + // Step 2: Create admin account + await page.getByLabel(/username/i).fill("admin"); + await page.getByLabel(/^password$/i).fill("Secure123!"); + await page.getByLabel(/confirm password/i).fill("Secure123!"); + await page.getByRole("button", { name: /next|continue/i }).click(); + + // Step 3: Basic config (port, HTTPS) + await expect( + page.getByLabel(/port/i).or(page.getByText(/port|https/i)), + ).toBeVisible(); + await page.getByRole("button", { name: /next|continue/i }).click(); + + // Step 4: Completion + await expect(page.getByText(/done|complete|finish/i)).toBeVisible(); + await page.getByRole("button", { name: /go to login|finish/i }).click(); + await expect(page).toHaveURL(/\/login/); + }); + + test("password mismatch in setup shows error", async ({ page }) => { + await page.route("**/admin/setup/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ initialised: false }), + }), + ); + + await page.goto("/setup"); + await page.getByRole("button", { name: /next|continue/i }).click(); + + await page.getByLabel(/username/i).fill("admin"); + await page.getByLabel(/^password$/i).fill("Secure123!"); + await page.getByLabel(/confirm password/i).fill("Mismatch999!"); + await page.getByRole("button", { name: /next|continue/i }).click(); + + await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible(); + }); +}); + +test.describe("Logout", () => { + test("logout clears session and redirects to /login", async ({ page }) => { + await page.route("**/admin/auth/login", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ token: "test-jwt", expiresAt: "2099-01-01" }), + }), + ); + await page.route("**/admin/auth/logout", (route) => + route.fulfill({ status: 204 }), + ); + await page.route("**/admin/dashboard", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ devices: {}, cookies: {}, syncCount: 0, uptimeSeconds: 0 }), + }), + ); + + // Log in first + await page.goto("/login"); + await page.getByLabel(/username/i).fill("admin"); + await page.getByLabel(/password/i).fill("password"); + await page.getByRole("button", { name: /log in|sign in/i }).click(); + await expect(page).toHaveURL(/\/dashboard/); + + // Log out + const logoutBtn = page + .getByRole("button", { name: /log ?out|sign ?out/i }) + .or(page.getByRole("link", { name: /log ?out|sign ?out/i })); + await logoutBtn.click(); + await expect(page).toHaveURL(/\/login/); + + // Token should be gone + const token = await page.evaluate(() => localStorage.getItem("admin_token")); + expect(token).toBeNull(); + }); +}); diff --git a/web/tests/e2e/02-dashboard.spec.ts b/web/tests/e2e/02-dashboard.spec.ts new file mode 100644 index 0000000..1c6266d --- /dev/null +++ b/web/tests/e2e/02-dashboard.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test"; +import { loginViaAPI } from "./helpers/auth.js"; +import { mockDashboard, mockAPIError } from "./helpers/mock-api.js"; + +/** + * RCA-15: Dashboard + * + * Covers: + * - Stats cards render with correct values + * - Device status list + * - Quick-action links navigate to correct routes + * - Data refresh works + * - Error state when API fails + */ + +test.describe("Dashboard", () => { + test.beforeEach(async ({ page, request }) => { + await loginViaAPI(page, request); + await mockDashboard(page); + }); + + test("shows all four stats cards", async ({ page }) => { + await page.goto("/dashboard"); + + // Connected devices + await expect(page.getByText(/connected devices|devices/i).first()).toBeVisible(); + // Cookie count + await expect(page.getByText(/cookie|cookies/i).first()).toBeVisible(); + // Sync count + await expect(page.getByText(/sync/i).first()).toBeVisible(); + // Uptime + await expect(page.getByText(/uptime|running/i).first()).toBeVisible(); + }); + + test("stats cards display values from the API", async ({ page }) => { + await page.goto("/dashboard"); + // Our mock returns: devices total=3, cookies total=142, syncCount=57 + await expect(page.getByText("3")).toBeVisible(); + await expect(page.getByText("142")).toBeVisible(); + await expect(page.getByText("57")).toBeVisible(); + }); + + test("device status list shows online/offline badges", async ({ page }) => { + await page.route("**/admin/devices*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + devices: [ + { id: "d1", name: "Chrome on macOS", platform: "chrome", online: true, lastSeen: new Date().toISOString() }, + { id: "d2", name: "Firefox on Windows", platform: "firefox", online: false, lastSeen: "2026-03-15T10:00:00Z" }, + ], + }), + }), + ); + + await page.goto("/dashboard"); + + await expect(page.getByText("Chrome on macOS")).toBeVisible(); + await expect(page.getByText("Firefox on Windows")).toBeVisible(); + // At least one online/offline indicator + const badges = page.getByText(/online|offline/i); + await expect(badges.first()).toBeVisible(); + }); + + test("quick action 'View all cookies' navigates to /cookies", async ({ page }) => { + await page.goto("/dashboard"); + await page.getByRole("link", { name: /view all cookie|all cookie|cookie/i }).first().click(); + await expect(page).toHaveURL(/\/cookies/); + }); + + test("quick action 'Manage devices' navigates to /devices", async ({ page }) => { + await page.goto("/dashboard"); + await page.getByRole("link", { name: /manage device|devices/i }).first().click(); + await expect(page).toHaveURL(/\/devices/); + }); + + test("quick action 'Settings' navigates to /settings", async ({ page }) => { + await page.goto("/dashboard"); + await page.getByRole("link", { name: /setting|settings/i }).first().click(); + await expect(page).toHaveURL(/\/settings/); + }); + + test("refresh button re-fetches dashboard data", async ({ page }) => { + let callCount = 0; + await page.route("**/admin/dashboard", (route) => { + callCount++; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ devices: {}, cookies: {}, syncCount: callCount, uptimeSeconds: 0 }), + }); + }); + + await page.goto("/dashboard"); + const refreshBtn = page.getByRole("button", { name: /refresh/i }); + if (await refreshBtn.isVisible()) { + const before = callCount; + await refreshBtn.click(); + expect(callCount).toBeGreaterThan(before); + } + }); + + test("shows error message when dashboard API fails", async ({ page }) => { + await page.unroute("**/admin/dashboard"); + await mockAPIError(page, "**/admin/dashboard", 500, "Server error"); + + await page.goto("/dashboard"); + await expect( + page + .getByRole("alert") + .or(page.getByText(/error|failed|unavailable/i)) + .first(), + ).toBeVisible(); + }); +}); diff --git a/web/tests/e2e/03-cookies.spec.ts b/web/tests/e2e/03-cookies.spec.ts new file mode 100644 index 0000000..f9be871 --- /dev/null +++ b/web/tests/e2e/03-cookies.spec.ts @@ -0,0 +1,186 @@ +import { test, expect } from "@playwright/test"; +import { loginViaAPI } from "./helpers/auth.js"; +import { mockCookies, mockAPIError } from "./helpers/mock-api.js"; + +/** + * RCA-16: Cookie management page + * + * Covers: + * - Cookies grouped by domain + * - Search by domain name + * - Search by cookie name + * - Detail panel shows all fields + * - Delete single cookie with confirmation + * - Bulk delete + * - Domain group collapse/expand + * - Pagination / scroll + * - API error state + */ + +test.describe("Cookie management", () => { + test.beforeEach(async ({ page, request }) => { + await loginViaAPI(page, request); + await mockCookies(page); + }); + + test("lists cookies grouped by domain", async ({ page }) => { + await page.goto("/cookies"); + + await expect(page.getByText("example.com")).toBeVisible(); + await expect(page.getByText("other.io")).toBeVisible(); + }); + + test("search by domain filters results", async ({ page }) => { + await page.goto("/cookies"); + + const searchInput = page + .getByPlaceholder(/search/i) + .or(page.getByRole("searchbox")) + .or(page.getByLabel(/search/i)); + + await searchInput.fill("other.io"); + + await expect(page.getByText("other.io")).toBeVisible(); + await expect(page.getByText("example.com")).not.toBeVisible(); + }); + + test("search by cookie name filters results", async ({ page }) => { + await page.goto("/cookies"); + + const searchInput = page + .getByPlaceholder(/search/i) + .or(page.getByRole("searchbox")) + .or(page.getByLabel(/search/i)); + + await searchInput.fill("session"); + + // "session" cookie under example.com should be visible + await expect(page.getByText("session")).toBeVisible(); + // "token" under other.io should not be visible + await expect(page.getByText("token")).not.toBeVisible(); + }); + + test("clicking a cookie shows detail panel with all fields", async ({ page }) => { + await page.goto("/cookies"); + + // Click the "session" cookie row + await page.getByText("session").first().click(); + + // Detail panel should show all cookie fields + await expect(page.getByText(/name/i)).toBeVisible(); + await expect(page.getByText(/value/i)).toBeVisible(); + await expect(page.getByText(/domain/i)).toBeVisible(); + await expect(page.getByText(/path/i)).toBeVisible(); + await expect(page.getByText(/expires/i)).toBeVisible(); + await expect(page.getByText(/secure/i)).toBeVisible(); + await expect(page.getByText(/httponly/i)).toBeVisible(); + }); + + test("deletes a single cookie after confirmation", async ({ page }) => { + let deleteCalled = false; + await page.route("**/admin/cookies/c1", (route) => { + if (route.request().method() === "DELETE") { + deleteCalled = true; + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + } + return route.continue(); + }); + + await page.goto("/cookies"); + + // Click the first cookie's delete button + const deleteBtn = page + .getByRole("button", { name: /delete/i }) + .first(); + await deleteBtn.click(); + + // Confirmation dialog should appear + await expect( + page.getByRole("dialog").or(page.getByText(/confirm|are you sure/i)), + ).toBeVisible(); + + // Confirm deletion + await page.getByRole("button", { name: /confirm|yes|delete/i }).last().click(); + + expect(deleteCalled).toBe(true); + }); + + test("cancel on delete dialog does not delete the cookie", async ({ page }) => { + let deleteCalled = false; + await page.route("**/admin/cookies/*", (route) => { + if (route.request().method() === "DELETE") { + deleteCalled = true; + } + return route.continue(); + }); + + await page.goto("/cookies"); + const deleteBtn = page.getByRole("button", { name: /delete/i }).first(); + await deleteBtn.click(); + + await page + .getByRole("button", { name: /cancel|no/i }) + .last() + .click(); + + expect(deleteCalled).toBe(false); + }); + + test("can select multiple cookies and bulk delete", async ({ page }) => { + let bulkDeleteCalled = false; + await page.route("**/admin/cookies", (route) => { + if (route.request().method() === "DELETE") { + bulkDeleteCalled = true; + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + } + return route.continue(); + }); + + await page.goto("/cookies"); + + // Select checkboxes + const checkboxes = page.getByRole("checkbox"); + const count = await checkboxes.count(); + if (count > 0) { + await checkboxes.first().check(); + if (count > 1) await checkboxes.nth(1).check(); + + const bulkBtn = page.getByRole("button", { name: /delete selected|bulk delete/i }); + if (await bulkBtn.isVisible()) { + await bulkBtn.click(); + await page.getByRole("button", { name: /confirm|yes|delete/i }).last().click(); + expect(bulkDeleteCalled).toBe(true); + } + } + }); + + test("domain group collapses and expands", async ({ page }) => { + await page.goto("/cookies"); + + // Find a domain group header and click to collapse + const groupHeader = page.getByText("example.com").first(); + await groupHeader.click(); + + // After collapse, cookies within that domain should be hidden + // (exact selector depends on implementation — check one of the children) + const sessionCookie = page.getByText("session"); + // It may be hidden or removed; either is acceptable + const isVisible = await sessionCookie.isVisible().catch(() => false); + // Click again to expand + await groupHeader.click(); + await expect(page.getByText("session")).toBeVisible(); + }); + + test("shows error message when cookies API fails", async ({ page }) => { + await page.unroute("**/admin/cookies*"); + await mockAPIError(page, "**/admin/cookies*", 500, "Failed to load cookies"); + + await page.goto("/cookies"); + await expect( + page + .getByRole("alert") + .or(page.getByText(/error|failed|could not load/i)) + .first(), + ).toBeVisible(); + }); +}); diff --git a/web/tests/e2e/04-devices.spec.ts b/web/tests/e2e/04-devices.spec.ts new file mode 100644 index 0000000..d22f139 --- /dev/null +++ b/web/tests/e2e/04-devices.spec.ts @@ -0,0 +1,171 @@ +import { test, expect } from "@playwright/test"; +import { loginViaAPI } from "./helpers/auth.js"; +import { mockDevices, mockAPIError } from "./helpers/mock-api.js"; + +/** + * RCA-17: Device management page + * + * Covers: + * - Device card grid layout + * - Online/offline status badge + * - Platform icons (chrome, firefox, edge, safari) + * - Last seen time displayed + * - Remote revoke with confirmation dialog + * - Device detail expansion + * - Filter by online status + * - API error state + */ + +test.describe("Device management", () => { + test.beforeEach(async ({ page, request }) => { + await loginViaAPI(page, request); + await mockDevices(page); + }); + + test("displays device cards in a grid", async ({ page }) => { + await page.goto("/devices"); + + await expect(page.getByText("Chrome on macOS")).toBeVisible(); + await expect(page.getByText("Firefox on Windows")).toBeVisible(); + }); + + test("shows online badge for online device", async ({ page }) => { + await page.goto("/devices"); + + // Find the Chrome on macOS card and verify it has an online indicator + const chromeCard = page.locator("[class*=card], [class*=device]").filter({ + hasText: "Chrome on macOS", + }); + await expect(chromeCard).toBeVisible(); + await expect( + chromeCard.getByText(/online/i).or(chromeCard.locator("[class*=online]")), + ).toBeVisible(); + }); + + test("shows offline badge for offline device", async ({ page }) => { + await page.goto("/devices"); + + const ffCard = page.locator("[class*=card], [class*=device]").filter({ + hasText: "Firefox on Windows", + }); + await expect(ffCard).toBeVisible(); + await expect( + ffCard.getByText(/offline/i).or(ffCard.locator("[class*=offline]")), + ).toBeVisible(); + }); + + test("shows last active time for each device", async ({ page }) => { + await page.goto("/devices"); + + await expect(page.getByText(/last seen|last active/i).first()).toBeVisible(); + }); + + test("remote revoke opens confirmation dialog", async ({ page }) => { + await page.goto("/devices"); + + const revokeBtn = page + .getByRole("button", { name: /revoke|logout|sign out/i }) + .first(); + await revokeBtn.click(); + + await expect( + page + .getByRole("dialog") + .or(page.getByText(/confirm|are you sure|revoke/i)) + .first(), + ).toBeVisible(); + }); + + test("confirming revoke calls POST /admin/devices/:id/revoke", async ({ page }) => { + let revokeCalled = false; + await page.route("**/admin/devices/d1/revoke", (route) => { + if (route.request().method() === "POST") { + revokeCalled = true; + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + } + return route.continue(); + }); + + await page.goto("/devices"); + + const revokeBtn = page + .getByRole("button", { name: /revoke|logout|sign out/i }) + .first(); + await revokeBtn.click(); + + await page + .getByRole("button", { name: /confirm|yes|revoke/i }) + .last() + .click(); + + expect(revokeCalled).toBe(true); + }); + + test("cancelling revoke dialog does not call API", async ({ page }) => { + let revokeCalled = false; + await page.route("**/admin/devices/*/revoke", (route) => { + revokeCalled = true; + return route.continue(); + }); + + await page.goto("/devices"); + + const revokeBtn = page + .getByRole("button", { name: /revoke|logout|sign out/i }) + .first(); + await revokeBtn.click(); + + await page + .getByRole("button", { name: /cancel|no/i }) + .last() + .click(); + + expect(revokeCalled).toBe(false); + }); + + test("device detail expansion shows extra fields", async ({ page }) => { + await page.goto("/devices"); + + // Click a device card or expand button to reveal detail + const card = page + .locator("[class*=card], [class*=device]") + .filter({ hasText: "Chrome on macOS" }); + await card.click(); + + await expect( + page + .getByText(/extension version|version/i) + .or(page.getByText(/registered|first seen/i)) + .first(), + ).toBeVisible(); + }); + + test("filter by 'online' shows only online devices", async ({ page }) => { + await page.goto("/devices"); + + const filterSelect = page + .getByLabel(/filter|status/i) + .or(page.getByRole("combobox")) + .or(page.getByRole("listbox")); + + if ((await filterSelect.count()) > 0) { + await filterSelect.first().selectOption({ label: /online/i }); + + await expect(page.getByText("Chrome on macOS")).toBeVisible(); + await expect(page.getByText("Firefox on Windows")).not.toBeVisible(); + } + }); + + test("shows error message when devices API fails", async ({ page }) => { + await page.unroute("**/admin/devices*"); + await mockAPIError(page, "**/admin/devices*", 500, "Failed to load devices"); + + await page.goto("/devices"); + await expect( + page + .getByRole("alert") + .or(page.getByText(/error|failed|could not load/i)) + .first(), + ).toBeVisible(); + }); +}); diff --git a/web/tests/e2e/05-settings.spec.ts b/web/tests/e2e/05-settings.spec.ts new file mode 100644 index 0000000..6c68ef0 --- /dev/null +++ b/web/tests/e2e/05-settings.spec.ts @@ -0,0 +1,210 @@ +import { test, expect } from "@playwright/test"; +import { loginViaAPI } from "./helpers/auth.js"; +import { mockSettings, mockAPIError } from "./helpers/mock-api.js"; + +/** + * RCA-18: Settings page + * + * Covers: + * - Three tabs: Sync / Security / Appearance + * - Settings are pre-populated from GET /admin/settings + * - Changes saved via PATCH /admin/settings + * - Success toast on save + * - Password change (security tab) + * - Theme selection (appearance tab) + * - Language selection (appearance tab) + * - API error on save + */ + +test.describe("Settings page", () => { + test.beforeEach(async ({ page, request }) => { + await loginViaAPI(page, request); + await mockSettings(page); + }); + + test("displays three tabs: sync, security, appearance", async ({ page }) => { + await page.goto("/settings"); + + await expect(page.getByRole("tab", { name: /sync/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: /security/i })).toBeVisible(); + await expect(page.getByRole("tab", { name: /appearance/i })).toBeVisible(); + }); + + // --- Sync tab --- + + test("sync tab: auto-sync toggle reflects saved value", async ({ page }) => { + await page.goto("/settings"); + + await page.getByRole("tab", { name: /sync/i }).click(); + + const toggle = page + .getByRole("switch", { name: /auto.?sync/i }) + .or(page.getByLabel(/auto.?sync/i)); + // Mock returns autoSync: true + await expect(toggle).toBeChecked(); + }); + + test("sync tab: frequency selector shows current value", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("tab", { name: /sync/i }).click(); + + // Mock returns frequency: "realtime" + const select = page + .getByLabel(/frequency/i) + .or(page.getByRole("combobox").filter({ hasText: /realtime/i })); + await expect(select).toBeVisible(); + }); + + test("sync tab: saving calls PATCH /admin/settings", async ({ page }) => { + let patchCalled = false; + await page.route("**/admin/settings", (route) => { + if (route.request().method() === "PATCH") { + patchCalled = true; + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + } + return route.continue(); + }); + + await page.goto("/settings"); + await page.getByRole("tab", { name: /sync/i }).click(); + + // Toggle auto-sync off + const toggle = page + .getByRole("switch", { name: /auto.?sync/i }) + .or(page.getByLabel(/auto.?sync/i)); + await toggle.click(); + + // Some implementations save immediately; others have an explicit Save button + const saveBtn = page.getByRole("button", { name: /save/i }); + if (await saveBtn.isVisible()) await saveBtn.click(); + + expect(patchCalled).toBe(true); + }); + + test("sync tab: success toast appears after save", async ({ page }) => { + await page.route("**/admin/settings", (route) => { + if (route.request().method() === "PATCH") { + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + } + return route.continue(); + }); + + await page.goto("/settings"); + await page.getByRole("tab", { name: /sync/i }).click(); + + const toggle = page + .getByRole("switch", { name: /auto.?sync/i }) + .or(page.getByLabel(/auto.?sync/i)); + await toggle.click(); + + const saveBtn = page.getByRole("button", { name: /save/i }); + if (await saveBtn.isVisible()) await saveBtn.click(); + + await expect( + page.getByText(/saved|success|updated/i).first(), + ).toBeVisible({ timeout: 5000 }); + }); + + // --- Security tab --- + + test("security tab: change password requires current + new + confirm", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("tab", { name: /security/i }).click(); + + await expect(page.getByLabel(/current password/i)).toBeVisible(); + await expect(page.getByLabel(/new password/i)).toBeVisible(); + await expect(page.getByLabel(/confirm.*(new )?password/i)).toBeVisible(); + }); + + test("security tab: password change with mismatch shows error", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("tab", { name: /security/i }).click(); + + await page.getByLabel(/current password/i).fill("oldPass123"); + await page.getByLabel(/new password/i).fill("NewPass456!"); + await page.getByLabel(/confirm.*(new )?password/i).fill("Different789!"); + + await page.getByRole("button", { name: /change|save password|update/i }).click(); + + await expect(page.getByText(/do not match|mismatch|must match/i)).toBeVisible(); + }); + + test("security tab: session timeout field accepts numeric input", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("tab", { name: /security/i }).click(); + + const timeoutField = page + .getByLabel(/session timeout/i) + .or(page.getByRole("spinbutton").filter({ hasText: /timeout/i })); + if (await timeoutField.isVisible()) { + await timeoutField.fill("120"); + await expect(timeoutField).toHaveValue("120"); + } + }); + + // --- Appearance tab --- + + test("appearance tab: theme options present (light/dark/system)", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("tab", { name: /appearance/i }).click(); + + await expect(page.getByText(/light/i)).toBeVisible(); + await expect(page.getByText(/dark/i)).toBeVisible(); + await expect(page.getByText(/system/i)).toBeVisible(); + }); + + test("appearance tab: language selector shows Chinese and English options", async ({ page }) => { + await page.goto("/settings"); + await page.getByRole("tab", { name: /appearance/i }).click(); + + await expect( + page.getByText(/chinese|中文/i).or(page.getByText("zh")), + ).toBeVisible(); + await expect( + page.getByText(/english/i).or(page.getByText("en")), + ).toBeVisible(); + }); + + // --- Error states --- + + test("shows error message when settings fail to load", async ({ page }) => { + await page.unroute("**/admin/settings*"); + await mockAPIError(page, "**/admin/settings*", 500, "Failed to load settings"); + + await page.goto("/settings"); + await expect( + page + .getByRole("alert") + .or(page.getByText(/error|failed|could not load/i)) + .first(), + ).toBeVisible(); + }); + + test("shows error toast when save fails", async ({ page }) => { + await page.route("**/admin/settings", (route) => { + if (route.request().method() === "PATCH") { + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Server error" }), + }); + } + return route.continue(); + }); + + await page.goto("/settings"); + await page.getByRole("tab", { name: /sync/i }).click(); + + const toggle = page + .getByRole("switch", { name: /auto.?sync/i }) + .or(page.getByLabel(/auto.?sync/i)); + await toggle.click(); + + const saveBtn = page.getByRole("button", { name: /save/i }); + if (await saveBtn.isVisible()) await saveBtn.click(); + + await expect( + page.getByText(/error|failed|could not save/i).first(), + ).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/web/tests/e2e/06-responsive.spec.ts b/web/tests/e2e/06-responsive.spec.ts new file mode 100644 index 0000000..cb261d1 --- /dev/null +++ b/web/tests/e2e/06-responsive.spec.ts @@ -0,0 +1,63 @@ +import { test, expect, devices } from "@playwright/test"; +import { loginViaAPI } from "./helpers/auth.js"; +import { mockDashboard, mockCookies, mockDevices, mockSettings } from "./helpers/mock-api.js"; + +/** + * Responsive layout tests + * + * These run on the default desktop viewport; the Playwright projects + * in playwright.config.ts also exercise mobile-chrome, mobile-safari, + * and tablet viewports automatically. + * + * This file adds explicit viewport-override tests for key layout expectations. + */ + +const PAGES = [ + { path: "/dashboard", name: "Dashboard" }, + { path: "/cookies", name: "Cookies" }, + { path: "/devices", name: "Devices" }, + { path: "/settings", name: "Settings" }, +]; + +for (const { path, name } of PAGES) { + test.describe(`Responsive — ${name}`, () => { + test.beforeEach(async ({ page, request }) => { + await loginViaAPI(page, request); + await mockDashboard(page); + await mockCookies(page); + await mockDevices(page); + await mockSettings(page); + }); + + test("renders without horizontal scroll on mobile (375px)", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(path); + await page.waitForLoadState("networkidle"); + + const scrollWidth = await page.evaluate(() => document.body.scrollWidth); + const clientWidth = await page.evaluate(() => document.body.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); // 1px tolerance + }); + + test("renders without horizontal scroll on tablet (768px)", async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto(path); + await page.waitForLoadState("networkidle"); + + const scrollWidth = await page.evaluate(() => document.body.scrollWidth); + const clientWidth = await page.evaluate(() => document.body.clientWidth); + expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1); + }); + + test("navigation is reachable on mobile", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(path); + + // On mobile there's typically a hamburger menu or bottom nav + const nav = page + .getByRole("navigation") + .or(page.getByRole("button", { name: /menu|nav/i })); + await expect(nav.first()).toBeVisible(); + }); + }); +} diff --git a/web/tests/e2e/helpers/auth.ts b/web/tests/e2e/helpers/auth.ts new file mode 100644 index 0000000..f1d45a8 --- /dev/null +++ b/web/tests/e2e/helpers/auth.ts @@ -0,0 +1,52 @@ +import { type Page, type APIRequestContext, expect } from "@playwright/test"; + +export const TEST_ADMIN = { + username: process.env.TEST_ADMIN_USER ?? "admin", + password: process.env.TEST_ADMIN_PASS ?? "testpassword123", +}; + +/** + * Log in via the UI login form and wait for the dashboard to load. + */ +export async function loginViaUI(page: Page): Promise { + await page.goto("/login"); + await page.getByLabel(/username/i).fill(TEST_ADMIN.username); + await page.getByLabel(/password/i).fill(TEST_ADMIN.password); + await page.getByRole("button", { name: /log in|sign in/i }).click(); + await expect(page).toHaveURL(/\/dashboard/); +} + +/** + * Log in via the admin API directly and store the token in localStorage. + * Faster than UI login for tests that only need an authenticated session. + */ +export async function loginViaAPI( + page: Page, + request: APIRequestContext, +): Promise { + const resp = await request.post("/admin/auth/login", { + data: { username: TEST_ADMIN.username, password: TEST_ADMIN.password }, + }); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body).toHaveProperty("token"); + + await page.goto("/"); + await page.evaluate( + ({ token }) => localStorage.setItem("admin_token", token), + { token: body.token as string }, + ); + return body.token as string; +} + +/** + * Log out via the UI and confirm redirect to /login. + */ +export async function logoutViaUI(page: Page): Promise { + // Common patterns: a "Logout" button in the nav/header + const logoutBtn = page + .getByRole("button", { name: /log ?out|sign ?out/i }) + .or(page.getByRole("link", { name: /log ?out|sign ?out/i })); + await logoutBtn.click(); + await expect(page).toHaveURL(/\/login/); +} diff --git a/web/tests/e2e/helpers/mock-api.ts b/web/tests/e2e/helpers/mock-api.ts new file mode 100644 index 0000000..5cc668c --- /dev/null +++ b/web/tests/e2e/helpers/mock-api.ts @@ -0,0 +1,153 @@ +import { type Page } from "@playwright/test"; + +/** + * Intercept /admin/dashboard and return a canned response so UI tests + * don't depend on a running relay server with real data. + */ +export async function mockDashboard(page: Page): Promise { + await page.route("**/admin/dashboard", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + devices: { total: 3, online: 2, offline: 1 }, + cookies: { total: 142, domains: 8 }, + syncCount: 57, + uptimeSeconds: 86400, + }), + }), + ); +} + +/** + * Intercept /admin/cookies and return a paginated list. + */ +export async function mockCookies(page: Page): Promise { + await page.route("**/admin/cookies*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + cookies: [ + { + id: "c1", + domain: "example.com", + name: "session", + value: "abc123", + path: "/", + expires: "2027-01-01T00:00:00Z", + secure: true, + httpOnly: true, + }, + { + id: "c2", + domain: "example.com", + name: "pref", + value: "dark", + path: "/", + expires: "2027-06-01T00:00:00Z", + secure: false, + httpOnly: false, + }, + { + id: "c3", + domain: "other.io", + name: "token", + value: "xyz", + path: "/", + expires: null, + secure: true, + httpOnly: true, + }, + ], + total: 3, + page: 1, + }), + }), + ); +} + +/** + * Intercept /admin/devices and return device list. + */ +export async function mockDevices(page: Page): Promise { + await page.route("**/admin/devices*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + devices: [ + { + id: "d1", + name: "Chrome on macOS", + platform: "chrome", + online: true, + lastSeen: new Date().toISOString(), + registeredAt: "2026-01-01T00:00:00Z", + ipAddress: "192.168.1.10", + extensionVersion: "2.0.0", + }, + { + id: "d2", + name: "Firefox on Windows", + platform: "firefox", + online: false, + lastSeen: "2026-03-15T10:00:00Z", + registeredAt: "2026-02-01T00:00:00Z", + ipAddress: null, + extensionVersion: "2.0.0", + }, + ], + }), + }), + ); +} + +/** + * Intercept /admin/settings and return settings object. + */ +export async function mockSettings(page: Page): Promise { + await page.route("**/admin/settings*", (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + sync: { + autoSync: true, + frequency: "realtime", + domainWhitelist: [], + domainBlacklist: [], + }, + security: { + sessionTimeoutMinutes: 60, + requirePairingPin: false, + }, + appearance: { + theme: "system", + language: "zh", + }, + }), + }); + } + return route.fulfill({ status: 200, contentType: "application/json", body: "{}" }); + }); +} + +/** + * Simulate a 500 error on the given path — used for error-handling tests. + */ +export async function mockAPIError( + page: Page, + urlPattern: string, + status = 500, + message = "Internal Server Error", +): Promise { + await page.route(urlPattern, (route) => + route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ error: message }), + }), + ); +}