// Baseline end-to-end coverage of the page UI. Tests drive the app through // visible labels, not implementation details, so they keep passing when the // client-side mechanics change (e.g. the move to HTMX). import { test, expect } from "@playwright/test"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki"); // Fail any test that logs a JS error. test.beforeEach(({ page }) => { page.on("pageerror", (err) => { throw err; }); }); const sidebar = (page) => page.locator("aside.tree-sidebar"); const modal = (page) => page.locator(".modal"); // Page actions sit in a dropdown on narrow screens and inline (icon-only) on // wide ones; address them by title and open the dropdown only when present. async function pageAction(page, title) { const toggle = page.getByRole("button", { name: "ACTIONS" }); if (await toggle.isVisible()) await toggle.click(); await page.locator("header").getByTitle(title).click(); } test("renders a page and navigates via the sidebar", async ({ page }) => { await page.goto("/"); await expect(page.locator("main h1")).toContainText("Home"); await sidebar(page).getByRole("link", { name: "Projects" }).click(); await expect(page).toHaveURL(/\/Projects\/$/); await expect(page.locator("main h1")).toContainText("Projects"); // The sidebar expands the active folder's children. await expect(sidebar(page).getByRole("link", { name: "Alpha" })).toBeVisible(); }); test("task checkbox toggle persists", async ({ page }) => { await page.goto("/Projects/"); const box = page.locator("main input.task-checkbox").first(); await expect(box).not.toBeChecked(); await box.check(); await expect(box).toBeEnabled(); await expect.poll(() => readFileSync(join(wiki, "Projects", "index.md"), "utf8")).toContain("- [x] first task"); await page.reload(); await expect(page.locator("main input.task-checkbox").first()).toBeChecked(); }); test("unchecking a task persists", async ({ page }) => { await page.goto("/Projects/"); const box = page.locator("main input.task-checkbox").nth(1); await expect(box).toBeChecked(); await box.uncheck(); await expect(box).toBeEnabled(); await expect.poll(() => readFileSync(join(wiki, "Projects", "index.md"), "utf8")).toContain("- [ ] second task"); }); test("a failed task save reverts the checkbox", async ({ page }) => { page.on("dialog", (d) => d.dismiss()); await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" })); await page.goto("/Projects/"); const box = page.locator("main input.task-checkbox").nth(1); const before = await box.isChecked(); await box.click(); await expect(box).toBeEnabled(); await expect(box).toBeChecked({ checked: before }); }); test("view settings switch the listing to thumbnails", async ({ page }) => { await page.goto("/Projects/"); await expect(page.locator("main table.data-table")).toBeVisible(); await page.getByTitle("View & sorting").click(); await modal(page).getByText("Thumbnail").click(); await modal(page).getByLabel("Sort by").selectOption("size"); await modal(page).getByText("Desc").click(); await modal(page).getByRole("button", { name: "SAVE" }).click(); await expect(page.locator("main .thumb-grid")).toBeVisible(); await expect(page).toHaveURL(/\/Projects\/$/); // Reopening shows the saved state. await page.getByTitle("View & sorting").click(); await expect(modal(page).getByLabel("Thumbnail")).toBeChecked(); await expect(modal(page).getByLabel("Sort by")).toHaveValue("size"); await expect(modal(page).getByLabel("Desc")).toBeChecked(); }); test("editor saves a page", async ({ page }) => { await page.goto("/Projects/Alpha/"); await pageAction(page, "Edit page (E)"); await expect(page).toHaveURL(/\?edit/); const editor = page.locator(".cm-content"); await editor.click(); await page.keyboard.press("Control+End"); await page.keyboard.type("\nAdded by e2e.\n"); await page.getByRole("button", { name: "SAVE" }).click(); await expect(page).toHaveURL(/\/Projects\/Alpha\/$/); await expect(page.locator("main")).toContainText("Added by e2e."); // Save replaces the editor's history entry: Back leaves the page entirely. await page.goBack(); await expect(page).not.toHaveURL(/\?edit/); }); test("move renames a page", async ({ page }) => { await page.goto("/Projects/Beta/"); await pageAction(page, "Move page (M)"); // Tree picker preselects the current parent; keep it. await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByPlaceholder("Page name").fill("Gamma"); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect(page).toHaveURL(/\/Projects\/Gamma\/$/); await expect(page.locator("main h1")).toContainText("Beta"); }); test("move onto an empty folder offers a merge", async ({ page }) => { mkdirSync(join(wiki, "Projects", "Empty"), { recursive: true }); await page.goto("/Projects/Alpha/"); await pageAction(page, "Move page (M)"); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByPlaceholder("Page name").fill("Empty"); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect(modal(page)).toContainText("Merge folders?"); await modal(page).getByRole("button", { name: "MERGE" }).click(); await expect(page).toHaveURL(/\/Projects\/Empty\/$/); await expect(page.locator("main h1")).toContainText("Alpha"); }); test("delete removes a page and returns to the parent", async ({ page }) => { await page.goto("/Notes/"); await pageAction(page, "Delete page"); await expect(modal(page)).toContainText("Delete /Notes/"); await modal(page).getByRole("button", { name: "DELETE" }).click(); await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/$/); await expect(sidebar(page).getByRole("link", { name: "Notes" })).toHaveCount(0); }); test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => { mkdirSync(join(wiki, "Scratch", "Gone"), { recursive: true }); writeFileSync(join(wiki, "Scratch", "Gone", "index.md"), "# Gone\n"); await page.goto("/Scratch/"); await page.goto("/Scratch/Gone/"); await pageAction(page, "Delete page"); await modal(page).getByRole("button", { name: "DELETE" }).click(); await expect(page).toHaveURL(/\/Scratch\/$/); await page.goBack(); await expect(page).toHaveURL(/\/Scratch\/$/); }); test("server dialogs close with Escape and CANCEL", async ({ page }) => { await page.goto("/Projects/"); await pageAction(page, "Delete page"); await expect(modal(page)).toBeVisible(); // CANCEL is focused, so Enter must not delete. await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused(); await page.keyboard.press("Escape"); await expect(modal(page)).toHaveCount(0); await pageAction(page, "Delete page"); await modal(page).getByRole("button", { name: "CANCEL" }).click(); await expect(modal(page)).toHaveCount(0); await expect(page).toHaveURL(/\/Projects\/$/); }); test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => { mkdirSync(join(wiki, "Taken"), { recursive: true }); writeFileSync(join(wiki, "Taken", "index.md"), "# Taken\n"); mkdirSync(join(wiki, "Mover"), { recursive: true }); writeFileSync(join(wiki, "Mover", "index.md"), "# Mover\n"); await page.goto("/Mover/"); await pageAction(page, "Move page (M)"); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByPlaceholder("Page name").fill("Taken"); // Dismiss the alert from the handler: an open alert blocks the click. const messages = []; page.on("dialog", (d) => { messages.push(d.message()); d.dismiss(); }); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]); await expect(modal(page)).toBeVisible(); await expect(page).toHaveURL(/\/Mover\/$/); }); function mkpage(rel, title) { mkdirSync(join(wiki, rel), { recursive: true }); writeFileSync(join(wiki, rel, "index.md"), `# ${title}\n`); } test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => { const treeRequests = []; page.on("request", (r) => { if (r.url().includes("?tree")) treeRequests.push(r.url()); }); const res = await page.request.get("/Projects/"); expect(await res.text()).toMatch(/class="tree-name tree-folder" href="\/Projects\/"/); await page.goto("/Projects/"); await expect(sidebar(page).locator(".tree-row.is-active")).toContainText("Projects"); expect(treeRequests).toEqual([]); }); test("sidebar folders expand lazily and collapse", async ({ page }) => { mkpage("Tree/One/Deep", "Deep"); mkpage("Tree/Two", "Two"); await page.goto("/"); const treeRow = sidebar(page).locator(".tree-row", { hasText: "Tree" }); const chevron = treeRow.locator(".tree-chevron"); const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content); await expect(sidebar(page).getByRole("link", { name: "One" })).toHaveCount(0); expect(await glyph()).toBe('"▸"'); await chevron.click(); await expect(sidebar(page).getByRole("link", { name: "One" })).toBeVisible(); expect(await glyph()).toBe('"▾"'); // One has a subfolder, Two has none: only One gets a chevron. await expect(sidebar(page).locator("summary.tree-row", { hasText: "One" })).toHaveCount(1); await expect(sidebar(page).locator(".tree-row", { hasText: "Two" }).locator(".tree-chevron")).toHaveClass(/is-leaf/); await treeRow.locator(".tree-chevron").click(); await expect(sidebar(page).getByRole("link", { name: "One" })).toBeHidden(); }); test("tree picker selects inside a lazily expanded folder", async ({ page }) => { mkpage("Tree/One/Deep", "Deep"); mkpage("Mover2", "Mover2"); await page.goto("/Mover2/"); await pageAction(page, "Move page (M)"); const picker = modal(page).locator(".tree-picker"); await picker.locator(".tree-row", { hasText: "Tree" }).locator(".tree-chevron").click(); await picker.locator(".tree-row", { hasText: "One" }).locator(".tree-chevron").click(); await picker.getByText("Deep", { exact: true }).click(); await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Tree/One/Deep"); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect(page).toHaveURL(/\/Tree\/One\/Deep\/Mover2\/$/); }); test("tree picker can pick the root", async ({ page }) => { mkpage("Tree/Two", "Two"); await page.goto("/Tree/Two/"); await pageAction(page, "Move page (M)"); // The current parent is preselected and its chain expanded. await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Tree"); await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click(); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByPlaceholder("Page name").fill("TwoTop"); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/TwoTop\/$/); }); test("editor embed picker selects a file", async ({ page }) => { mkpage("Embeds", "Embeds"); writeFileSync(join(wiki, "Embeds", "photo.jpg"), "x"); await page.goto("/Embeds/?edit"); await page.locator(".cm-content").click(); await page.keyboard.press("Alt+Shift+G"); const picker = modal(page).locator(".tree-picker"); // Folder mode rows aren't selectable here; the file is. await picker.getByText("photo.jpg").click(); await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Embeds/photo.jpg"); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByRole("button", { name: "INSERT" }).click(); await expect(page.locator(".cm-content")).toContainText("photo.jpg"); }); test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => { await page.setViewportSize({ width: 400, height: 800 }); await page.goto("/Projects/"); await expect(sidebar(page)).toBeHidden(); await page.getByRole("button", { name: "ACTIONS" }).click(); await page.getByTitle("Folders").click(); const overlay = page.locator(".overlay"); await expect(overlay.getByRole("link", { name: "Projects" })).toBeVisible(); await overlay.getByRole("link", { name: "Projects" }).click(); await expect(overlay).toHaveCount(0); }); async function seedSearch(page) { for (const n of ["Zebra Crossing", "Zebra Finch", "Zeta1", "Zeta2", "Zeta3", "Zeta4", "Zeta5", "Zeta6"]) { mkpage(`Suggest/${n}`, n); } expect((await page.request.post("/_reindex")).status()).toBe(204); } const suggest = (page) => page.locator("header .suggest-dropdown"); test("header typeahead: highlight, keyboard pick", async ({ page }) => { await seedSearch(page); await page.goto("/"); const input = page.locator("header .search-input"); await input.fill("finch"); await expect(suggest(page)).toBeVisible(); await expect(suggest(page).locator(".suggest-row").first().locator("strong")).toHaveText("Finch"); await input.press("ArrowDown"); await expect(suggest(page).locator(".suggest-row.is-active")).toContainText("Zebra Finch"); await input.press("Enter"); await expect(page).toHaveURL(/\/Suggest\/Zebra%20Finch\/$/); }); test("header typeahead: click, footer, escape, short query", async ({ page }) => { await seedSearch(page); await page.goto("/"); const input = page.locator("header .search-input"); await input.fill("z"); await page.waitForTimeout(300); await expect(suggest(page)).toBeHidden(); await input.fill("zeta"); const footer = suggest(page).locator(".suggest-footer"); const { total } = await (await page.request.get("/_search?q=zeta")).json(); expect(total).toBeGreaterThan(5); await expect(footer).toHaveText(`Show all ${total} matches`); await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5); await input.press("Escape"); await expect(suggest(page)).toBeHidden(); await input.fill("zebra c"); await suggest(page).locator(".suggest-name", { hasText: "Zebra Crossing" }).click(); await expect(page).toHaveURL(/\/Suggest\/Zebra%20Crossing\/$/); await page.goto("/"); await page.locator("header .search-input").fill("zeta"); await suggest(page).locator(".suggest-footer").click(); await expect(page).toHaveURL(/\/\?q=zeta$/); }); test("editor link dialog fills the query from a suggestion", async ({ page }) => { await seedSearch(page); await page.goto("/Suggest/Zeta1/?edit"); await page.locator(".cm-content").click(); await page.keyboard.press("Alt+Shift+P"); const query = modal(page).getByPlaceholder("Search query"); await query.fill("crossing"); await modal(page).locator(".suggest-row", { hasText: "Zebra Crossing" }).click(); await expect(query).toHaveValue("Zebra Crossing"); await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused(); await modal(page).getByRole("button", { name: "INSERT" }).click(); await expect(page.locator(".cm-content")).toContainText("[[Zebra Crossing]]"); }); test("editor [[ autocomplete still uses the JSON endpoint", async ({ page }) => { await seedSearch(page); await page.goto("/Suggest/Zeta2/?edit"); await page.locator(".cm-content").click(); await page.keyboard.press("Control+End"); await page.keyboard.type("\n[[finc"); await expect(page.locator(".cm-tooltip-autocomplete")).toContainText("Zebra Finch"); });