import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; const BASE = "http://localhost:8765"; const STAMP = Date.now(); const EMAIL = `e2e-search-${STAMP}@example.com`; const USERNAME = `e2e-search-${STAMP}`; const PASSWORD = "E2e-Playwright-2026!"; let token = ""; const consoleFailures: string[] = []; function watchPage(page: Page): void { page.on("pageerror", (err) => { consoleFailures.push(`pageerror: ${err.message}`); }); page.on("console", (msg) => { if (msg.type() === "error") { consoleFailures.push(`console.error: ${msg.text()}`); } }); } function expectNoClientErrors(): void { const bad = consoleFailures.filter((f) => /(is not defined|is not a function|headerSearch|alpine)/i.test(f), ); expect(bad, `uncaught client errors:\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("Header search + audio scrubber (client wiring)", () => { let page: Page; test.beforeAll(async ({ browser, request }) => { const reg = await request.post("/api/auth/register", { data: { email: EMAIL, username: USERNAME, password: PASSWORD }, }); expect([201, 200, 409]).toContain(reg.status()); token = await apiLogin(request); expect(token).toBeTruthy(); page = await browser.newPage(); watchPage(page); await page.context().addCookies([{ name: "token", value: token, url: BASE }]); }); test.afterAll(async () => { expectNoClientErrors(); await page?.close(); }); test("header search dropdown opens on typing (regression: tree-shaken module)", async () => { // Drive the header on TWO pages: the bundle is shared, but this // catches any page-scoped wiring surprise. for (const pagePath of ["/bookshelf", "/series"]) { await page.goto(pagePath); const input = page.locator("#header-search"); await expect(input).toBeVisible(); // The regression this pins: search.ts (which registers the // headerSearch Alpine component) was tree-shaken from the bundle, // so typing did nothing — no request, no dropdown. Type with REAL // key events (not fill) and assert the request carries the query. await input.click(); await input.pressSequentially("algo", { delay: 90 }); const requestPromise = page.waitForRequest( (req) => req.url().includes("/api/media-items/search") && req.url().includes("q=algo") && (req.headers()["hx-request"] === "true" || req.headers()["hx-request"] === "true"), { timeout: 10_000 }, ); const req = await requestPromise; expect(req.url()).toContain("layout=dropdown"); const results = page.locator("#search-results"); await expect(results).toBeVisible({ timeout: 10_000 }); await expect(results.locator(".search-result-item").first()).toBeVisible({ timeout: 10_000, }); // Escape closes the dropdown (keyboard contract). await page.keyboard.press("Escape"); await expect(results).toBeHidden(); } }); test("audio scrubber: grabbing the handle does not teleport (plain and Shift)", async ({ request }) => { // Find an audiobook in the shared dev library (plain media list, // then filter client-side — the search endpoint's facet behavior // with empty q isn't a listing API). const res = await request.get("/api/media-items", { headers: { Authorization: `Bearer ${token}` }, }); expect(res.status()).toBe(200); const body = (await res.json()) as { data?: Array<{ id: string; format_group?: string }> }; const rows = (body.data ?? []).filter((r) => r.format_group === "audiobook"); test.skip(rows.length === 0, "no audiobooks in the dev library"); const bookId = rows[0].id; await page.goto(`/readers/${bookId}`); const scrubArea = page.locator("#scrub-area"); await expect(scrubArea).toBeVisible(); // The fresh user has never played this book — park the playhead at // 30% via the progress API so the grab tests run mid-book. const put = await request.put(`/api/media-items/${bookId}/progress`, { headers: { Authorization: `Bearer ${token}` }, data: { percentage: 0.3, device_id: "e2e-scrubber" }, }); expect([200, 204]).toContain(put.status()); await page.reload(); await expect(scrubArea).toBeVisible(); const box = await scrubArea.boundingBox(); test.skip(!box, "scrub bar not laid out"); const y = box!.y + box!.height / 2; // Where is the thumb right now? The native range input mirrors the // playhead; read its aria-valuenow + max to compute the thumb X. const thumbX = await page.evaluate(() => { const input = document.getElementById("audio-scrub") as HTMLInputElement; const area = document.getElementById("scrub-area")!; const rect = area.getBoundingClientRect(); const value = Number(input.value || 0); const max = Number(input.max || 1); return rect.left + (value / max) * rect.width; }); const readScrubValue = () => page.evaluate(() => Number((document.getElementById("audio-scrub") as HTMLInputElement).value)); const before = await readScrubValue(); expect(before).toBeGreaterThan(0); // --- plain grab: pointerdown ON the thumb must not move it --- await page.mouse.move(thumbX, y); await page.mouse.down(); await page.waitForTimeout(150); const atGrab = await readScrubValue(); expect(Math.abs(atGrab - before)).toBeLessThan( Math.max(1, before * 0.0005), "grabbing the handle must not jump the playhead", ); // Drag left by 60px: the handle follows RELATIVELY. await page.mouse.move(thumbX - 60, y, { steps: 5 }); await page.waitForTimeout(100); const afterDrag = await readScrubValue(); expect(afterDrag).toBeLessThan(atGrab, "dragging left scrubs backward"); await page.mouse.up(); await page.waitForTimeout(400); // seek commits // --- Shift+grab: the precision rail appears, no teleport --- const railBefore = await readScrubValue(); // Re-locate the thumb after the commit. const thumbX2 = await page.evaluate(() => { const input = document.getElementById("audio-scrub") as HTMLInputElement; const area = document.getElementById("scrub-area")!; const rect = area.getBoundingClientRect(); const value = Number(input.value || 0); const max = Number(input.max || 1); return rect.left + (value / max) * rect.width; }); await page.keyboard.down("Shift"); await page.mouse.move(thumbX2, y); await page.mouse.down(); await page.waitForTimeout(150); const railAtGrab = await readScrubValue(); expect(Math.abs(railAtGrab - railBefore)).toBeLessThan( Math.max(1, railBefore * 0.0005), "Shift+grab must not teleport either", ); // The rail is the feedback for Shift+grab — it must be visible. await expect(page.locator('div[x-show="railVisible"]')).toBeVisible(); await page.keyboard.up("Shift"); await page.mouse.up(); }); });