// End-to-end coverage of the canvas view: creating a canvas from a page, // notes, connections, moving, deleting and the autosave behind all of them. // Like pages.spec.js, every test works in its own fresh top-level folder. 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) => { throw err; }); }); function randomWord(n) { return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(""); } // sandbox creates a fresh page, optionally holding Board.canvas with doc. function sandbox(doc) { const root = "c" + randomWord(7); mkdirSync(join(wiki, root), { recursive: true }); writeFileSync(join(wiki, root, "index.md"), "# Canvas host\n"); if (doc) writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(doc)); return root; } const readCanvas = (root, name = "Board.canvas") => JSON.parse(readFileSync(join(wiki, root, name), "utf8")); const node = (page, id) => page.locator(`.canvas-node[data-id="${id}"]`); function note(id, x, y, text) { return { id, type: "text", x, y, width: 200, height: 80, text }; } 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("creates a canvas from a page and writes a note", async ({ page }) => { const root = sandbox(); await page.goto(`/${root}/`); await pageAction(page, "New canvas on this page"); await page.locator(".modal").getByPlaceholder("Canvas name").fill("Ideas"); await page.locator(".modal").getByRole("button", { name: "CREATE" }).click(); await expect(page).toHaveURL(new RegExp(`/${root}/Ideas\\.canvas$`)); // The canvas takes the whole shell: no folder tree, no footer. await expect(page.locator("aside.tree-sidebar")).toHaveCount(0); await expect(page.locator("footer")).toHaveCount(0); await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } }); await expect(page.locator(".canvas-node-editor .cm-content")).toBeFocused(); await page.keyboard.type("Hello **world**"); await page.keyboard.press("Escape"); await expect(page.locator(".canvas-node-body strong")).toHaveText("world"); await expect.poll(() => readCanvas(root, "Ideas.canvas").nodes.map((n) => n.text)).toEqual(["Hello **world**"]); await expect(page.locator(".canvas-status")).toHaveText("Saved"); await page.reload(); await expect(page.locator(".canvas-node-body strong")).toHaveText("world"); // The page lists its canvas. await page.goto(`/${root}/`); await expect(page.locator("main").getByRole("link", { name: "Ideas.canvas" })).toBeVisible(); }); test("an empty new note disappears", async ({ page }) => { const root = sandbox({ nodes: [], edges: [] }); await page.goto(`/${root}/Board.canvas`); await page.locator(".canvas-viewport").dblclick({ position: { x: 300, y: 200 } }); await expect(page.locator(".canvas-node")).toHaveCount(1); await page.keyboard.press("Escape"); await expect(page.locator(".canvas-node")).toHaveCount(0); }); test("edits a note with the page editor's toolbar", async ({ page }) => { const root = sandbox({ nodes: [note("a", 0, 0, "plain")], edges: [] }); await page.goto(`/${root}/Board.canvas`); await node(page, "a").dblclick(); await expect(page.locator(".canvas-node-toolbar")).toBeVisible(); await page.keyboard.press("Control+End"); await page.keyboard.type(" "); await page.locator(".canvas-node-toolbar").getByTitle("Bold (B)").click(); await page.keyboard.type("strong"); // Clicking the background closes the editor. await page.locator(".canvas-viewport").click({ position: { x: 5, y: 300 } }); await expect(page.locator(".canvas-node-toolbar")).toBeHidden(); await expect(node(page, "a").locator("strong")).toHaveText("strong"); await expect.poll(() => readCanvas(root).nodes[0].text).toBe("plain **strong**"); }); test("connects two nodes by dragging a handle", async ({ page }) => { const root = sandbox({ nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")], edges: [] }); await page.goto(`/${root}/Board.canvas`); await node(page, "a").click(); const handle = node(page, "a").locator('.canvas-handle[data-side="right"]'); const from = await handle.boundingBox(); const to = await node(page, "b").boundingBox(); await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); await page.mouse.down(); await page.mouse.move(to.x + 20, to.y + to.height / 2, { steps: 5 }); await page.mouse.up(); await expect(page.locator(".canvas-edge")).toHaveCount(1); await expect .poll(() => readCanvas(root).edges.map((e) => [e.fromNode, e.fromSide, e.toNode, e.toSide])) .toEqual([["a", "right", "b", "left"]]); }); test("moves, undoes and deletes nodes", async ({ page }) => { const root = sandbox({ nodes: [note("a", 0, 0, "A"), note("b", 400, 0, "B")], edges: [{ id: "e", fromNode: "a", toNode: "b" }], }); await page.goto(`/${root}/Board.canvas`); const box = await node(page, "a").boundingBox(); await page.mouse.move(box.x + 50, box.y + 40); await page.mouse.down(); await page.mouse.move(box.x + 50, box.y + 240, { steps: 5 }); await page.mouse.up(); await expect.poll(() => readCanvas(root).nodes[0].y).toBeGreaterThan(100); await page.keyboard.press("Control+z"); await expect.poll(() => readCanvas(root).nodes[0].y).toBe(0); // Deleting a node removes its connections too. await node(page, "a").click(); await page.keyboard.press("Delete"); await expect(node(page, "a")).toHaveCount(0); await expect.poll(() => readCanvas(root)).toMatchObject({ nodes: [{ id: "b" }], edges: [] }); }); test("a task checkbox in a note persists", async ({ page }) => { const root = sandbox({ nodes: [note("a", 0, 0, "- [ ] one\n- [ ] two")], edges: [] }); await page.goto(`/${root}/Board.canvas`); await node(page, "a").locator("input.task-checkbox").nth(1).check(); await expect.poll(() => readCanvas(root).nodes[0].text).toBe("- [ ] one\n- [x] two"); }); test("a change made elsewhere stops the autosave", async ({ page }) => { const root = sandbox({ nodes: [note("a", 0, 0, "A")], edges: [] }); await page.goto(`/${root}/Board.canvas`); const other = { nodes: [note("z", 0, 0, "from elsewhere")], edges: [] }; writeFileSync(join(wiki, root, "Board.canvas"), JSON.stringify(other)); await node(page, "a").click(); await page.keyboard.press("ArrowDown"); await expect(page.locator(".canvas-status")).toContainText("Changed elsewhere"); expect(readCanvas(root)).toEqual(other); });