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