// End-to-end coverage of the page UI. Tests drive the app through visible // labels, not implementation details, so they survive changes to the // client-side mechanics. // // Every test builds the pages it needs under its own fresh top-level folder // (sandbox), so tests don't depend on each other's leftovers and can run alone // or in any order. Names are matched exactly: other tests' folders share the // sidebar and the search index. 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; }); }); // randomWord returns n random lowercase letters. function randomWord(n) { return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(""); } // sandbox creates a fresh top-level folder holding pages, keyed by path // relative to it ("" is the folder's own page); a null body makes an empty // folder. Returns the folder name. function sandbox(pages) { const root = "t" + randomWord(7); for (const [rel, body] of Object.entries(pages)) { const dir = join(wiki, root, rel); mkdirSync(dir, { recursive: true }); if (body !== null) writeFileSync(join(dir, "index.md"), body); } return root; } const readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8"); const exactly = (name) => new RegExp(`^${name}$`); const sidebar = (page) => page.locator("aside.tree-sidebar"); const modal = (page) => page.locator(".modal"); const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) }); const link = (scope, name) => scope.getByRole("link", { name, exact: true }); // 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(); } // movePage runs the move flow from the current page: keep the preselected // parent, then enter name. async function movePage(page, name) { await pageAction(page, "Move page (M)"); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByPlaceholder("Page name").fill(name); await modal(page).getByRole("button", { name: "MOVE" }).click(); } test("renders a page and navigates via the sidebar", async ({ page }) => { const root = sandbox({ "": "# Parent\n", Child: "# Child\n" }); await page.goto("/"); await expect(page.locator("main h1")).toContainText("Home"); await link(sidebar(page), root).click(); await expect(page).toHaveURL(new RegExp(`/${root}/$`)); await expect(page.locator("main h1")).toContainText("Parent"); // The sidebar expands the active folder's children. await expect(link(sidebar(page), "Child")).toBeVisible(); }); test("task checkbox toggle persists", async ({ page }) => { const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" }); await page.goto(`/${root}/`); const box = page.locator("main input.task-checkbox").first(); await expect(box).not.toBeChecked(); await box.check(); await expect(box).toBeEnabled(); await expect.poll(() => readPage(root)).toContain("- [x] first task"); await page.reload(); await expect(page.locator("main input.task-checkbox").first()).toBeChecked(); }); test("unchecking a task persists", async ({ page }) => { const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" }); await page.goto(`/${root}/`); const box = page.locator("main input.task-checkbox").nth(1); await expect(box).toBeChecked(); await box.uncheck(); await expect(box).toBeEnabled(); await expect.poll(() => readPage(root)).toContain("- [ ] second task"); }); test("a failed task save reverts the checkbox", async ({ page }) => { const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" }); page.on("dialog", (d) => d.dismiss()); await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" })); await page.goto(`/${root}/`); const box = page.locator("main input.task-checkbox").first(); await box.click(); await expect(box).toBeEnabled(); await expect(box).toBeChecked(); expect(readPage(root)).toContain("- [x] done task"); }); test("view settings switch the listing to thumbnails", async ({ page }) => { const root = sandbox({ "": "# Listing\n", Child: "# Child\n" }); await page.goto(`/${root}/`); 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(new RegExp(`/${root}/$`)); // 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 }) => { const root = sandbox({ "": "# Editable\n" }); await page.goto(`/${root}/`); await pageAction(page, "Edit page (E)"); await expect(page).toHaveURL(/\?edit/); await page.locator(".cm-content").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(new RegExp(`/${root}/$`)); await expect(page.locator("main")).toContainText("Added by e2e."); expect(readPage(root)).toContain("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 }) => { const root = sandbox({ Beta: "# Beta\n" }); await page.goto(`/${root}/Beta/`); await movePage(page, "Gamma"); await expect(page).toHaveURL(new RegExp(`/${root}/Gamma/$`)); await expect(page.locator("main h1")).toContainText("Beta"); }); test("move onto an empty folder offers a merge", async ({ page }) => { const root = sandbox({ Alpha: "# Alpha\n", Empty: null }); await page.goto(`/${root}/Alpha/`); await movePage(page, "Empty"); await expect(modal(page)).toContainText("Merge folders?"); await modal(page).getByRole("button", { name: "MERGE" }).click(); await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`)); await expect(page.locator("main h1")).toContainText("Alpha"); }); test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => { const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" }); await page.goto(`/${root}/Mover/`); // 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 movePage(page, "Taken"); await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]); await expect(modal(page)).toBeVisible(); await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`)); }); test("delete removes a page and returns to the parent", async ({ page }) => { const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" }); await page.goto(`/${root}/Notes/`); await pageAction(page, "Delete page"); await expect(modal(page)).toContainText(`Delete /${root}/Notes/`); await modal(page).getByRole("button", { name: "DELETE" }).click(); await expect(page).toHaveURL(new RegExp(`/${root}/$`)); await expect(link(sidebar(page), "Notes")).toHaveCount(0); }); test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => { const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" }); await page.goto(`/${root}/`); await page.goto(`/${root}/Gone/`); await pageAction(page, "Delete page"); await modal(page).getByRole("button", { name: "DELETE" }).click(); await expect(page).toHaveURL(new RegExp(`/${root}/$`)); await page.goBack(); await expect(page).toHaveURL(new RegExp(`/${root}/$`)); }); test("a server dialog works from the first moment it is shown", async ({ page }) => { // htmx wires up swapped-in forms only when it settles. Submit in the very // next task after the dialog is inserted: if the form isn't wired yet, the // browser submits it natively (a GET) and the page is not deleted. const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" }); await page.goto(`/${root}/Gone/`); await page.evaluate(() => { document.addEventListener("htmx:afterSwap", (e) => { if (e.detail.target.id !== "modal-host") return; const submit = e.detail.target.querySelector('button[type="submit"]'); setTimeout(() => submit.click(), 0); }, { once: true }); }); await pageAction(page, "Delete page"); await expect(page).toHaveURL(new RegExp(`/${root}/$`)); }); test("server dialogs close with Escape and CANCEL", async ({ page }) => { const root = sandbox({ "": "# Keep me\n" }); await page.goto(`/${root}/`); 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(new RegExp(`/${root}/$`)); expect(readPage(root)).toContain("Keep me"); }); test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => { const root = sandbox({ "": "# Here\n" }); const treeRequests = []; page.on("request", (r) => { if (r.url().includes("?tree")) treeRequests.push(r.url()); }); const res = await page.request.get(`/${root}/`); expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`); await page.goto(`/${root}/`); await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root); expect(treeRequests).toEqual([]); }); test("sidebar folders expand lazily and collapse", async ({ page }) => { const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" }); await page.goto("/"); const chevron = treeRow(sidebar(page), root).locator(".tree-chevron"); const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content); await expect(link(sidebar(page), "One")).toHaveCount(0); expect(await glyph()).toBe('"▸"'); await chevron.click(); await expect(link(sidebar(page), "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: exactly("One") })).toHaveCount(1); await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/); await chevron.click(); await expect(link(sidebar(page), "One")).toBeHidden(); }); test("tree picker selects inside a lazily expanded folder", async ({ page }) => { const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" }); await page.goto(`/${root}/Mover/`); await pageAction(page, "Move page (M)"); // The picker opens with the current parent expanded; Tree is still closed. const picker = modal(page).locator(".tree-picker"); await treeRow(picker, "Tree").locator(".tree-chevron").click(); await treeRow(picker, "One").locator(".tree-chevron").click(); await picker.getByText("Deep", { exact: true }).click(); await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/Tree/One/Deep`); await modal(page).getByRole("button", { name: "NEXT" }).click(); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`)); }); test("tree picker can pick the root", async ({ page }) => { const root = sandbox({ Two: "# Two\n" }); await page.goto(`/${root}/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(`/${root}`); 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(`${root}-top`); await modal(page).getByRole("button", { name: "MOVE" }).click(); await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`)); }); test("editor embed picker selects a file", async ({ page }) => { const root = sandbox({ "": "# Embeds\n" }); writeFileSync(join(wiki, root, "photo.jpg"), "x"); await page.goto(`/${root}/?edit`); await page.locator(".cm-content").click(); await page.keyboard.press("Alt+Shift+G"); const picker = modal(page).locator(".tree-picker"); // Folder rows aren't selectable in file mode; the file is. await picker.getByText("photo.jpg").click(); await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/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 }) => { const root = sandbox({ "": "# Mobile\n" }); await page.setViewportSize({ width: 400, height: 800 }); await page.goto(`/${root}/`); await expect(sidebar(page)).toBeHidden(); await page.getByRole("button", { name: "ACTIONS" }).click(); await page.getByTitle("Folders").click(); const overlay = page.locator(".overlay"); await expect(link(overlay, root)).toBeVisible(); await link(overlay, root).click(); await expect(overlay).toHaveCount(0); }); // seedSearch creates pages whose names share a random word (so no other // test's pages match it) and reindexes. Returns the sandbox, the word and its // capitalized form used in the names. async function seedSearch(page) { const word = randomWord(8); const Word = word[0].toUpperCase() + word.slice(1); const pages = { "": "# Search\n" }; for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) { pages[`${Word} ${n}`] = `# ${Word} ${n}\n`; } const root = sandbox(pages); expect((await page.request.post("/_reindex")).status()).toBe(204); return { root, word, Word }; } const suggest = (page) => page.locator("header .suggest-dropdown"); test("header typeahead: highlight, keyboard pick", async ({ page }) => { const { root, word, Word } = await seedSearch(page); await page.goto("/"); const input = page.locator("header .search-input"); await input.fill(`${word} finch`); const first = suggest(page).locator(".suggest-row").first(); await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`); await expect(first.locator("strong")).toHaveText([Word, "Finch"]); await input.press("ArrowDown"); await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`); await input.press("Enter"); await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`)); }); test("header typeahead: click, footer, escape, short query", async ({ page }) => { const { root, word, Word } = await seedSearch(page); await page.goto("/"); const input = page.locator("header .search-input"); await input.fill(word[0]); await page.waitForTimeout(300); await expect(suggest(page)).toBeHidden(); await input.fill(word); const { total } = await (await page.request.get(`/_search?q=${word}`)).json(); expect(total).toBeGreaterThan(5); await expect(suggest(page).locator(".suggest-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(`${word} crossing`); await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click(); await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`)); await page.goto("/"); await page.locator("header .search-input").fill(word); await suggest(page).locator(".suggest-footer").click(); await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`)); }); test("editor link dialog fills the query from a suggestion", async ({ page }) => { const { root, word, Word } = await seedSearch(page); await page.goto(`/${root}/?edit`); await page.locator(".cm-content").click(); await page.keyboard.press("Alt+Shift+P"); const query = modal(page).getByPlaceholder("Search query"); await query.fill(`${word} crossing`); await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click(); await expect(query).toHaveValue(`${Word} 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(`[[${Word} Zebra Crossing]]`); }); test("editor [[ autocomplete lists matching pages", async ({ page }) => { const { root, word, Word } = await seedSearch(page); await page.goto(`/${root}/?edit`); await page.locator(".cm-content").click(); await page.keyboard.press("Control+End"); await page.keyboard.type(`\n[[${word} finc`); await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`); });