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

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