import { readFileSync } from "node:fs"; import { expect, test } from "@playwright/test"; import { type MockupResult, type MockupServer, startMockupServer, } from "../mockup.ts"; import { createMockupPreviewManager, renderMockupPreviewPage, } from "../mockup-preview.ts"; let server: MockupServer | undefined; const interactiveFragment = readFileSync( new URL("../guidance/mockup-html/SKILL.md", import.meta.url), "utf8", ).match(/```html\n([\s\S]*?)```/)?.[1]; if (!interactiveFragment) throw new Error("Missing interactive mockup skill example"); test.afterEach(() => { if (!server) return; server.result.catch(() => undefined); server.close("e2e cleanup"); server = undefined; }); test("capture preview runs fragment scripts and preserves animation", async ({ page, }) => { await page.setContent( renderMockupPreviewPage({ title: "Preview", question: "Does it move?", options: [ { id: "animated", label: "Animated", html: `
`, }, ], }), ); const card = page.locator(".preview-card"); await expect(card).toHaveCount(1); await expect .poll(() => card.evaluate((element) => { const content = element.querySelector(".fragment-content"); const root = content?.shadowRoot; return { ready: root?.querySelector(".dot")?.getAttribute("data-ready"), controls: document.querySelectorAll("#accept, #iterate").length, }; }), ) .toEqual({ ready: "yes", controls: 0 }); const first = await card.screenshot({ animations: "allow" }); await expect .poll(async () => { const next = await card.screenshot({ animations: "allow" }); return first.equals(next); }) .toBe(false); }); test("grows a shared bounded canvas and reports unavoidable cropping", async ({ page, }) => { await page.setContent( renderMockupPreviewPage({ title: "Sizing", question: "Is all ordinary content visible?", options: [ { id: "tall", label: "Tall", html: '
bottom
', }, { id: "short", label: "Short", html: '
short
', }, { id: "oversized", label: "Oversized", html: '
oversized
', }, ], }), ); await expect .poll(() => page.evaluate(() => document.documentElement.dataset.mockupPreviewReady), ) .toBe("true"); const cards = page.locator(".preview-card"); const heights = await cards.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height), ); expect(new Set(heights).size).toBe(1); expect(heights[0]).toBe(720); expect(await cards.nth(0).getAttribute("data-preview-cropped")).toBeNull(); expect(await cards.nth(2).getAttribute("data-preview-cropped")).toBe(""); const ordinaryBounds = await cards.nth(0).evaluate((card) => { const bottom = card .querySelector(".fragment-content") ?.shadowRoot?.querySelector("#bottom") ?.getBoundingClientRect(); const bounds = card.getBoundingClientRect(); return { bottom: bottom?.bottom, cardBottom: bounds.bottom }; }); expect(ordinaryBounds.bottom).toBeLessThanOrEqual( ordinaryBounds.cardBottom ?? 0, ); }); test("captures sharp frames and loops them after completion", async () => { const manager = createMockupPreviewManager(); try { const preview = await manager.start("loop", { title: "Loop", question: "Does it repeat?", options: [ { id: "animated", label: "Animated", html: `
`, }, ], }); const png = Buffer.from(preview.current()?.[0] ?? "", "base64"); expect(png.readUInt32BE(16)).toBe(720); await preview.done; const completedVersion = preview.version(); await expect .poll(() => preview.version(), { timeout: 2_500 }) .toBeGreaterThan(completedVersion); await manager.start("replacement", { title: "Replacement", question: "Static?", options: [{ id: "static", label: "Static", html: "

Still

" }], }); const frozenVersion = preview.version(); expect(manager.get("loop")).toBe(preview); expect(preview.expired()).toBe(false); expect(preview.current()).toBeDefined(); await new Promise((resolve) => setTimeout(resolve, 1_200)); expect(preview.version()).toBe(frozenVersion); } finally { await manager.close(); } }); test("iterates in one page with live Pi progress, stop, and optional selection", async ({ page, }) => { let aborts = 0; server = await startMockupServer( { title: "Layout review", question: "Which layout works?", options: [ { id: "dense", label: "Dense", description: "Compact layout", html: `${interactiveFragment}`, }, { id: "calm", label: "Calm", html: interactiveFragment, }, ], }, { openBrowser: false, abortAgent: () => { aborts++; return true; }, }, ); await page.goto(server.url); const stableUrl = page.url(); await expect( page.getByRole("heading", { name: "Layout review" }), ).toBeVisible(); await expect(page.getByText("Which layout works?")).toBeVisible(); await expect(page.locator(".option-card")).toHaveCount(2); const denseCounter = page.locator('[data-fragment="dense"]'); await expect( denseCounter.locator('script[type="application/json"]'), ).toHaveJSProperty("textContent", '{"count":0}'); const output = denseCounter.locator("output"); await expect(output).toHaveText("0"); await denseCounter.getByRole("button", { name: "Increment" }).click(); await expect(output).toHaveText("1"); await expect(page.locator('[data-fragment="calm"] output')).toHaveText("0"); expect( await page.evaluate(() => [...document.scripts].some((script) => Object.hasOwn(script, "mockupRoot"), ), ), ).toBe(false); await expect .poll(() => denseCounter.locator(".counter").evaluate((element) => ({ shadow: element.getRootNode() instanceof ShadowRoot, reviewFrameControls: document.querySelectorAll("#accept, #iterate").length, })), ) .toEqual({ shadow: true, reviewFrameControls: 2 }); await page.locator('[data-select-option="dense"]').check(); await page.locator("#global-notes").fill("keep dense"); await page.locator('[data-pin-option="dense"]').click(); const fragment = page.locator('[data-fragment="dense"]'); const fragmentBox = await fragment.boundingBox(); expect(fragmentBox).not.toBeNull(); await fragment.click({ position: { x: (fragmentBox?.width ?? 20) - 10, y: 40 }, }); await page.getByLabel("Pin note").fill("tighten spacing"); const annotationGeometry = await fragment.evaluate((element) => { const fragmentRect = element.getBoundingClientRect(); const bubbleRect = element .querySelector(".pin-bubble") ?.getBoundingClientRect(); const pinRect = element.querySelector(".pin")?.getBoundingClientRect(); return { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, bubbleWithin: !!bubbleRect && bubbleRect.left >= fragmentRect.left && bubbleRect.right <= fragmentRect.right, pinWithin: !!pinRect && pinRect.left >= fragmentRect.left && pinRect.right <= fragmentRect.right, }; }); expect(annotationGeometry).toEqual({ clientWidth: annotationGeometry.clientWidth, scrollWidth: annotationGeometry.clientWidth, bubbleWithin: true, pinWithin: true, }); const firstSubmission = server.result; await page.locator("#iterate").click(); await expect(page.locator("#progress-title")).toHaveText( "Feedback received. Pi is working.", ); const first: MockupResult = await firstSubmission; expect(first).toMatchObject({ action: "iterate", selected: ["dense"], notes: "keep dense", annotations: [{ optionId: "dense", note: "tighten spacing" }], review: { viewport: { devicePixelRatio: 1 } }, }); server.progress("Pi is working on the revision."); await expect(page.locator("#progress-title")).toHaveText( "Pi is working on the revision.", ); await page.getByRole("button", { name: "Stop agent" }).click(); await expect.poll(() => aborts).toBe(1); const secondSubmission = server.present({ title: "Layout revision", question: "Does the denser revision work?", options: [ { id: "dense-v2", label: "Dense revision", html: interactiveFragment, }, ], }); await expect( page.getByRole("heading", { name: "Layout revision" }), ).toBeVisible(); expect(page.url()).toBe(stableUrl); const revised = page.locator('[data-fragment="dense-v2"]'); await revised.getByRole("button", { name: "Increment" }).click(); await expect(revised.locator("output")).toHaveText("1"); await page.locator("#accept").click(); await expect(page.locator("#progress-title")).toHaveText( "Accepted. You can close this tab.", ); const second = await secondSubmission; expect(second).toMatchObject({ action: "accept", selected: ["dense-v2"], notes: "", annotations: [], }); });