// 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\/$/); });