diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 10a03de..dc79e09 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -4,9 +4,10 @@ const port = process.env.E2E_PORT || "8765"; export default defineConfig({ testDir: "tests", - // One seeded wiki is shared, and tests mutate it: run serially. - workers: 1, - fullyParallel: false, + // Tests share one server and wiki but each works in its own sandbox folder, + // so they can run in parallel. + workers: 4, + fullyParallel: true, reporter: "list", use: { baseURL: `http://127.0.0.1:${port}`, diff --git a/e2e/server.mjs b/e2e/server.mjs index fe4c4d0..44de029 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -16,18 +16,9 @@ mkdirSync(wiki, { recursive: true }); execFileSync("go", ["build", "-o", bin, "."], { cwd: join(here, ".."), stdio: "inherit" }); -const pages = { - "index.md": "# Home\n\nWelcome.\n", - "Projects/index.md": "# Projects\n\n## Todo\n\n- [ ] first task\n- [x] second task\n", - "Projects/Alpha/index.md": "# Alpha\n\nAlpha page.\n", - "Projects/Beta/index.md": "# Beta\n\nBeta page.\n", - "Notes/index.md": "# Notes\n\nSome notes.\n", -}; -for (const [p, body] of Object.entries(pages)) { - mkdirSync(dirname(join(wiki, p)), { recursive: true }); - writeFileSync(join(wiki, p), body); -} -writeFileSync(join(wiki, "Projects", "readme.txt"), "a file\n"); +// Tests create their own pages (see sandbox in tests/pages.spec.js); the seed +// is only the home page. +writeFileSync(join(wiki, "index.md"), "# Home\n\nWelcome.\n"); const srv = spawn(bin, ["-dir", wiki, "-cache", join(tmp, "cache"), "-addr", "127.0.0.1:" + port, "-calendars", ""], { stdio: "inherit", diff --git a/e2e/tests/pages.spec.js b/e2e/tests/pages.spec.js index 38bcb0f..6d4e011 100644 --- a/e2e/tests/pages.spec.js +++ b/e2e/tests/pages.spec.js @@ -1,6 +1,11 @@ -// 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). +// End-to-end coverage of the page UI. Tests drive the app through visible +// labels, not implementation details, so they survive changes to the +// client-side mechanics. +// +// Every test builds the pages it needs under its own fresh top-level folder +// (sandbox), so tests don't depend on each other's leftovers and can run alone +// or in any order. Names are matched exactly: other tests' folders share the +// sidebar and the search index. import { test, expect } from "@playwright/test"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -15,8 +20,30 @@ test.beforeEach(({ page }) => { }); }); +// randomWord returns n random lowercase letters. +function randomWord(n) { + return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(""); +} + +// sandbox creates a fresh top-level folder holding pages, keyed by path +// relative to it ("" is the folder's own page); a null body makes an empty +// folder. Returns the folder name. +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 readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8"); +const exactly = (name) => new RegExp(`^${name}$`); const sidebar = (page) => page.locator("aside.tree-sidebar"); const modal = (page) => page.locator(".modal"); +const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) }); +const link = (scope, name) => scope.getByRole("link", { name, exact: true }); // 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. @@ -26,49 +53,63 @@ async function pageAction(page, title) { await page.locator("header").getByTitle(title).click(); } +// movePage runs the move flow from the current page: keep the preselected +// parent, then enter name. +async function movePage(page, name) { + await pageAction(page, "Move page (M)"); + await modal(page).getByRole("button", { name: "NEXT" }).click(); + await modal(page).getByPlaceholder("Page name").fill(name); + await modal(page).getByRole("button", { name: "MOVE" }).click(); +} + test("renders a page and navigates via the sidebar", async ({ page }) => { + const root = sandbox({ "": "# Parent\n", Child: "# Child\n" }); 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"); + await link(sidebar(page), root).click(); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); + await expect(page.locator("main h1")).toContainText("Parent"); // The sidebar expands the active folder's children. - await expect(sidebar(page).getByRole("link", { name: "Alpha" })).toBeVisible(); + await expect(link(sidebar(page), "Child")).toBeVisible(); }); test("task checkbox toggle persists", async ({ page }) => { - await page.goto("/Projects/"); + const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" }); + await page.goto(`/${root}/`); 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 expect.poll(() => readPage(root)).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 root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" }); + await page.goto(`/${root}/`); 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"); + await expect.poll(() => readPage(root)).toContain("- [ ] second task"); }); test("a failed task save reverts the checkbox", async ({ page }) => { + const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" }); 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 page.goto(`/${root}/`); + const box = page.locator("main input.task-checkbox").first(); await box.click(); await expect(box).toBeEnabled(); - await expect(box).toBeChecked({ checked: before }); + await expect(box).toBeChecked(); + expect(readPage(root)).toContain("- [x] done task"); }); test("view settings switch the listing to thumbnails", async ({ page }) => { - await page.goto("/Projects/"); + const root = sandbox({ "": "# Listing\n", Child: "# Child\n" }); + await page.goto(`/${root}/`); await expect(page.locator("main table.data-table")).toBeVisible(); await page.getByTitle("View & sorting").click(); await modal(page).getByText("Thumbnail").click(); @@ -76,7 +117,7 @@ test("view settings switch the listing to thumbnails", async ({ page }) => { 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\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); // Reopening shows the saved state. await page.getByTitle("View & sorting").click(); await expect(modal(page).getByLabel("Thumbnail")).toBeChecked(); @@ -85,68 +126,96 @@ test("view settings switch the listing to thumbnails", async ({ page }) => { }); test("editor saves a page", async ({ page }) => { - await page.goto("/Projects/Alpha/"); + const root = sandbox({ "": "# Editable\n" }); + await page.goto(`/${root}/`); await pageAction(page, "Edit page (E)"); await expect(page).toHaveURL(/\?edit/); - const editor = page.locator(".cm-content"); - await editor.click(); + await page.locator(".cm-content").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).toHaveURL(new RegExp(`/${root}/$`)); await expect(page.locator("main")).toContainText("Added by e2e."); + expect(readPage(root)).toContain("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\/$/); + const root = sandbox({ Beta: "# Beta\n" }); + await page.goto(`/${root}/Beta/`); + await movePage(page, "Gamma"); + await expect(page).toHaveURL(new RegExp(`/${root}/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(); + const root = sandbox({ Alpha: "# Alpha\n", Empty: null }); + await page.goto(`/${root}/Alpha/`); + await movePage(page, "Empty"); await expect(modal(page)).toContainText("Merge folders?"); await modal(page).getByRole("button", { name: "MERGE" }).click(); - await expect(page).toHaveURL(/\/Projects\/Empty\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`)); await expect(page.locator("main h1")).toContainText("Alpha"); }); +test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => { + const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" }); + await page.goto(`/${root}/Mover/`); + // 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 movePage(page, "Taken"); + await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]); + await expect(modal(page)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`)); +}); + test("delete removes a page and returns to the parent", async ({ page }) => { - await page.goto("/Notes/"); + const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" }); + await page.goto(`/${root}/Notes/`); await pageAction(page, "Delete page"); - await expect(modal(page)).toContainText("Delete /Notes/"); + await expect(modal(page)).toContainText(`Delete /${root}/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); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); + await expect(link(sidebar(page), "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/"); + const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" }); + await page.goto(`/${root}/`); + await page.goto(`/${root}/Gone/`); await pageAction(page, "Delete page"); await modal(page).getByRole("button", { name: "DELETE" }).click(); - await expect(page).toHaveURL(/\/Scratch\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); await page.goBack(); - await expect(page).toHaveURL(/\/Scratch\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); +}); + +test("a server dialog works from the first moment it is shown", async ({ page }) => { + // htmx wires up swapped-in forms only when it settles. Submit in the very + // next task after the dialog is inserted: if the form isn't wired yet, the + // browser submits it natively (a GET) and the page is not deleted. + const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" }); + await page.goto(`/${root}/Gone/`); + await page.evaluate(() => { + document.addEventListener("htmx:afterSwap", (e) => { + if (e.detail.target.id !== "modal-host") return; + const submit = e.detail.target.querySelector('button[type="submit"]'); + setTimeout(() => submit.click(), 0); + }, { once: true }); + }); + await pageAction(page, "Delete page"); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); }); test("server dialogs close with Escape and CANCEL", async ({ page }) => { - await page.goto("/Projects/"); + const root = sandbox({ "": "# Keep me\n" }); + await page.goto(`/${root}/`); await pageAction(page, "Delete page"); await expect(modal(page)).toBeVisible(); // CANCEL is focused, so Enter must not delete. @@ -156,185 +225,168 @@ test("server dialogs close with Escape and CANCEL", async ({ page }) => { await pageAction(page, "Delete page"); await modal(page).getByRole("button", { name: "CANCEL" }).click(); await expect(modal(page)).toHaveCount(0); - await expect(page).toHaveURL(/\/Projects\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/$`)); + expect(readPage(root)).toContain("Keep me"); }); -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 root = sandbox({ "": "# Here\n" }); 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"); + const res = await page.request.get(`/${root}/`); + expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`); + await page.goto(`/${root}/`); + await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root); expect(treeRequests).toEqual([]); }); test("sidebar folders expand lazily and collapse", async ({ page }) => { - mkpage("Tree/One/Deep", "Deep"); - mkpage("Tree/Two", "Two"); + const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" }); await page.goto("/"); - const treeRow = sidebar(page).locator(".tree-row", { hasText: "Tree" }); - const chevron = treeRow.locator(".tree-chevron"); + const chevron = treeRow(sidebar(page), root).locator(".tree-chevron"); const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content); - await expect(sidebar(page).getByRole("link", { name: "One" })).toHaveCount(0); + await expect(link(sidebar(page), "One")).toHaveCount(0); expect(await glyph()).toBe('"▸"'); await chevron.click(); - await expect(sidebar(page).getByRole("link", { name: "One" })).toBeVisible(); + await expect(link(sidebar(page), "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(); + await expect(sidebar(page).locator("summary.tree-row", { hasText: exactly("One") })).toHaveCount(1); + await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/); + await chevron.click(); + await expect(link(sidebar(page), "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/"); + const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" }); + await page.goto(`/${root}/Mover/`); await pageAction(page, "Move page (M)"); + // The picker opens with the current parent expanded; Tree is still closed. 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 treeRow(picker, "Tree").locator(".tree-chevron").click(); + await treeRow(picker, "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 expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/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\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`)); }); test("tree picker can pick the root", async ({ page }) => { - mkpage("Tree/Two", "Two"); - await page.goto("/Tree/Two/"); + const root = sandbox({ Two: "# Two\n" }); + await page.goto(`/${root}/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 expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}`); 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).getByPlaceholder("Page name").fill(`${root}-top`); await modal(page).getByRole("button", { name: "MOVE" }).click(); - await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/TwoTop\/$/); + await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`)); }); test("editor embed picker selects a file", async ({ page }) => { - mkpage("Embeds", "Embeds"); - writeFileSync(join(wiki, "Embeds", "photo.jpg"), "x"); - await page.goto("/Embeds/?edit"); + const root = sandbox({ "": "# Embeds\n" }); + writeFileSync(join(wiki, root, "photo.jpg"), "x"); + await page.goto(`/${root}/?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. + // Folder rows aren't selectable in file mode; the file is. await picker.getByText("photo.jpg").click(); - await expect(modal(page).locator(".tree-selected-path")).toHaveText("/Embeds/photo.jpg"); + await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/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 }) => { + const root = sandbox({ "": "# Mobile\n" }); await page.setViewportSize({ width: 400, height: 800 }); - await page.goto("/Projects/"); + await page.goto(`/${root}/`); 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(link(overlay, root)).toBeVisible(); + await link(overlay, root).click(); await expect(overlay).toHaveCount(0); }); +// seedSearch creates pages whose names share a random word (so no other +// test's pages match it) and reindexes. Returns the sandbox, the word and its +// capitalized form used in the names. async function seedSearch(page) { - for (const n of ["Zebra Crossing", "Zebra Finch", "Zeta1", "Zeta2", "Zeta3", "Zeta4", "Zeta5", "Zeta6"]) { - mkpage(`Suggest/${n}`, n); + const word = randomWord(8); + const Word = word[0].toUpperCase() + word.slice(1); + const pages = { "": "# Search\n" }; + for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) { + pages[`${Word} ${n}`] = `# ${Word} ${n}\n`; } + const root = sandbox(pages); expect((await page.request.post("/_reindex")).status()).toBe(204); + return { root, word, Word }; } const suggest = (page) => page.locator("header .suggest-dropdown"); test("header typeahead: highlight, keyboard pick", async ({ page }) => { - await seedSearch(page); + const { root, word, Word } = 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.fill(`${word} finch`); + const first = suggest(page).locator(".suggest-row").first(); + await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`); + await expect(first.locator("strong")).toHaveText([Word, "Finch"]); await input.press("ArrowDown"); - await expect(suggest(page).locator(".suggest-row.is-active")).toContainText("Zebra Finch"); + await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`); await input.press("Enter"); - await expect(page).toHaveURL(/\/Suggest\/Zebra%20Finch\/$/); + await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`)); }); test("header typeahead: click, footer, escape, short query", async ({ page }) => { - await seedSearch(page); + const { root, word, Word } = await seedSearch(page); await page.goto("/"); const input = page.locator("header .search-input"); - await input.fill("z"); + await input.fill(word[0]); 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(); + await input.fill(word); + const { total } = await (await page.request.get(`/_search?q=${word}`)).json(); expect(total).toBeGreaterThan(5); - await expect(footer).toHaveText(`Show all ${total} matches`); + await expect(suggest(page).locator(".suggest-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 input.fill(`${word} crossing`); + await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click(); + await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`)); await page.goto("/"); - await page.locator("header .search-input").fill("zeta"); + await page.locator("header .search-input").fill(word); await suggest(page).locator(".suggest-footer").click(); - await expect(page).toHaveURL(/\/\?q=zeta$/); + await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`)); }); test("editor link dialog fills the query from a suggestion", async ({ page }) => { - await seedSearch(page); - await page.goto("/Suggest/Zeta1/?edit"); + const { root, word, Word } = await seedSearch(page); + await page.goto(`/${root}/?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 query.fill(`${word} crossing`); + await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click(); + await expect(query).toHaveValue(`${Word} 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]]"); + await expect(page.locator(".cm-content")).toContainText(`[[${Word} Zebra Crossing]]`); }); -test("editor [[ autocomplete still uses the JSON endpoint", async ({ page }) => { - await seedSearch(page); - await page.goto("/Suggest/Zeta2/?edit"); +test("editor [[ autocomplete lists matching pages", async ({ page }) => { + const { root, word, Word } = await seedSearch(page); + await page.goto(`/${root}/?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"); + await page.keyboard.type(`\n[[${word} finc`); + await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`); });