From ecfd7276ab384d5fc467bbc1a58d0846ba30b5d5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 25 Oct 2026 11:24:25 -0400 Subject: [PATCH] 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). --- .gitignore | 1 + e2e/system.spec.ts | 232 +++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + playwright.config.ts | 16 +++ 4 files changed, 250 insertions(+) create mode 100644 e2e/system.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index bcf36af6..b4b5b536 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ session-*.md # Binary media fixtures: never commit (generate with ffmpeg at test time, # see docs/contributing/development.md → Media fixtures) internal/services/testdata/ +test-results diff --git a/e2e/system.spec.ts b/e2e/system.spec.ts new file mode 100644 index 00000000..043e3e1b --- /dev/null +++ b/e2e/system.spec.ts @@ -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 { + 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([]); + }); +}); diff --git a/package.json b/package.json index 259438cc..efa6a9c3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "turndown-plugin-gfm": "^1.0.2" }, "devDependencies": { + "@playwright/test": "^1.64.1", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@types/turndown": "^5.0.6", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..d7a5d0f4 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +});