Files
datascape/e2e/tests/pages.spec.js
T
luxickandClaude Opus 5.5 a55a1c8ed4 Render search suggestions on the server
The typeahead requests /_search via htmx (debounced, newer requests
replace in-flight ones) and gets rendered rows with the match
highlighting done in Go. Rows are links, so picking in the header is
plain navigation; the editor's link dialog reads data-name instead. The
editor's [[ autocomplete keeps the JSON response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:00:08 +02:00

341 lines
15 KiB
JavaScript

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