Toolbar feedback round: the series search box now matches whatever the main search matches for a group — series NAME, any book's AUTHOR, and any book's TITLE inside the series (WHERE EXISTS per group-row, so counts stay true); the separate author input is gone from the toolbar (the author= param stays live for the JSON API and deep links). The sort control becomes a base-key select (Date added/Name/Author/Books/ Progress/Last read) plus a chevron direction toggle that flips and resubmits; sort/dir compose through ResolveSeriesSort (full registry keys keep working for back-compat, unknown base keys fall to recent). The search/apply button moved next to the search box as requested, and default URLs stay clean (sort/dir only emitted once the user chooses).
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
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<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("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 () => {
|
|
await page.goto("/bookshelf");
|
|
await expect(page.locator("#header-search")).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. The component
|
|
// must be registered (x-data resolves) and the htmx swap must land.
|
|
await page.fill("#header-search", "a");
|
|
// Below the 2-char minimum: no results may appear.
|
|
await page.waitForTimeout(700);
|
|
await expect(page.locator("#search-results")).toBeHidden();
|
|
|
|
await page.fill("#header-search", "al");
|
|
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,
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|