Canvas support V1
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user