Files
datascape/e2e/tests/pages.spec.js
T
luxickandClaude Opus 5.5 4365fa2567 Render the folder tree on the server
The sidebar tree is part of the page (chain to the current page
expanded, active row marked), so it no longer paints late and the
sessionStorage snapshot is gone; only the rail's scroll position is
kept. Folders are native <details> that lazy-load their children as
HTML via htmx. The tree picker hosts the same server-rendered rows with
radio inputs, so ?tree returns HTML and the JSON listing is removed.

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

273 lines
12 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);
});