import { readFile } from "node:fs/promises";
import path from "node:path";
import {
expect,
type Page,
type FrameLocator,
type Locator,
} from "@playwright/test";
import { FIXTURE_HTML, SEED_TITLE } from "./global-setup";
/**
* Helpers for driving the Design visual editor in real Chrome.
*
* Hard-won facts for this editor:
* - The design renders inside an iframe, so tests should use a frame locator
* (`page.frameLocator('iframe')`) instead of parent-page CSS selectors.
* - A pointer-capturing shield `
`
* sits on top inside the iframe, so clicks need `{ force: true }`.
* - Selection/edits are reported to the parent via postMessage
* (`element-select`, `element-hover`, `visual-style-change`,
* `visual-structure-change`). Assert on those + the parent inspector DOM.
* - `page.screenshot()` HANGS (the page never idles). Use `cdpScreenshot()`.
*/
export async function readSeedDesignId(): Promise
{
const authDir = process.env.E2E_AUTH_DIR
? path.resolve(process.env.E2E_AUTH_DIR)
: path.join(import.meta.dirname, ".auth");
const seedPath = path.join(authDir, "seed.json");
const raw = await readFile(seedPath, "utf8");
const { designId } = JSON.parse(raw) as { designId: string };
if (!designId) throw new Error("no seeded designId - global-setup failed");
return designId;
}
function e2eBaseUrl(page: Page): string {
const currentUrl = page.url();
if (currentUrl && currentUrl !== "about:blank") {
return new URL(currentUrl).origin;
}
return (
process.env.E2E_BASE_URL ??
`http://127.0.0.1:${process.env.E2E_PORT ?? "9333"}`
);
}
async function postAction(
page: Page,
name: string,
input: Record,
): Promise {
const res = await page.request.post(
`${e2eBaseUrl(page)}/_agent-native/actions/${name}`,
{
data: input,
headers: { "Content-Type": "application/json" },
},
);
if (!res.ok()) {
throw new Error(
`action ${name} failed: ${res.status()} ${await res.text()}`,
);
}
return res.json();
}
export async function createFixtureDesign(
page: Page,
title = SEED_TITLE,
): Promise {
const created = await postAction(page, "create-design", {
title,
projectType: "prototype",
});
const designId: string | undefined =
created?.id ?? created?.data?.id ?? created?.design?.id;
if (!designId) {
throw new Error(
`create-design did not return an id: ${JSON.stringify(created)}`,
);
}
await postAction(page, "create-file", {
designId,
filename: "index.html",
content: FIXTURE_HTML,
fileType: "html",
});
return designId;
}
const DESIGN_PREVIEW_IFRAME_SELECTOR = "iframe[data-design-preview-iframe]";
const E2E_BASE_URL = process.env.E2E_BASE_URL;
const E2E_BASE_PATH = (() => {
if (!E2E_BASE_URL) return "";
try {
return new URL(E2E_BASE_URL).pathname.replace(/\/$/, "");
} catch {
return "";
}
})();
export function appPath(path: string): string {
const route = new URL(path, "http://agent-native.local");
if (E2E_BASE_URL && E2E_BASE_PATH) {
const url = new URL(E2E_BASE_URL);
// dev-lazy strips the app mount (/design) before handing the request to the
// app, so the editor's own /design/:id route intentionally becomes
// /design/design/:id when E2E_BASE_URL points at the gateway mount.
url.pathname = `${E2E_BASE_PATH}${route.pathname}`;
url.search = route.search;
url.hash = route.hash;
return url.toString();
}
return `${route.pathname}${route.search}${route.hash}`;
}
export function designFrame(page: Page, screenId?: string): FrameLocator {
const iframe = screenId
? page.locator(
`${DESIGN_PREVIEW_IFRAME_SELECTOR}[data-screen-iframe-id="${screenId}"]`,
)
: page.locator(DESIGN_PREVIEW_IFRAME_SELECTOR).last();
return iframe.contentFrame();
}
async function selectableNodeByText(
page: Page,
text: string,
): Promise {
const candidates = designFrame(page).locator("[data-agent-native-node-id]", {
hasText: text,
});
const fallback = designFrame(page).getByText(text, { exact: false }).first();
const count = await candidates.count();
if (count === 0) return fallback;
let bestIndex = 0;
let bestArea = Number.POSITIVE_INFINITY;
for (let index = 0; index < count; index += 1) {
const candidate = candidates.nth(index);
const box = await candidate.boundingBox().catch(() => null);
if (!box || box.width <= 0 || box.height <= 0) continue;
const area = box.width * box.height;
if (area < bestArea) {
bestIndex = index;
bestArea = area;
}
}
return candidates.nth(bestIndex);
}
/** Open the editor for a design and wait for the toolbar + iframe to be ready. */
export async function gotoEditor(page: Page, designId: string): Promise {
await page.goto(appPath(`/design/${designId}`), {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("button", { name: "Move", exact: true }),
).toBeVisible({ timeout: 30_000 });
await waitForDesignBridgeReady(page);
}
async function waitForDesignBridgeReady(page: Page): Promise {
await expect(
page.locator(DESIGN_PREVIEW_IFRAME_SELECTOR).first(),
).toBeVisible();
const overviewChromeVisible = await page
.locator("[data-screen-shell]")
.first()
.isVisible()
.catch(() => false);
if (!overviewChromeVisible) {
const editShield = designFrame(page).locator(
'[data-agent-native-edit-overlay="shield"]',
);
// Edit mode installs the pointer shield; Interact mode intentionally does
// not. If a shield exists, wait for it, but don't confuse the persistent
// toolbar Interact button with overview screen-card chrome.
if ((await editShield.count()) > 0) {
await expect(editShield).toBeVisible({ timeout: 10_000 });
}
}
// Wait for the iframe bridge to stamp at least one selectable node.
await expect
.poll(
async () => {
const previewIframes = await page
.locator(DESIGN_PREVIEW_IFRAME_SELECTOR)
.elementHandles();
let selectableNodeCount = 0;
for (const iframe of previewIframes) {
const frame = await iframe.contentFrame();
if (!frame) continue;
selectableNodeCount += await frame
.locator("[data-agent-native-node-id], h1, h2, p, button")
.count()
.catch(() => 0);
}
return selectableNodeCount;
},
{ timeout: 20_000 },
)
.toBeGreaterThan(0);
if (overviewChromeVisible) {
await expect(page.locator("[data-screen-shell]").first()).toBeVisible({
timeout: 10_000,
});
await expect
.poll(
async () => {
const box = await page
.locator("[data-screen-shell]")
.first()
.locator("[data-screen-card]")
.first()
.boundingBox();
return box && box.width > 0 && box.height > 0;
},
{ timeout: 10_000 },
)
.toBeTruthy();
await page.waitForTimeout(750);
}
}
export async function enterDirectMode(
page: Page,
options?: { screenId?: string },
): Promise {
const fullView = page.getByRole("button", { name: "Interact", exact: true });
const fullViewVisible = await fullView
.first()
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (fullViewVisible) {
const targetFullView = options?.screenId
? page
.locator(
`[data-screen-shell][data-frame-id="${options.screenId.replace(/"/g, '\\"')}"]`,
)
.getByRole("button", { name: "Interact", exact: true })
: fullView.last();
await expect(targetFullView).toHaveCount(1);
await targetFullView.click();
// The top toolbar's Interact mode button intentionally remains visible
// (and becomes pressed) in direct mode. The old assertion expected every
// button named "Interact" to disappear, which now mistakes that persistent
// mode control for an overview card. The overview screen shells are the
// stable boundary that actually unmounts when the transition succeeds.
await expect(page.locator("[data-screen-shell]")).toHaveCount(0);
}
await expect
.poll(
async () =>
(
await page
.locator(DESIGN_PREVIEW_IFRAME_SELECTOR)
.last()
.boundingBox()
)?.width ?? 0,
{ timeout: 10_000 },
)
.toBeGreaterThan(600);
await waitForDesignBridgeReady(page);
}
/** Start capturing bridge postMessages on the parent window. */
export async function installBridge(page: Page): Promise {
await page.evaluate(() => {
const win = window as any;
if (!Array.isArray(win.__bridge)) win.__bridge = [];
if (win.__bridgeInstalled) return;
win.__bridgeInstalled = true;
window.addEventListener("message", (e: MessageEvent) => {
const t = (e.data as any)?.type;
if (
typeof t === "string" &&
(/^(element-|visual-)/.test(t) || t === "text-content-change")
) {
if (!Array.isArray(win.__bridge)) win.__bridge = [];
win.__bridge.push(e.data);
}
});
});
}
export async function bridgeMessages(page: Page): Promise {
return page.evaluate(() => (window as any).__bridge ?? []);
}
/** Wait until a bridge message of `type` arrives, returning its payload. */
export async function waitForBridge(
page: Page,
type: string,
timeout = 15_000,
): Promise {
const handle = await page.waitForFunction(
(t) =>
[...((window as any).__bridge ?? [])]
.reverse()
.find((m: any) => m.type === t) ?? null,
type,
{ timeout },
);
return handle.jsonValue();
}
/**
* Click an element inside the design iframe by its visible text and return the
* resulting `element-select` payload. Uses force:true to punch through the
* shield overlay (which is what actually drives selection).
*/
export async function selectByText(
page: Page,
text: string,
options?: { screenId?: string },
): Promise {
await enterDirectMode(page, options);
await installBridge(page);
await page.evaluate(() => ((window as any).__bridge = []));
const target = await selectableNodeByText(page, text);
await target.waitFor({ state: "visible", timeout: 8_000 });
const box = await target.boundingBox();
if (!box) throw new Error(`no bounding box for "${text}"`);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
let sel: any;
try {
sel = await waitForBridge(page, "element-select", 2_000);
} catch {
await page.evaluate(() => ((window as any).__bridge = []));
await dispatchShieldClickByText(page, text);
sel = await waitForBridge(page, "element-select");
}
const payload = sel?.payload ?? sel;
expect(String(payload?.textContent ?? "")).toContain(text);
return payload;
}
async function dispatchShieldClickByText(
page: Page,
text: string,
): Promise {
const target = await selectableNodeByText(page, text);
await target.waitFor({ state: "visible", timeout: 8_000 });
const rect = await target.boundingBox();
if (!rect) throw new Error(`unable to dispatch selection for "${text}"`);
const frameRect = await page
.locator(DESIGN_PREVIEW_IFRAME_SELECTOR)
.last()
.boundingBox();
if (!frameRect) throw new Error("unable to locate design iframe");
await designFrame(page)
.locator('[data-agent-native-edit-overlay="shield"]')
.first()
.dispatchEvent("click", {
bubbles: true,
cancelable: true,
clientX: rect.x - frameRect.x + rect.width / 2,
clientY: rect.y - frameRect.y + rect.height / 2,
detail: 1,
});
}
/** Number of inputs in the right-hand inspector (proxy for "inspector populated"). */
export async function inspectorInputCount(page: Page): Promise {
return page.locator("input").count();
}
/**
* Drag an element inside the iframe by `(dx, dy)` parent-page pixels using real
* pointer events (the canvas bridge handles mousedown/mousemove/mouseup).
* Selects it first so the move/reorder interaction is armed.
*/
export async function dragCanvasByText(
page: Page,
text: string,
dx: number,
dy: number,
): Promise {
await selectByText(page, text);
await page.evaluate(() => ((window as any).__bridge = []));
const target = await selectableNodeByText(page, text);
const box = await target.boundingBox();
if (!box) throw new Error(`no bounding box for "${text}"`);
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
await page.mouse.down();
const steps = 10;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(cx + (dx * i) / steps, cy + (dy * i) / steps);
}
await page.waitForTimeout(150);
await page.mouse.up();
await page.waitForTimeout(400);
const msgs = await bridgeMessages(page);
return [...new Set(msgs.map((m) => m.type))];
}
/** Screenshot via CDP; bypasses Playwright's stability wait, which never settles. */
export async function cdpScreenshot(
page: Page,
filePath: string,
): Promise {
const client = await page.context().newCDPSession(page);
const { data } = await client.send("Page.captureScreenshot", {
format: "png",
});
const { writeFile } = await import("node:fs/promises");
await writeFile(filePath, Buffer.from(data, "base64"));
}