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:
@@ -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",
|
||||
|
||||
60
web/playwright.config.ts
Normal file
60
web/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
273
web/tests/api/admin-api.spec.ts
Normal file
273
web/tests/api/admin-api.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
239
web/tests/e2e/01-login.spec.ts
Normal file
239
web/tests/e2e/01-login.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
116
web/tests/e2e/02-dashboard.spec.ts
Normal file
116
web/tests/e2e/02-dashboard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
186
web/tests/e2e/03-cookies.spec.ts
Normal file
186
web/tests/e2e/03-cookies.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
171
web/tests/e2e/04-devices.spec.ts
Normal file
171
web/tests/e2e/04-devices.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
210
web/tests/e2e/05-settings.spec.ts
Normal file
210
web/tests/e2e/05-settings.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
63
web/tests/e2e/06-responsive.spec.ts
Normal file
63
web/tests/e2e/06-responsive.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
52
web/tests/e2e/helpers/auth.ts
Normal file
52
web/tests/e2e/helpers/auth.ts
Normal 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/);
|
||||
}
|
||||
153
web/tests/e2e/helpers/mock-api.ts
Normal file
153
web/tests/e2e/helpers/mock-api.ts
Normal 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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user