test(e2e): add Playwright system smoke and regression suite

Covers: health, auth guards, register/login/token flow, every
protected page render with per-page uncaught-error capture, devices
modal wiring, stats tabs, docs pages, theme switch persistence,
reader links against real library content, security header presence,
and explicit regression assertions for the functions removed in the
dead-code cycle (Clear Queue button, queue endpoints).
This commit is contained in:
John O'Keefe committed 2026-10-25 11:24:25 -04:00
1 parent b6eba66801
commit ecfd7276ab
4 files changed
+250

No files matched your search

+1
View File
@@ -86,3 +86,4 @@ session-*.md
# Binary media fixtures: never commit (generate with ffmpeg at test time, # Binary media fixtures: never commit (generate with ffmpeg at test time,
# see docs/contributing/development.md → Media fixtures) # see docs/contributing/development.md → Media fixtures)
internal/services/testdata/ internal/services/testdata/
test-results
+232
View File
@@ -0,0 +1,232 @@
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
const BASE = "http://localhost:8765";
const STAMP = Date.now();
const EMAIL = `e2e-${STAMP}@example.com`;
const USERNAME = `e2e-player-${STAMP}`;
const PASSWORD = "E2e-Playwright-2026!";
let token = "";
// Collect uncaught page errors per page; every test asserts none of them
// mention the functions this cleanup cycle removed or fixed.
const consoleFailures: string[] = [];
function watchPage(page: Page, label: string): void {
page.on("pageerror", (err) => {
consoleFailures.push(`[${label}] pageerror: ${err.message}`);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleFailures.push(`[${label}] console.error: ${msg.text()}`);
}
});
}
function expectNoRemovedFunctionErrors(): void {
const bad = consoleFailures.filter((f) =>
/(clearSyncQueue|addBookToSelection|removeBookFromSelection|initDashboard|initStatsPage|selectTab|selectRange|selectLibrary|logout|changeTheme|showErrorToast|setupProfileModal|is not defined|is not a function)/i.test(
f,
),
);
expect(bad, `uncaught errors referencing removed/fixed functions:\n${bad.join("\n")}`).toEqual([]);
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status(), "login should succeed").toBe(200);
const body = await res.json();
return body.access_token as string;
}
test.describe.configure({ mode: "serial" });
test.describe("Bookhoard system smoke + regression", () => {
let page: Page;
test.beforeAll(async ({ browser, request }) => {
const reg = await request.post("/api/auth/register", {
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
});
// 201 created OR 409 already-registered (rerun tolerance)
expect([201, 200, 409]).toContain(reg.status());
token = await apiLogin(request);
expect(token).toBeTruthy();
page = await browser.newPage();
// Auth for server-rendered pages travels in the `token` cookie.
await page.context().addCookies([
{ name: "token", value: token, url: BASE },
]);
});
test.afterAll(async () => {
expectNoRemovedFunctionErrors();
await page?.close();
});
test("health endpoint reports healthy + db connected", async ({ request }) => {
const res = await request.get("/health");
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.status).toBe("healthy");
expect(body.database).toBe("connected");
});
test("unauthenticated API is guarded", async ({ request }) => {
const res = await request.get("/api/auth/profile");
expect([401, 403]).toContain(res.status());
});
test("login API returns a bearer token + user", async ({ request }) => {
const res = await request.post("/api/auth/login", {
data: { login: EMAIL, password: PASSWORD },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.access_token).toBeTruthy();
expect(body.user).toBeTruthy();
});
test("authenticated profile works with bearer token", async ({ request }) => {
const res = await request.get("/api/auth/profile", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
const data = body.data ?? body;
expect(String(data.username ?? data.Username ?? "")).toContain("e2e-player");
});
test("removed queue endpoints stay gone", async ({ request }) => {
const res = await request.get("/api/queue/all", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status()).toBe(404);
});
test("login page renders", async () => {
watchPage(page, "login");
const res = await page.goto("/login");
expect(res?.status()).toBe(200);
await expect(page.locator("body")).toContainText(/log ?in|sign in/i);
});
const protectedPages: Array<[string, RegExp]> = [
["/dashboard", /dashboard|continue|library|section/i],
["/bookshelf", /bookshelf|book|filter|library/i],
["/collections", /collection/i],
["/series", /series/i],
["/devices", /device/i],
["/stats", /stat|reading|summary|minutes/i],
];
for (const [path, bodyRe] of protectedPages) {
test(`page ${path} renders authenticated with no uncaught errors`, async () => {
watchPage(page, path);
const res = await page.goto(path);
expect(res?.status(), `${path} should render`).toBe(200);
await expect(page.locator("body")).toContainText(bodyRe);
// the theme flash guard must have painted a theme class
await expect(page.locator("body")).toHaveClass(/theme-/);
});
}
test("devices page: add-device modal opens; Clear Queue button is gone", async () => {
watchPage(page, "devices-modal");
await page.goto("/devices");
const clearQueue = page.getByRole("button", { name: /clear queue/i });
await expect(clearQueue).toHaveCount(0);
// open the add-device modal to prove the page's Alpine wiring works
const addBtn = page.getByRole("button", { name: /add device|add new device/i }).first();
if (await addBtn.count()) {
await addBtn.click();
await expect(page.locator("#add-device-modal")).toBeVisible();
await expect(page.locator("#device-type")).toBeVisible();
}
});
test("stats page: tabs switch with no errors (selectTab restored)", async () => {
watchPage(page, "stats-tabs");
await page.goto("/stats");
const tabs = page.locator("[data-tab], .tab-btn, [role=tab]");
const count = await tabs.count();
console.log("stats tab buttons found:", count);
if (count > 1) {
await tabs.nth(1).click();
await page.waitForTimeout(300);
await tabs.nth(0).click();
}
});
test("docs: overview and an endpoint page render", async () => {
watchPage(page, "docs");
const res = await page.goto("/docs");
expect(res?.status()).toBe(200);
await expect(page.locator("body")).toContainText(/api|documentation/i);
// endpoint docs render from the split markdown pages
const res2 = await page.request.get("/docs/developer/api/api-reference");
expect([200, 301, 302]).toContain(res2.status());
});
test("theme switch persists via the theme module (unexport-only, still wired)", async () => {
watchPage(page, "theme");
await page.goto("/dashboard");
const before = await page.evaluate(() => document.body.className);
await page.evaluate(() => {
localStorage.setItem("theme", "light");
});
await page.reload();
const after = await page.evaluate(() => document.body.className);
expect(after).toContain("theme-");
// restore the original theme so the dev environment is untouched
await page.evaluate((b) => {
const m = /\btheme-([\w-]+)/.exec(b);
localStorage.setItem("theme", m ? m[1] : "tokyo-night");
}, before);
});
test("bookshelf exposes a reader link for real library content", async () => {
watchPage(page, "reader-link");
await page.goto("/bookshelf");
const readerLink = page.locator('a[href*="/readers/"]').first();
if (await readerLink.count()) {
const href = await readerLink.getAttribute("href");
console.log("reader href:", href);
const res = await page.request.get(href ?? "", {
headers: { Authorization: `Bearer ${token}` },
});
// the reader page itself is an SSR page: 200 expected when the item exists
expect([200]).toContain(res.status());
} else {
console.log("no reader links on bookshelf — skipped");
}
});
test("registration page renders with complexity-validated form", async () => {
watchPage(page, "register");
const res = await page.goto("/register");
expect(res?.status()).toBe(200);
await expect(page.locator("body")).toContainText(/register|create/i);
});
test("security headers are on every response", async ({ request }) => {
const res = await request.get("/login");
const h = res.headers();
expect(h["x-content-type-options"]).toBe("nosniff");
expect(h["x-frame-options"]).toBe("DENY");
expect(h["content-security-policy"]).toContain("default-src 'self'");
expect(h["referrer-policy"]).toBe("strict-origin-when-cross-origin");
});
test("no console failures were collected anywhere", async () => {
expectNoRemovedFunctionErrors();
// strict: any pageerror at all is a failure; console.error entries that are
// network 4xx noise from intentional 401/404 probes are tolerated.
const real = consoleFailures.filter((f) => f.startsWith("[") && f.includes("pageerror"));
expect(real, `uncaught page errors:\n${real.join("\n")}`).toEqual([]);
});
});
+1
View File
@@ -25,6 +25,7 @@
"turndown-plugin-gfm": "^1.0.2" "turndown-plugin-gfm": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.1",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: "http://localhost:8765",
headless: true,
viewport: { width: 1440, height: 900 },
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
});