Make e2e tests independent and run them in parallel
Each test builds its pages in its own sandbox folder (search tests use a random word), so tests no longer rely on earlier tests' leftovers and run alone, in any order, or in parallel. The seed is just the home page. Parallel load hid the htmx settle race from the timing-based tests, so a dedicated test now submits a dialog in the task right after it is inserted, which fails deterministically without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}`,
|
||||
|
||||
+3
-12
@@ -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",
|
||||
|
||||
+189
-137
@@ -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`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user