Each test builds its pages in its own sandbox folder (search tests use a random word), so tests no longer rely on earlier tests' leftovers and run alone, in any order, or in parallel. The seed is just the home page. Parallel load hid the htmx settle race from the timing-based tests, so a dedicated test now submits a dialog in the task right after it is inserted, which fails deterministically without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
18 KiB
JavaScript
393 lines
18 KiB
JavaScript
// 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`);
|
|
});
|