import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; // The canvas scripts run in the browser; these tests evaluate them in a minimal // fake DOM so the focus-mode message contract is checked without a browser. const ASSET_DIR = fileURLToPath(new URL("../assets/design-canvas/", import.meta.url)); const read = (name: string) => readFileSync(join(ASSET_DIR, name), "utf8"); type Listener = (event: any) => void; // postMessage clones its message; the copy also leaves the vm realm's prototypes behind. const structuredCopy = (message: unknown) => JSON.parse(JSON.stringify(message)); type Rect = { left: number; top: number; width: number; height: number }; class FakeElement { localName: string; id = ""; children: FakeElement[] = []; parentElement: FakeElement | null = null; attributes = new Map(); classes = new Set(); listeners = new Map(); style: Record = { setProperty(name: string, value: string) { this[name] = value; } }; dataset: Record = {}; textContent = ""; title = ""; hidden = false; disabled = false; inert = false; value = ""; // Set by the canvas on iframes, the Open in new tab link and the Scale to fit checkbox. src = ""; href = ""; checked = false; rect: Rect = { left: 0, top: 0, width: 0, height: 0 }; contentWindow: { posted: any[]; postMessage(message: any): void } | null = null; doc: FakeDocument; constructor(tag: string, doc: FakeDocument) { this.localName = tag; this.doc = doc; if (tag === "iframe") { const posted: any[] = []; this.contentWindow = { posted, postMessage: (message) => posted.push(structuredCopy(message)) }; } } get className() { return [...this.classes].join(" "); } set className(value: string) { this.classes = new Set(value.split(/\s+/).filter(Boolean)); } get classList() { const classes = this.classes; return { add: (name: string) => classes.add(name), remove: (name: string) => classes.delete(name), contains: (name: string) => classes.has(name), toggle: (name: string, force?: boolean) => { const on = force === undefined ? !classes.has(name) : force; if (on) classes.add(name); else classes.delete(name); return on; }, }; } // Layout offsets inside the offset parent; unlike rects, the world's transform never changes them. offsetLeft = 0; offsetTop = 0; get offsetWidth() { return 0; } get offsetHeight() { return 0; } setAttribute(name: string, value: unknown) { this.attributes.set(name, String(value)); } getAttribute(name: string) { return this.attributes.get(name) ?? null; } hasAttribute(name: string) { return this.attributes.has(name); } removeAttribute(name: string) { this.attributes.delete(name); } append(...nodes: FakeElement[]) { for (const node of nodes) { node.parentElement = this; this.children.push(node); } } replaceChildren(...nodes: FakeElement[]) { for (const child of this.children) child.parentElement = null; this.children = []; this.append(...nodes); } contains(node: FakeElement | null) { for (let current = node; current; current = current.parentElement) if (current === this) return true; return false; } matches(selector: string) { return selector.startsWith(".") ? this.classes.has(selector.slice(1)) : this.localName === selector; } closest(selector: string) { for (let current: FakeElement | null = this; current; current = current.parentElement) if (current.matches(selector)) return current; return null; } querySelector() { return null; } addEventListener(type: string, listener: Listener) { this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); } /** Bubbles from this element through its ancestors, like a DOM event. */ dispatch(type: string, init: Record = {}) { const event: any = { type, target: this, defaultPrevented: false, ...init, preventDefault() { event.defaultPrevented = true; }, stopPropagation() {}, stopImmediatePropagation() {} }; for (let current: FakeElement | null = this; current; current = current.parentElement) { for (const listener of current.listeners.get(type) ?? []) listener(event); } return event; } focus() { this.doc.activeElement = this; } getBoundingClientRect() { return this.rect; } setPointerCapture() {} scrollIntoView() {} } class FakeDocument { activeElement: FakeElement | null = null; body: FakeElement; documentElement: FakeElement; title = ""; byId = new Map(); listeners = new Map(); constructor() { this.documentElement = new FakeElement("html", this); this.body = new FakeElement("body", this); this.documentElement.append(this.body); this.activeElement = this.body; } createElement(tag: string) { return new FakeElement(tag, this); } getElementById(id: string) { let element = this.byId.get(id); if (!element) { element = new FakeElement(id === "gd-back" ? "button" : "div", this); element.id = id; this.byId.set(id, element); } return element; } addEventListener(type: string, listener: Listener) { this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); } querySelectorAll() { return []; } } const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); async function loadCanvas(firstHeight = 300, extraReplies: Record = {}) { const document = new FakeDocument(); document.getElementById("gd-config").textContent = JSON.stringify({ base: "/t/", slug: "alpha" }); const viewport = document.getElementById("gd-viewport"); const world = document.getElementById("gd-world"); viewport.append(world); viewport.rect = { left: 0, top: 50, width: 1000, height: 700 }; const windowListeners = new Map(); const replies: Record = { "/t/api/system": { exists: false }, "/t/api/designs": { designs: [{ slug: "alpha", title: "Alpha", valid: true }] }, "/t/api/designs/alpha": { slug: "alpha", title: "Alpha", brief: "", artboards: [ { file: "01-a.html", title: "First", tradeoff: "", width: 400, height: firstHeight }, { file: "02-b.html", title: "Second", tradeoff: "", width: 400, height: 300 }, ], }, "/t/api/designs/alpha/comments": { comments: [] }, "/t/api/designs/alpha/handoffs": { version: 1, chosen: null, handoffs: {} }, "/t/api/designs/beta": { slug: "beta", title: "Beta", brief: "", artboards: [{ file: "01-only.html", title: "Only", tradeoff: "", width: 500, height: 400 }], }, "/t/api/designs/beta/comments": { comments: [] }, "/t/api/designs/beta/handoffs": { version: 1, chosen: null, handoffs: {} }, ...extraReplies, }; const fetched: string[] = []; const posts: Array<{ url: string; body: unknown }> = []; const urls: string[] = []; const sourceListeners = new Map(); const context: Record = { document, console, // Canvas timers (toasts, the preview load timeout) must not keep the test process alive. setTimeout: (callback: () => void, ms?: number) => { const timer = setTimeout(callback, ms); timer.unref(); return timer; }, clearTimeout, location: { port: "4100", origin: "http://127.0.0.1:4100" }, URL, innerWidth: 1200, innerHeight: 800, CSS: { escape: (value: string) => value }, history: { replaceState(_state: unknown, _title: string, url: string) { urls.push(url); } }, localStorage: { getItem: () => null, setItem() {} }, requestAnimationFrame: (callback: () => void) => { callback(); return 0; }, fetch: async (url: string, init?: { method?: string; body?: string }) => { if (init?.method === "POST") posts.push({ url, body: JSON.parse(init.body ?? "null") }); fetched.push(url); return { ok: url in replies, status: url in replies ? 200 : 404, json: async () => replies[url] ?? {} }; }, EventSource: class { addEventListener(type: string, listener: Listener) { sourceListeners.set(type, [...(sourceListeners.get(type) ?? []), listener]); } }, addEventListener(type: string, listener: Listener) { windowListeners.set(type, [...(windowListeners.get(type) ?? []), listener]); }, }; context.window = context; vm.runInNewContext(read("canvas.js"), context); for (let i = 0; i < 10; i++) await flush(); const boards = world.children.map((wrapper) => { const box = wrapper.children.find((child) => child.classes.has("gd-frame-box"))!; const frame = box.children.find((child) => child.localName === "iframe")!; const shield = box.children.find((child) => child.classes.has("gd-shield")); return { wrapper, frame, shield }; }); const message = (source: unknown, data: unknown) => { for (const listener of windowListeners.get("message") ?? []) listener({ source, data }); }; const windowEvent = (type: string) => { for (const listener of windowListeners.get(type) ?? []) listener({ type }); }; /** A server-sent event on the canvas's live-reload stream, then the async work it starts. */ const serverEvent = async (type: string, data: unknown) => { for (const listener of sourceListeners.get(type) ?? []) listener({ type, data: JSON.stringify(data) }); for (let i = 0; i < 10; i++) await flush(); }; const frames = () => world.children.map((wrapper) => wrapper.children.find((child) => child.classes.has("gd-frame-box"))!.children.find((child) => child.localName === "iframe")!); return { document, viewport, world, boards, message, windowEvent, serverEvent, frames, fetched, posts, urls, replies, zoomLevel: document.getElementById("gd-zoom-level"), navMode: document.getElementById("gd-nav-mode") }; } function click(target: FakeElement, x: number, y: number) { target.dispatch("pointerdown", { button: 0, pointerId: 1, clientX: x, clientY: y }); target.dispatch("pointerup", { button: 0, pointerId: 1, clientX: x, clientY: y }); } test("a click on an artboard's shield enters focus mode at 100% and only that artboard can end it", async () => { const canvas = await loadCanvas(); assert.equal(canvas.boards.length, 2); const [first, second] = canvas.boards; assert.ok(first.shield && second.shield, "every artboard has an interaction shield"); assert.equal(canvas.navMode.textContent, "Navigating"); // Start from a non-default view so restoring it is observable. canvas.viewport.dispatch("wheel", { deltaX: 0, deltaY: 40, deltaMode: 0, ctrlKey: true, metaKey: false, shiftKey: false, clientX: 300, clientY: 300 }); canvas.viewport.dispatch("wheel", { deltaX: 30, deltaY: 20, deltaMode: 0, ctrlKey: false, metaKey: false, shiftKey: false, clientX: 300, clientY: 300 }); const before = canvas.world.style.transform; const zoomBefore = canvas.zoomLevel.textContent; assert.notEqual(zoomBefore, "100%"); first.frame.rect = { left: 120, top: 140, width: 268, height: 201 }; click(first.shield!, 200, 200); assert.equal(canvas.zoomLevel.textContent, "100%"); assert.match(canvas.world.style.transform, /scale\(1\)$/); assert.equal(first.shield!.hidden, true, "the focused artboard loses its shield"); assert.equal(second.shield!.hidden, false); assert.equal(second.wrapper.inert, true, "the other artboards are inert"); assert.equal(canvas.document.activeElement, first.frame, "keyboard focus moves into the artboard"); assert.match(canvas.navMode.textContent, /^Using: First · Shift\+Esc to exit$/); assert.deepEqual(first.frame.contentWindow!.posted.at(-1), { type: "gd:focus", enabled: true }); // Canvas pan and zoom are off while focused. const focusedTransform = canvas.world.style.transform; canvas.viewport.dispatch("wheel", { deltaX: 0, deltaY: 80, deltaMode: 0, ctrlKey: false, metaKey: false, shiftKey: false, clientX: 10, clientY: 10 }); canvas.message(first.frame.contentWindow, { type: "gd:wheel", deltaX: 0, deltaY: 80, deltaMode: 0, zoom: true, shift: false, x: 5, y: 5 }); assert.equal(canvas.world.style.transform, focusedTransform); // gd:exit-focus is accepted only from the focused artboard's own window. canvas.message(second.frame.contentWindow, { type: "gd:exit-focus" }); canvas.message({}, { type: "gd:exit-focus" }); assert.equal(canvas.zoomLevel.textContent, "100%"); canvas.message(first.frame.contentWindow, { type: "gd:exit-focus" }); assert.equal(canvas.world.style.transform, before, "the previous zoom and pan return exactly"); assert.equal(canvas.zoomLevel.textContent, zoomBefore); assert.equal(first.shield!.hidden, false); assert.equal(second.wrapper.inert, false); assert.equal(canvas.navMode.textContent, "Navigating"); }); test("a taller artboard is top-aligned and clipped to the viewport while focused, then restored", async () => { const canvas = await loadCanvas(1600); const [first] = canvas.boards; assert.equal(first.frame.style.height, "1600px"); // In world coordinates the frame box sits at (480, 20), under a 20 px label. first.frame.parentElement!.offsetLeft = 480; first.frame.parentElement!.offsetTop = 20; click(first.shield!, 200, 200); // The 700 px viewport keeps a 16 px margin, the 20 px label and a 64 px band for the Back button. assert.equal(first.frame.style.height, "600px"); // x centers the 400 px board in 1000 px: 300 - 480; y puts its top 16 + 20 px down: 36 - 20. assert.equal(canvas.world.style.transform, "translate(-180px, 16px) scale(1)"); // Re-centering (window resize, panel toggle) is idempotent. canvas.windowEvent("resize"); assert.equal(canvas.world.style.transform, "translate(-180px, 16px) scale(1)"); canvas.document.getElementById("gd-back").dispatch("click"); assert.equal(first.frame.style.height, "1600px"); }); test("entering focus hides an open panel and exiting restores it; a closed panel stays closed", async () => { const canvas = await loadCanvas(); const [first] = canvas.boards; const panel = canvas.document.getElementById("gd-panel"); const toggle = canvas.document.getElementById("gd-panel-toggle"); assert.equal(panel.hidden, false); click(first.shield!, 200, 200); assert.equal(panel.hidden, true, "focus mode hides the open panel to gain width"); assert.equal(toggle.getAttribute("aria-pressed"), "false"); canvas.message(first.frame.contentWindow, { type: "gd:exit-focus" }); assert.equal(panel.hidden, false, "exiting reopens it"); assert.equal(toggle.getAttribute("aria-pressed"), "true"); toggle.dispatch("click"); assert.equal(panel.hidden, true); click(first.shield!, 200, 200); assert.equal(panel.hidden, true); canvas.document.getElementById("gd-back").dispatch("click"); assert.equal(panel.hidden, true, "a closed panel stays closed"); assert.equal(toggle.getAttribute("aria-pressed"), "false"); }); test("the Back to canvas button and Shift+Esc in the canvas both leave focus mode", async () => { const canvas = await loadCanvas(); const [first] = canvas.boards; const back = canvas.document.getElementById("gd-back"); assert.equal(back.hidden, true); click(first.shield!, 200, 200); assert.equal(back.hidden, false); back.dispatch("click"); assert.equal(canvas.navMode.textContent, "Navigating"); assert.equal(back.hidden, true); click(first.shield!, 200, 200); const keydown = canvas.document.listeners.get("keydown") ?? []; const plain: any = { key: "Escape", shiftKey: false, target: back, defaultPrevented: false, preventDefault() { plain.defaultPrevented = true; } }; keydown.forEach((listener) => listener(plain)); assert.match(canvas.navMode.textContent, /^Using:/, "plain Esc does not leave focus mode"); const chord: any = { key: "Escape", shiftKey: true, target: back, defaultPrevented: false, preventDefault() { chord.defaultPrevented = true; } }; keydown.forEach((listener) => listener(chord)); assert.equal(canvas.navMode.textContent, "Navigating"); }); test("a drag over an artboard pans the canvas, and in comment mode a click is routed to the inspector as a pick", async () => { const canvas = await loadCanvas(); const [first] = canvas.boards; first.frame.rect = { left: 100, top: 100, width: 200, height: 150 }; const before = canvas.world.style.transform; first.shield!.dispatch("pointerdown", { button: 0, pointerId: 1, clientX: 150, clientY: 150 }); first.shield!.dispatch("pointermove", { pointerId: 1, clientX: 190, clientY: 170 }); first.shield!.dispatch("pointerup", { button: 0, pointerId: 1, clientX: 190, clientY: 170 }); assert.notEqual(canvas.world.style.transform, before, "the drag panned"); assert.equal(canvas.navMode.textContent, "Navigating", "a drag never enters focus mode"); canvas.document.getElementById("gd-comment").dispatch("click"); click(first.shield!, 150, 130); assert.equal(canvas.navMode.textContent, "Navigating", "comment mode picks instead of focusing"); // The frame shows a 400x300 artboard at half size: (150-100)/0.5, (130-100)/0.5. assert.deepEqual(first.frame.contentWindow!.posted.at(-1), { type: "gd:pick-at", x: 100, y: 60 }); }); // S14: `open` while a canvas page is connected sends it a show event instead of opening a tab. test("a show event for the same design re-fetches its manifest and reloads its artboards, keeping zoom, pan and focus", async () => { const canvas = await loadCanvas(); // A laid-out world, so a fit() during the refresh would visibly change the view. Object.defineProperty(canvas.world, "offsetWidth", { get: () => 2000 }); Object.defineProperty(canvas.world, "offsetHeight", { get: () => 900 }); canvas.viewport.dispatch("wheel", { deltaX: 0, deltaY: 40, deltaMode: 0, ctrlKey: true, metaKey: false, shiftKey: false, clientX: 300, clientY: 300 }); const view = canvas.world.style.transform; const zoom = canvas.zoomLevel.textContent; const before = canvas.frames(); const manifestFetches = canvas.fetched.filter((url) => url === "/t/api/designs/alpha").length; // A file written while the page missed the change event: the refresh must still pick it up. (canvas.replies["/t/api/designs/alpha"] as { artboards: unknown[] }).artboards.push({ file: "03-c.html", title: "Third", tradeoff: "", width: 400, height: 300 }); await canvas.serverEvent("show", { slug: "alpha" }); assert.equal(canvas.fetched.filter((url) => url === "/t/api/designs/alpha").length, manifestFetches + 1, "the manifest is fetched again"); const after = canvas.frames(); assert.equal(after.length, 3, "the new artboard appears"); assert.ok(after.every((frame) => !before.includes(frame)), "every artboard is reloaded"); assert.equal(canvas.world.style.transform, view, "pan and zoom stay"); assert.equal(canvas.zoomLevel.textContent, zoom); assert.equal(canvas.urls.at(-1), "/t/d/alpha/"); // Focus mode survives a refresh of the same design, on the same artboard. const shield = canvas.frames()[0].parentElement!.children.find((child) => child.classes.has("gd-shield"))!; click(shield, 200, 200); assert.match(canvas.navMode.textContent, /^Using: First/); await canvas.serverEvent("show", { slug: "alpha" }); assert.match(canvas.navMode.textContent, /^Using: First/, "focus stays on the same artboard"); assert.equal(canvas.zoomLevel.textContent, "100%"); }); test("a show event for another design leaves focus, loads that design and replaces the URL", async () => { const canvas = await loadCanvas(); const [first] = canvas.boards; click(first.shield!, 200, 200); assert.match(canvas.navMode.textContent, /^Using:/); await canvas.serverEvent("show", { slug: "beta" }); assert.ok(canvas.fetched.includes("/t/api/designs/beta"), "the other design's manifest is loaded"); assert.ok(canvas.fetched.includes("/t/api/designs/beta/comments"), "and its comments"); assert.equal(canvas.urls.at(-1), "/t/d/beta/"); assert.equal(canvas.document.getElementById("gd-title").textContent, "Beta"); assert.equal(canvas.document.getElementById("gd-design").value, "beta", "the design selector follows"); assert.equal(canvas.navMode.textContent, "Navigating", "focus mode ends when the design changes"); assert.deepEqual(canvas.frames().map((frame) => frame.title), ["Only artboard"]); await canvas.serverEvent("show", { slug: null }); assert.equal(canvas.urls.at(-1), "/t/", "null shows the system-only view at the root URL"); assert.equal(canvas.document.getElementById("gd-title").textContent, "Gentle Design"); assert.deepEqual(canvas.frames(), []); const fetches = canvas.fetched.length; for (const data of [{ slug: 3 }, {}, "beta", { slug: "../x" }]) await canvas.serverEvent("show", data); assert.equal(canvas.fetched.length, fetches, "a malformed show event is ignored"); }); function loadInspector() { const posted: any[] = []; const listeners = new Map(); class Element {} const style = () => ({ cursor: "" }); const makeNode = () => Object.assign(new Element(), { style: style(), isConnected: false, setAttribute() {}, hasAttribute: () => false, getBoundingClientRect: () => ({ left: 0, top: 0, width: 10, height: 10 }), }); const button = Object.assign(new Element(), { id: "cta", localName: "button", innerText: "Buy now", parentElement: null, hasAttribute: () => false, getAttribute: () => null, getBoundingClientRect: () => ({ left: 4, top: 8, width: 60, height: 20 }), }); const scrolling = { scrollTop: 0, scrollLeft: 0, clientHeight: 100, scrollHeight: 100, clientWidth: 100, scrollWidth: 100, parentElement: null }; const documentElement = Object.assign(makeNode(), { append() {} }); const document = { documentElement, body: makeNode(), scrollingElement: scrolling, createElement: makeNode, elementFromPoint: () => button, querySelectorAll: (selector: string) => (selector === "#cta" ? [button] : []), querySelector: () => null, }; const parent = { postMessage: (message: any) => posted.push(structuredCopy(message)) }; const context: Record = { document, Element, CSS: { escape: (value: string) => value }, getComputedStyle: () => ({ overflowX: "hidden", overflowY: "hidden" }), requestAnimationFrame: () => 0, MutationObserver: class { observe() {} }, parent, addEventListener(type: string, listener: Listener) { listeners.set(type, [...(listeners.get(type) ?? []), listener]); }, }; context.window = context; vm.runInNewContext(read("inspector.js"), context); const fire = (type: string, init: Record) => { const event: any = { defaultPrevented: false, propagationStopped: false, ...init, preventDefault() { event.defaultPrevented = true; }, stopPropagation() { event.propagationStopped = true; }, stopImmediatePropagation() { event.propagationStopped = true; } }; for (const listener of listeners.get(type) ?? []) listener(event); return event; }; const fromParent = (data: unknown) => fire("message", { source: parent, data }); return { posted, fire, fromParent }; } test("the inspector posts gd:exit-focus on Shift+Esc and keeps plain Esc for the design", () => { const inspector = loadInspector(); const plain = inspector.fire("keydown", { key: "Escape", shiftKey: false }); assert.deepEqual(inspector.posted, []); assert.equal(plain.defaultPrevented, false); const chord = inspector.fire("keydown", { key: "Escape", shiftKey: true }); assert.deepEqual(inspector.posted, [{ type: "gd:exit-focus" }]); assert.equal(chord.defaultPrevented, true); inspector.fire("keydown", { key: "Enter", shiftKey: true }); assert.equal(inspector.posted.length, 1); }); test("the inspector picks the element under a routed point only in comment mode", () => { const inspector = loadInspector(); inspector.fromParent({ type: "gd:pick-at", x: 10, y: 12 }); assert.deepEqual(inspector.posted, [], "no pick outside comment mode"); inspector.fromParent({ type: "gd:mode", enabled: true }); inspector.fromParent({ type: "gd:pick-at", x: 10, y: 12 }); assert.deepEqual(inspector.posted, [{ type: "gd:pick", selector: "#cta", excerpt: "Buy now", rect: { x: 4, y: 8, width: 60, height: 20 } }]); inspector.fromParent({ type: "gd:pick-at", x: "10", y: 12 }); assert.equal(inspector.posted.length, 1, "non-numeric points are ignored"); }); test("in focus mode the inspector leaves the wheel to the artboard and never pans the canvas", () => { const inspector = loadInspector(); const wheel = { deltaX: 0, deltaY: 50, deltaMode: 0, shiftKey: false, clientX: 1, clientY: 1, target: null }; const unfocused = inspector.fire("wheel", { ...wheel, ctrlKey: false, metaKey: false }); assert.equal(unfocused.defaultPrevented, true); assert.equal(inspector.posted.at(-1).type, "gd:wheel"); inspector.posted.length = 0; inspector.fromParent({ type: "gd:focus", enabled: true }); const plain = inspector.fire("wheel", { ...wheel, ctrlKey: false, metaKey: false }); assert.equal(plain.defaultPrevented, false, "the artboard scrolls natively"); const zoom = inspector.fire("wheel", { ...wheel, ctrlKey: true, metaKey: false }); assert.equal(zoom.defaultPrevented, true, "Ctrl/Cmd+wheel never zooms the browser page"); assert.deepEqual(inspector.posted, []); }); test("canvas assets keep the sandbox, the source check and no remote URLs", () => { const canvas = read("canvas.js"); assert.match(canvas, /frame\.setAttribute\("sandbox", "allow-scripts"\)/); assert.match(canvas, /candidate\.frame\.contentWindow === event\.source/); // Only the Compare preview of the running app (another loopback origin) may keep its own origin. assert.equal(canvas.split("allow-same-origin").length - 1, 1); assert.match(canvas, /const PREVIEW_SANDBOX = "allow-scripts allow-same-origin allow-forms allow-popups";/); for (const name of ["index.html", "canvas.css", "canvas.js", "inspector.js"]) { const source = read(name); assert.ok(!source.includes("allow-top-navigation"), `${name} must not grant top navigation`); if (name !== "canvas.js") assert.ok(!source.includes("allow-same-origin"), `${name} must not grant allow-same-origin`); assert.ok(!/https?:\/\//i.test(source), `${name} must not reference remote URLs`); assert.ok(!/innerHTML|outerHTML|insertAdjacentHTML/.test(source), `${name} must not parse HTML strings`); } const html = read("index.html"); assert.match(html, /id="gd-back"[^>]*>Back to canvas]*>Navigating boolean): FakeElement[] { const found: FakeElement[] = test(root) ? [root] : []; for (const child of root.children) found.push(...findAll(child, test)); return found; } const withClass = (root: FakeElement, name: string) => findAll(root, (element) => element.classes.has(name)); const texts = (root: FakeElement, name: string) => withClass(root, name).map((element) => element.textContent); function handoffDoc(chosen: string | null, handoffs: Record>) { const entries = Object.fromEntries(Object.entries(handoffs).map(([file, entry]) => [file, { status: "sent", queued: false, sentAt: "2026-10-26T10:00:00.000Z", startedAt: null, implementedAt: null, ...entry }])); return { version: 1, chosen, handoffs: entries }; } async function setHandoffs(canvas: Awaited>, document: unknown) { canvas.replies["/t/api/designs/alpha/handoffs"] = document; await canvas.serverEvent("change", { slug: "alpha", file: "handoffs.json" }); } const boardsOf = (canvas: Awaited>) => canvas.world.children; test("the handoff badge follows the state live, and shows the linked feature document", async () => { const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { queued: true, feature: "odd/tasks/login.md" } }) }); const [first, second] = boardsOf(canvas); assert.deepEqual(texts(first, "gd-handoff-badge"), ["Queued"]); assert.deepEqual(texts(first, "gd-handoff-feature"), ["odd/tasks/login.md"]); assert.deepEqual(texts(second, "gd-handoff-badge"), [], "an artboard never handed off has no badge"); const states: Array<[Record, string]> = [ [{ queued: false }, "Implementing…"], [{ status: "in_progress", startedAt: "2026-10-26T10:01:00.000Z" }, "In progress"], [{ status: "implemented", implementedAt: "2026-10-26T10:02:00.000Z", note: "Done with the header" }, "Implemented ✓"], ]; for (const [entry, label] of states) { await setHandoffs(canvas, handoffDoc("01-a.html", { "01-a.html": { feature: "odd/tasks/login.md", ...entry } })); assert.deepEqual(texts(boardsOf(canvas)[0], "gd-handoff-badge"), [label], label); } assert.deepEqual(texts(boardsOf(canvas)[0], "gd-handoff-note"), ["Done with the header"]); assert.ok(canvas.fetched.filter((url) => url === "/t/api/designs/alpha/handoffs").length >= 4, "every handoffs.json change is fetched"); assert.equal(boardsOf(canvas)[0], first, "a handoffs.json change does not reload the artboards"); }); test("the chosen artboard gets a Chosen badge and an accent frame; the others are labeled Alternative", async () => { const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc(null, {}) }); for (const board of boardsOf(canvas)) { assert.deepEqual(texts(board, "gd-choice"), [], "nothing is chosen yet"); assert.equal(board.classes.has("gd-chosen"), false); } await setHandoffs(canvas, handoffDoc("02-b.html", { "02-b.html": {} })); const [first, second] = boardsOf(canvas); assert.deepEqual(texts(second, "gd-choice"), ["Chosen"]); assert.equal(second.classes.has("gd-chosen"), true); assert.deepEqual(texts(first, "gd-choice"), ["Alternative"]); assert.equal(first.classes.has("gd-chosen"), false); await setHandoffs(canvas, handoffDoc("01-a.html", { "02-b.html": {}, "01-a.html": {} })); assert.deepEqual(boardsOf(canvas).map((board) => texts(board, "gd-choice")[0]), ["Chosen", "Alternative"], "a new handoff moves Chosen"); }); test("Compare appears only on an implemented artboard with a loopback previewUrl other than the canvas", async () => { const canvas = await loadCanvas(); const compareButtons = () => boardsOf(canvas).map((board) => withClass(board, "gd-compare-start").length); const cases: Array<[Record, number]> = [ [{ status: "implemented" }, 0], [{ status: "in_progress", previewUrl: "http://localhost:3000/" }, 0], [{ status: "implemented", previewUrl: "http://example.com/" }, 0], [{ status: "implemented", previewUrl: "http://localhost:4100/" }, 0], [{ status: "implemented", previewUrl: "javascript:alert(1)" }, 0], [{ status: "implemented", previewUrl: "http://localhost:3000/" }, 1], [{ status: "implemented", previewUrl: "http://[::1]:3000/" }, 1], ]; for (const [entry, count] of cases) { await setHandoffs(canvas, handoffDoc("01-a.html", { "01-a.html": entry })); assert.deepEqual(compareButtons(), [count, 0], JSON.stringify(entry)); } }); test("Compare shows the artboard and the sandboxed app side by side at the artboard size, and Shift+Esc or Back exits", async () => { const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { status: "implemented", previewUrl: "http://localhost:5173/login" } }) }); const overlay = canvas.document.getElementById("gd-compare"); const panes = canvas.document.getElementById("gd-compare-panes"); const open = () => withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click"); open(); assert.equal(overlay.hidden, false); const frames = findAll(panes, (element) => element.localName === "iframe"); assert.equal(frames.length, 2); const [artboard, preview] = frames; assert.equal(artboard.getAttribute("sandbox"), "allow-scripts", "the artboard stays sandboxed as on the canvas"); assert.equal(artboard.src, "/t/files/alpha/01-a.html"); assert.equal(preview.getAttribute("sandbox"), "allow-scripts allow-same-origin allow-forms allow-popups"); assert.equal(preview.getAttribute("referrerpolicy"), "no-referrer"); assert.equal(preview.src, "http://localhost:5173/login"); for (const frame of frames) assert.deepEqual([frame.style.width, frame.style.height], ["400px", "300px"]); const link = canvas.document.getElementById("gd-compare-open"); assert.equal(link.href, "http://localhost:5173/login"); assert.equal(link.getAttribute("rel"), "noopener noreferrer"); assert.equal(link.getAttribute("target"), "_blank"); assert.equal(canvas.viewport.inert, true, "the canvas underneath is inert"); const keydown = canvas.document.listeners.get("keydown") ?? []; const press = (shiftKey: boolean) => { const event: any = { key: "Escape", shiftKey, target: canvas.document.body, defaultPrevented: false, preventDefault() { event.defaultPrevented = true; } }; keydown.forEach((listener) => listener(event)); }; press(false); assert.equal(overlay.hidden, false, "plain Esc stays"); press(true); assert.equal(overlay.hidden, true, "Shift+Esc exits Compare"); assert.deepEqual(panes.children, [], "the app frame is removed"); assert.equal(canvas.viewport.inert, false); open(); canvas.document.getElementById("gd-compare-back").dispatch("click"); assert.equal(overlay.hidden, true, "Back to canvas exits Compare"); open(); const [shownArtboard] = findAll(panes, (element) => element.localName === "iframe"); canvas.message({}, { type: "gd:exit-focus" }); assert.equal(overlay.hidden, false, "only the compared artboard may ask to exit"); canvas.message(shownArtboard.contentWindow, { type: "gd:exit-focus" }); assert.equal(overlay.hidden, true, "Shift+Esc inside the artboard exits Compare"); }); test("Compare offers scale to fit when the pair is wider than the window", async () => { const canvas = await loadCanvas(300, { "/t/api/designs/alpha/handoffs": handoffDoc("01-a.html", { "01-a.html": { status: "implemented", previewUrl: "http://localhost:3000/" } }) }); const stage = canvas.document.getElementById("gd-compare-stage"); stage.rect = { left: 0, top: 0, width: 500, height: 600 }; withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click"); const fit = canvas.document.getElementById("gd-compare-fit"); const field = canvas.document.getElementById("gd-compare-fit-field"); const panes = canvas.document.getElementById("gd-compare-panes"); assert.equal(field.hidden, false, "two 400 px boards do not fit in 500 px"); assert.equal(panes.style.transform, "none", "Compare starts at 100%"); fit.checked = true; fit.dispatch("change"); assert.match(panes.style.transform, /^scale\(0\.\d+\)$/); canvas.document.getElementById("gd-compare-back").dispatch("click"); stage.rect = { left: 0, top: 0, width: 2000, height: 600 }; withClass(boardsOf(canvas)[0], "gd-compare-start")[0].dispatch("click"); assert.equal(field.hidden, true, "no toggle when the pair fits"); assert.equal(panes.style.transform, "none"); }); test("Implement this shows a toast and the badge at once: Queued when busy, Implementing… when idle", async () => { const canvas = await loadCanvas(300, { "/t/api/designs/alpha/implement": { ok: true, slug: "alpha", file: "01-a.html", queued: true } }); const toast = canvas.document.getElementById("gd-toast"); assert.match(read("index.html"), /