test(web): add Playwright E2E and admin API test suite for RCA-19

Prepares the full QA test infrastructure for the admin frontend before
all prerequisite feature tasks (RCA-12–18) are complete.

- playwright.config.ts: 6 browser/device projects (Chromium, Firefox,
  WebKit, mobile Chrome, mobile Safari, tablet)
- tests/e2e/01-login.spec.ts: login form, route guards, setup wizard
- tests/e2e/02-dashboard.spec.ts: stats cards, device list, quick actions
- tests/e2e/03-cookies.spec.ts: cookie list, search, detail panel, delete
- tests/e2e/04-devices.spec.ts: device cards, revoke flow, status filter
- tests/e2e/05-settings.spec.ts: three-tab layout, save/error toasts
- tests/e2e/06-responsive.spec.ts: no horizontal scroll on mobile/tablet
- tests/api/admin-api.spec.ts: REST API contract tests for all /admin/* endpoints
- helpers/auth.ts: loginViaUI + loginViaAPI helpers
- helpers/mock-api.ts: route intercept fixtures for all pages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 20:24:22 +08:00
parent e3a9d9f63c
commit f4144c96f1
11 changed files with 1529 additions and 1 deletions

View File

@@ -6,7 +6,12 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "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": { "dependencies": {
"@headlessui/vue": "^1.7.0", "@headlessui/vue": "^1.7.0",

60
web/playwright.config.ts Normal file
View File

@@ -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,
},
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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 });
});
});

View File

@@ -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();
});
});
}

View File

@@ -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<void> {
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<string> {
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<void> {
// 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/);
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await page.route(urlPattern, (route) =>
route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ error: message }),
}),
);
}