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>
156 lines
6.8 KiB
JavaScript
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);
|
|
});
|