Task checkboxes post themselves (hx-post="?toggle=N") and flip back when the save fails, replacing tasks.js. View settings are a server-rendered form dialog with real radios and a select, replacing view-settings.js; with that, postReplace has no callers left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
8.0 KiB
JavaScript
182 lines
8.0 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\/$/);
|
|
});
|