Files
luxickandClaude Opus 5.5 aa90a04f91 Boost page navigation with htmx
Links and GET forms to wiki pages swap the next page into .shell instead
of loading a new document: the server answers boosted requests and
history restores with a partial (title, page actions out-of-band, shell
content). The header stays, browsers without cross-document view
transitions (Firefox) no longer flash between pages, and the swap runs
as a same-document view transition. Files, the editor, internal
endpoints and POST forms still navigate normally.

Back/Forward refetch pages (no snapshots that could predate a move or
delete); the tree rail keeps its scroll position and the content column
returns to where it was. Mutation redirects swap their result in the
same way. Page scripts that assumed a fresh document now also run after
a swap (toc, contents, companion, scroll-sync, search actions) or
register once (fitness).

The e2e suite now also runs in Firefox.

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

156 lines
6.8 KiB
JavaScript

// Boosted navigation: outside the editor, page links swap the next page into
// the open document instead of loading a new one. A marker on window survives
// a boosted navigation and disappears with a full page load.
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");
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
// Chrome aborts the cross-document view transition (style.css) when the
// next document can't take part, e.g. a plain-text file, and reports it
// as an unhandled rejection that no page script can catch.
if (err.message.includes("ViewTransition opt-in disabled")) return;
throw err;
});
});
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
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 sidebar = (page) => page.locator("aside.tree-sidebar");
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
const mark = (page) => page.evaluate(() => (window.__marker = 1));
const marked = (page) => page.evaluate(() => window.__marker === 1);
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
const root = sandbox({
"": "# Parent\n",
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
});
await page.goto(`/${root}/`);
await mark(page);
await link(sidebar(page), "Child").click();
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
await expect(page.locator("main h1")).toContainText("Child");
await expect(page).toHaveTitle("Child");
expect(await marked(page)).toBe(true);
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
// Page scripts ran for the new content: TOC and section edit links.
await expect(page.locator("nav.toc")).toContainText("Second");
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
// htmx attributes in the new content work: the task toggles.
await page.locator("main input.task-checkbox").check();
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
});
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
const root = sandbox({ "": long("Parent"), Child: long("Child") });
await page.goto(`/${root}/`);
await mark(page);
const center = page.locator(".center");
await center.evaluate((el) => (el.scrollTop = 600));
await link(sidebar(page), "Child").click();
await expect(page.locator("main h1")).toContainText("Child");
// A new page starts at the top.
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
await expect(page).toHaveTitle("Parent");
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
await page.goForward();
await expect(page.locator("main h1")).toContainText("Child");
expect(await marked(page)).toBe(true);
});
test("Back to the first, fully loaded page works", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
await page.goto(`/${root}/Child/`);
await mark(page);
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
await expect(page.locator("main h1")).toContainText("Child");
expect(await marked(page)).toBe(true);
});
test("files and the editor load as full pages", async ({ page }) => {
const root = sandbox({ "": "# Parent\n" });
writeFileSync(join(wiki, root, "note.txt"), "plain file");
await page.goto(`/${root}/`);
await mark(page);
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
await expect(page.locator("body")).toContainText("plain file");
expect(await marked(page)).toBe(false);
await page.goto(`/${root}/`);
await mark(page);
await page.locator("header").getByTitle("Edit page (E)").click();
await expect(page).toHaveURL(/\?edit/);
await expect(page.locator(".cm-content")).toBeVisible();
expect(await marked(page)).toBe(false);
});
test("header actions follow the page", async ({ page }) => {
const root = sandbox({ "": "# Parent\n" });
await page.goto("/");
await mark(page);
const move = page.locator("header").getByTitle("Move page (M)");
await expect(move).toHaveCount(0);
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await expect(move).toBeVisible();
await page.goBack();
await expect(page.locator("main h1")).toContainText("Home");
await expect(move).toHaveCount(0);
expect(await marked(page)).toBe(true);
});
test("search results and the search box follow boosted navigation", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
expect((await page.request.post("/_reindex")).status()).toBe(204);
await page.goto(`/${root}/`);
await mark(page);
const input = page.locator("header .search-input");
await input.fill(root);
await input.press("Enter");
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
await expect(page).toHaveTitle(`Search: ${root}`);
await expect(page.locator("main .search-card").first()).toBeVisible();
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await expect(input).toHaveValue("");
expect(await marked(page)).toBe(true);
});
test("a mutation swaps in its result without a reload", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/Gone/`);
await mark(page);
await page.locator("header").getByTitle("Delete page").click();
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
await expect(page.locator(".modal")).toHaveCount(0);
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
expect(await marked(page)).toBe(true);
});