#!/usr/bin/env node /** * Real-browser proof of Studio's complete WebMCP edit loop. * * The runner owns its environment: it copies the checked-in adversarial fixture * to a temporary directory, symlinks that copy into Studio's ignored project * directory, starts a bounded Vite server, injects the real * `document.modelContext.registerTool` producer boundary before React mounts, * and calls the registered tool definitions through their `execute` callbacks. * It never calls a tool implementation directly and never mutates the fixture. * * Run from the repository root: * * node packages/studio/tests/e2e/webmcp-edit-loop.mjs * * Optional environment: * * WEBMCP_E2E_PORT=5197 * WEBMCP_E2E_EVIDENCE_DIR=/absolute/output/directory * PUPPETEER_EXECUTABLE_PATH=/absolute/path/to/chrome */ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { createServer } from "node:net"; import { arch, platform, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import puppeteer from "puppeteer-core"; import { resolveChromeExecutable } from "./chrome-executable.mjs"; const E2E_DIR = dirname(fileURLToPath(import.meta.url)); const STUDIO_DIR = resolve(E2E_DIR, "../.."); const FIXTURE_DIR = join(E2E_DIR, "fixtures/webmcp-edit-loop"); const PROJECT_ID = "webmcp-edit-loop"; const REQUESTED_PORT = process.env.WEBMCP_E2E_PORT ? Number(process.env.WEBMCP_E2E_PORT) : null; const PORT = REQUESTED_PORT ?? (await findAvailablePort()); const ORIGIN = `http://127.0.0.1:${PORT}`; const RUN_ID = new Date().toISOString().replace(/[:.]/g, "-"); const EVIDENCE_DIR = resolve( process.env.WEBMCP_E2E_EVIDENCE_DIR || join(E2E_DIR, "evidence", PROJECT_ID, RUN_ID), ); const TOOL_COUNT = 12; const NAVIGATION_TIMEOUT_MS = 90_000; const RENDER_TIMEOUT_MS = 120_000; function assert(condition, message) { if (!condition) throw new Error(message); } function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function findAvailablePort() { return new Promise((resolvePort, reject) => { const server = createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(); reject(new Error("Could not allocate a Studio test port")); return; } server.close((error) => (error ? reject(error) : resolvePort(address.port))); }); }); } // Bounded polling is the contract here: startup failure must include the child logs. // fallow-ignore-next-line complexity async function waitForServer(child, logs) { const deadline = Date.now() + NAVIGATION_TIMEOUT_MS; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Studio server exited early (${child.exitCode})\n${logs.join("")}`); } try { const response = await fetch(`${ORIGIN}/api/projects`); if (response.ok) return; } catch { // The port is not listening yet. } await new Promise((resolveWait) => setTimeout(resolveWait, 250)); } throw new Error(`Studio server did not become ready in ${NAVIGATION_TIMEOUT_MS}ms`); } function startStudioServer(logs) { const child = spawn("bun", ["run", "dev", "--", "--port", String(PORT), "--strictPort"], { cwd: STUDIO_DIR, env: { ...process.env, HYPERFRAMES_AUTO_PROXY: "false" }, stdio: ["ignore", "pipe", "pipe"], }); const record = (chunk) => { logs.push(String(chunk)); if (logs.length > 120) logs.shift(); }; child.stdout.on("data", record); child.stderr.on("data", record); return child; } function installModelContextHarness(disabled) { if (window.top !== window) return; if (disabled) { const key = "hf-studio-ui-preferences"; const previous = JSON.parse(localStorage.getItem(key) ?? "{}"); localStorage.setItem(key, JSON.stringify({ ...previous, agentToolsEnabled: false })); } const registered = new Map(); const pending = new Map(); const registrationLog = []; let nextCallId = 0; const abortError = () => new DOMException("Registration aborted", "AbortError"); const modelContext = { async registerTool(tool, options = {}) { await Promise.resolve(); if (options.signal?.aborted) throw abortError(); if (registered.has(tool.name)) { throw new DOMException(`Tool ${tool.name} is already registered`, "InvalidStateError"); } registered.set(tool.name, tool); registrationLog.push(tool.name); options.signal?.addEventListener( "abort", () => { if (registered.get(tool.name) === tool) registered.delete(tool.name); }, { once: true }, ); }, }; Object.defineProperty(document, "modelContext", { configurable: true, value: modelContext, }); Object.defineProperty(window, "__webMcpHarness", { configurable: true, value: { names: () => [...registered.keys()].sort(), registrationLog: () => [...registrationLog], definitions: () => [...registered.values()].map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema ?? null, annotations: tool.annotations ?? null, })), startCall(name, input) { const tool = registered.get(name); if (!tool) throw new Error(`Tool ${name} is not registered`); const callId = ++nextCallId; const controller = new AbortController(); pending.set(callId, Promise.resolve(tool.execute(input, { signal: controller.signal }))); return callId; }, async awaitCall(callId) { const promise = pending.get(callId); if (!promise) throw new Error(`Unknown call ${callId}`); try { return await promise; } finally { pending.delete(callId); } }, async call(name, input) { const callId = this.startCall(name, input); return this.awaitCall(callId); }, }, }); } async function callTool(page, name, input = {}) { return page.evaluate( ({ toolName, toolInput }) => window.__webMcpHarness.call(toolName, toolInput), { toolName: name, toolInput: input }, ); } async function startToolCall(page, name, input) { return page.evaluate( ({ toolName, toolInput }) => window.__webMcpHarness.startCall(toolName, toolInput), { toolName: name, toolInput: input }, ); } async function awaitToolCall(page, callId) { return page.evaluate((id) => window.__webMcpHarness.awaitCall(id), callId); } async function waitForToolSurface(page, expectedCount = TOOL_COUNT) { await page.waitForFunction( (count) => window.__webMcpHarness?.names().length === count, { timeout: NAVIGATION_TIMEOUT_MS }, expectedCount, ); // Registration is sequential. Require a stable settled count rather than a // transient prefix that happens to equal the expected number. await new Promise((resolveWait) => setTimeout(resolveWait, 100)); const names = await page.evaluate(() => window.__webMcpHarness.names()); assert(names.length === expectedCount, `Expected ${expectedCount} settled tools, got ${names}`); return names; } // The tool may truthfully refuse while the preview is replacing its document. // fallow-ignore-next-line complexity async function waitForReadyLook(page) { const deadline = Date.now() + NAVIGATION_TIMEOUT_MS; while (Date.now() < deadline) { const result = await callTool(page, "studio_look", { limit: 200 }); if (result.ok && result.sceneStatus === "ready" && result.elementCount > 0) return result; await new Promise((resolveWait) => setTimeout(resolveWait, 150)); } throw new Error("studio_look never observed a ready scene"); } async function waitForLens(page, phase) { await page.waitForSelector(`[data-topology-lens="${phase}"] [data-topology-target="true"]`, { visible: true, timeout: NAVIGATION_TIMEOUT_MS, }); return page.evaluate((expectedPhase) => { const player = document.querySelector("hyperframes-player"); const frame = player?.shadowRoot?.querySelector("iframe"); return { parentPhase: document .querySelector("[data-topology-lens]") ?.getAttribute("data-topology-lens"), iframeHasLens: Boolean(frame?.contentDocument?.querySelector("[data-topology-lens]")), targetText: frame?.contentDocument?.querySelector("#duplicate-headline")?.textContent?.trim(), expectedPhase, }; }, phase); } async function savePageScreenshot(page, name) { await page.screenshot({ path: join(EVIDENCE_DIR, name), fullPage: false }); } async function saveAnimatedPageScreenshot(page, name) { // Capture after the motion has become legible, not on the first rendered frame. await new Promise((resolveWait) => setTimeout(resolveWait, 96)); await savePageScreenshot(page, name); } async function savePreviewScreenshot(page, name) { const handle = await page.evaluateHandle(() => { const player = document.querySelector("hyperframes-player"); return player?.shadowRoot?.querySelector("iframe") ?? null; }); const element = handle.asElement(); assert(element, "Could not find the Studio preview iframe"); await element.screenshot({ path: join(EVIDENCE_DIR, name) }); await handle.dispose(); } async function capturePreviewHash(page) { const handle = await page.evaluateHandle(() => { const player = document.querySelector("hyperframes-player"); return player?.shadowRoot?.querySelector("iframe") ?? null; }); const element = handle.asElement(); assert(element, "Could not find the Studio preview iframe"); const bytes = await element.screenshot({ type: "png" }); await handle.dispose(); return sha256(bytes); } async function waitForPreviewHashChange(page, previousHash) { const deadline = Date.now() + NAVIGATION_TIMEOUT_MS; while (Date.now() < deadline) { const currentHash = await capturePreviewHash(page); if (currentHash !== previousHash) return currentHash; await new Promise((resolveWait) => setTimeout(resolveWait, 40)); } throw new Error("Open Studio preview pixels stayed stale while the write was pending"); } async function saveImageEvidenceFromBrowser(url, evidenceName, contentType) { assert(browser, "Browser is unavailable for image evidence"); const evidencePage = await browser.newPage(); try { await evidencePage.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 }); await evidencePage.goto(url, { waitUntil: "networkidle0", timeout: RENDER_TIMEOUT_MS, }); const image = await evidencePage.waitForSelector("img", { visible: true, timeout: RENDER_TIMEOUT_MS, }); assert(image, `Browser did not render image evidence for ${url}`); const outputPath = join(EVIDENCE_DIR, evidenceName); if (contentType.includes("jpeg")) { await image.screenshot({ path: outputPath, type: "jpeg", quality: 92 }); } else { await image.screenshot({ path: outputPath, type: "png" }); } } finally { await evidencePage.close(); } } async function fetchImage(url, evidenceName, expectedContentType = null) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), RENDER_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal }); assert(response.ok, `Image request failed (${response.status}) for ${url}`); const contentType = response.headers.get("content-type") ?? ""; assert(contentType.startsWith("image/"), `Expected image bytes, got ${contentType} for ${url}`); if (expectedContentType) { assert( contentType.includes(expectedContentType), `Expected ${expectedContentType}, got ${contentType} for ${url}`, ); } const bytes = Buffer.from(await response.arrayBuffer()); assert(bytes.length > 1_000, `Image evidence was unexpectedly small (${bytes.length} bytes)`); await saveImageEvidenceFromBrowser(url, evidenceName, contentType); return { contentType, sha256: sha256(bytes), size: bytes.length }; } finally { clearTimeout(timeout); } } async function findMountedThumbnailUrl(page, sourceFile, previous = null) { await page.waitForFunction( ({ source, old }) => [...document.querySelectorAll('#sidebar-panel-compositions img[src*="/thumbnail/"]')].some( (image) => image.src.includes(source) && (!old || image.src !== old), ), { timeout: RENDER_TIMEOUT_MS }, { source: sourceFile, old: previous }, ); return page.evaluate((source) => { const image = [ ...document.querySelectorAll('#sidebar-panel-compositions img[src*="/thumbnail/"]'), ].find((candidate) => candidate.src.includes(source)); return image?.src ?? null; }, sourceFile); } function targetForSource(look, sourceFile) { const candidates = look.elements.filter( (element) => element.sourceFile === sourceFile && element.handle.includes("duplicate-headline"), ); assert( candidates.length === 1, `Expected one duplicate headline in ${sourceFile}, got ${candidates.length}`, ); return candidates[0]; } // An agent follows the refusal hint with a fresh look, but never retries forever. // fallow-ignore-next-line complexity async function selectWithBoundedReacquire(page, initialTarget, sourceFile) { let target = initialTarget; for (let attempt = 0; attempt < 3; attempt += 1) { const selection = await callTool(page, "studio_select", { handle: target.handle }); if (selection.ok) return { selection, target }; const targetChanged = selection.kind === "invalid" && selection.reason === "the target changed while it was resolving"; if (!targetChanged || attempt === 2) { throw new Error( `studio_select failed after ${attempt + 1} attempt(s): ${JSON.stringify(selection)}`, ); } target = targetForSource(await waitForReadyLook(page), sourceFile); } throw new Error("unreachable selection retry state"); } // Linear browser acceptance scenario. Splitting it would hide the causal chain // between the write receipt, source bytes, overlay, thumbnail, frame, and reload. // fallow-ignore-next-line complexity async function runBrowserProof(page) { const mutationResponses = []; await page.evaluateOnNewDocument(installModelContextHarness, false); await page.setRequestInterception(true); page.on("request", (request) => { if ( request.method() === "POST" && (request.url().includes("/file-mutations/") || request.url().includes("/gsap-mutations/")) ) { setTimeout(() => void request.continue(), 340); return; } void request.continue(); }); page.on("response", (response) => { if (!response.url().includes("/file-mutations/patch-element/")) return; void response .json() .then((body) => mutationResponses.push({ status: response.status(), body })) .catch(() => undefined); }); await page.goto(`${ORIGIN}/#project/${PROJECT_ID}`, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS, }); const registeredTools = await waitForToolSurface(page); const definitions = await page.evaluate(() => window.__webMcpHarness.definitions()); const writeTools = definitions.filter( (definition) => definition.name.startsWith("studio_") && [ "studio_set_text", "studio_set_style", "studio_transform", "studio_add_animation", "studio_update_animation", "studio_add_keyframe", "studio_delete_animation", ].includes(definition.name), ); for (const tool of writeTools) { assert(tool.inputSchema?.required?.includes("handle"), `${tool.name} does not require handle`); } const lookBefore = await waitForReadyLook(page); let left = targetForSource(lookBefore, "compositions/left-card.html"); const right = targetForSource(lookBefore, "compositions/right-card.html"); assert(left.handle !== right.handle, "Duplicate authored ids produced the same handle"); assert( left.parentHandle && right.parentHandle && left.depth > 0 && right.depth > 0, `Nested targets did not report source hierarchy: ${JSON.stringify([left, right])}`, ); const selected = await selectWithBoundedReacquire(page, left, "compositions/left-card.html"); left = selected.target; const { selection } = selected; assert( selection.ok && selection.handle === left.handle, `studio_select did not select the left handle: ${JSON.stringify(selection)}`, ); assert( selection.box.width > 1 && selection.box.height > 1, `studio_select returned inert geometry: ${JSON.stringify(selection)}`, ); const selectedTarget = await page.waitForSelector('[data-dom-edit-selection-box="true"]', { visible: true, timeout: NAVIGATION_TIMEOUT_MS, }); assert(selectedTarget, "studio_select did not expose the agent target in Studio"); await savePageScreenshot(page, "00-agent-target-selected.png"); const thumbnailBeforeUrl = await findMountedThumbnailUrl(page, "left-card.html"); assert(thumbnailBeforeUrl, "No mounted left-card thumbnail URL was found"); const thumbnailBefore = await fetchImage( thumbnailBeforeUrl, "thumbnail-before.jpg", "image/jpeg", ); const frameBeforeResult = await callTool(page, "studio_frame", { time: 1, settleMs: 0 }); assert(frameBeforeResult.ok, `Initial studio_frame failed: ${JSON.stringify(frameBeforeResult)}`); const frameBefore = await fetchImage( new URL(frameBeforeResult.url, ORIGIN), "frame-before.png", "image/png", ); const leftSourcePath = join(globalThis.__webmcpProjectRoot, "compositions/left-card.html"); const rightSourcePath = join(globalThis.__webmcpProjectRoot, "compositions/right-card.html"); const leftSourceBefore = readFileSync(leftSourcePath, "utf8"); const rightSourceBefore = readFileSync(rightSourcePath, "utf8"); const previewBeforeText = await capturePreviewHash(page); const firstCall = await startToolCall(page, "studio_set_text", { handle: left.handle, text: "Agent saved this", }); const acquiring = await waitForLens(page, "acquiring"); assert(acquiring.parentPhase === "acquiring", "New target did not enter acquisition"); assert(!acquiring.iframeHasLens, "Topology Lens leaked into the preview iframe DOM"); const previewDuringText = await waitForPreviewHashChange(page, previewBeforeText); await saveAnimatedPageScreenshot(page, "01-new-target-acquiring.png"); await savePreviewScreenshot(page, "02-preview-during-action.png"); // Start the source renderer while acquisition is visible, but do not await // its comparatively slow Chrome capture before observing the short seal. const frameDuringActionCall = await startToolCall(page, "studio_frame", { time: 1, settleMs: 0 }); const firstReceipt = await awaitToolCall(page, firstCall); assert(firstReceipt.ok, `Text write failed: ${JSON.stringify(firstReceipt)}`); assert( ["saved", "verified"].includes(firstReceipt.stage), `Expected durable receipt, got ${JSON.stringify(firstReceipt)}; mutation=${JSON.stringify(mutationResponses.at(-1))}`, ); assert(firstReceipt.changed === true, "First write did not report a durable change"); assert( firstReceipt.target?.sourceFile === "compositions/left-card.html", `Receipt named the wrong source: ${JSON.stringify(firstReceipt.target)}`, ); const sealing = await waitForLens(page, "sealing"); assert(!sealing.iframeHasLens, "Persisted seal leaked into the preview iframe DOM"); await saveAnimatedPageScreenshot(page, "03-persisted-seal.png"); const frameDuringActionResult = await awaitToolCall(page, frameDuringActionCall); assert(frameDuringActionResult.ok, "studio_frame failed after starting during acquisition"); const frameDuringAction = await fetchImage( new URL(frameDuringActionResult.url, ORIGIN), "frame-during-action.png", "image/png", ); const repeatedCall = await startToolCall(page, "studio_set_style", { handle: left.handle, styles: { color: "#67e8f9" }, }); const localizing = await waitForLens(page, "localizing"); assert( localizing.parentPhase === "localizing", "Repeated target reacquired instead of localizing", ); await saveAnimatedPageScreenshot(page, "04-repeated-target-localizing.png"); const repeatedReceipt = await awaitToolCall(page, repeatedCall); assert( repeatedReceipt.ok && repeatedReceipt.stage === "saved", "Repeated style write was not saved", ); const newTargetCall = await startToolCall(page, "studio_set_style", { handle: right.handle, styles: { color: "#f5f3ff" }, }); const reacquiring = await waitForLens(page, "acquiring"); assert(reacquiring.parentPhase === "acquiring", "Changing targets did not restore acquisition"); await saveAnimatedPageScreenshot(page, "05-new-target-reacquiring.png"); const newTargetReceipt = await awaitToolCall(page, newTargetCall); assert( newTargetReceipt.ok && newTargetReceipt.changed === false, `Sibling no-op was not truthful: ${JSON.stringify(newTargetReceipt)}`, ); const previewBeforeTransform = await capturePreviewHash(page); const transformReceipt = await callTool(page, "studio_transform", { handle: left.handle, width: 280, height: 60, }); assert( transformReceipt.ok && transformReceipt.applied.includes("resize"), `Transform did not land: ${JSON.stringify(transformReceipt)}`, ); const previewAfterTransform = await capturePreviewHash(page); assert( previewAfterTransform !== previewBeforeTransform, "Open Studio preview pixels stayed stale after transform success", ); const animationTime = 0.25; const seekBeforeAnimation = await callTool(page, "studio_seek", { time: animationTime }); assert(seekBeforeAnimation.ok, "Could not seek before the live-animation proof"); const previewBeforeAnimation = await capturePreviewHash(page); const animationReceipt = await callTool(page, "studio_add_animation", { handle: left.handle, method: "from", }); assert( animationReceipt.ok, `Animation write failed: ${JSON.stringify(animationReceipt)}; mutation=${JSON.stringify(mutationResponses.at(-1))}`, ); const sourceAfterAnimation = readFileSync(leftSourcePath, "utf8"); assert( sourceAfterAnimation.includes("gsap"), "Animation tool returned before the GSAP source write was durable", ); const seekAfterAnimation = await callTool(page, "studio_seek", { time: animationTime }); assert(seekAfterAnimation.ok, "Could not seek after the live-animation proof"); const previewAfterAnimation = await capturePreviewHash(page); assert( previewAfterAnimation !== previewBeforeAnimation, "Open Studio preview pixels stayed stale after animation success", ); await savePreviewScreenshot(page, "05-live-animation-preview.png"); const leftSourceAfter = readFileSync(leftSourcePath, "utf8"); const rightSourceAfter = readFileSync(rightSourcePath, "utf8"); assert( leftSourceAfter.includes("Agent saved this"), "Left source file did not persist the text edit", ); assert(leftSourceAfter.includes("#67e8f9"), "Left source file did not persist the style edit"); assert(leftSourceAfter !== leftSourceBefore, "Target source stayed byte-for-byte unchanged"); assert(rightSourceAfter === rightSourceBefore, "The duplicate-id sibling source was modified"); const thumbnailAfterUrl = await findMountedThumbnailUrl( page, "left-card.html", thumbnailBeforeUrl, ); assert(thumbnailAfterUrl, "Mounted thumbnail URL did not advance after persistence"); const beforeRevision = new URL(thumbnailBeforeUrl).searchParams.get("revision"); const afterRevision = new URL(thumbnailAfterUrl).searchParams.get("revision"); assert(beforeRevision !== afterRevision, `Thumbnail revision stayed at ${beforeRevision}`); const thumbnailAfter = await fetchImage(thumbnailAfterUrl, "thumbnail-after.jpg", "image/jpeg"); assert( thumbnailAfter.sha256 !== thumbnailBefore.sha256, "Fresh thumbnail bytes matched stale bytes", ); const frameAfterResult = await callTool(page, "studio_frame", { time: 1, settleMs: 200 }); assert( frameAfterResult.ok, `Post-write studio_frame failed: ${JSON.stringify(frameAfterResult)}`, ); const frameAfter = await fetchImage( new URL(frameAfterResult.url, ORIGIN), "frame-after.png", "image/png", ); assert( frameAfter.sha256 !== frameBefore.sha256, "Post-write frame bytes matched pre-write frame", ); await savePageScreenshot(page, "06-studio-after.png"); const historyBeforeReload = (await callTool(page, "studio_look", { limit: 1 })).history; assert( historyBeforeReload?.canUndo && historyBeforeReload.undoLabel, "Write did not enter undo history", ); await page.reload({ waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS }); await waitForToolSurface(page); const lookAfterReload = await waitForReadyLook(page); const leftAfterReload = targetForSource(lookAfterReload, "compositions/left-card.html"); const inspectAfterReload = await callTool(page, "studio_inspect", { handle: leftAfterReload.handle, }); assert(inspectAfterReload.ok, "Reloaded target could not be inspected by its new handle"); assert(inspectAfterReload.text?.includes("Agent saved this"), "Reload lost the persisted text"); assert( inspectAfterReload.inlineStyles?.color === "#67e8f9" || inspectAfterReload.styles?.color?.includes("103, 232, 249"), `Reload lost the persisted style: ${JSON.stringify(inspectAfterReload.inlineStyles)}`, ); assert( lookAfterReload.history.undoLabel === historyBeforeReload.undoLabel, `Reload changed undo identity (${historyBeforeReload.undoLabel} -> ${lookAfterReload.history.undoLabel})`, ); await savePageScreenshot(page, "07-reload-persistence.png"); return { registeredTools, writeSchemasRequireHandle: writeTools.map((tool) => tool.name), duplicateTargets: [left, right], selection, receipts: { first: firstReceipt, repeated: repeatedReceipt, siblingNoOp: newTargetReceipt, transform: transformReceipt, animation: animationReceipt, }, lens: { acquiring, sealing, localizing, reacquiring }, source: { changed: "compositions/left-card.html", unchanged: "compositions/right-card.html", leftBefore: sha256(leftSourceBefore), leftAfter: sha256(leftSourceAfter), rightBefore: sha256(rightSourceBefore), rightAfter: sha256(rightSourceAfter), }, thumbnail: { beforeRevision, afterRevision, before: thumbnailBefore, after: thumbnailAfter, }, frame: { before: frameBefore, duringAction: frameDuringAction, after: frameAfter, liveTextPreview: { before: previewBeforeText, during: previewDuringText }, livePreview: { before: previewBeforeAnimation, after: previewAfterAnimation }, transformPreview: { before: previewBeforeTransform, after: previewAfterTransform }, }, reload: { text: inspectAfterReload.text, color: inspectAfterReload.inlineStyles?.color ?? inspectAfterReload.styles?.color, undoLabel: lookAfterReload.history.undoLabel, }, }; } async function proveDisabledPreference(browser) { const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 }); await page.evaluateOnNewDocument(installModelContextHarness, true); await page.goto(`${ORIGIN}/#project/${PROJECT_ID}`, { waitUntil: "domcontentloaded", timeout: NAVIGATION_TIMEOUT_MS, }); await page.waitForFunction(() => document.querySelector("hyperframes-player"), { timeout: NAVIGATION_TIMEOUT_MS, }); await new Promise((resolveWait) => setTimeout(resolveWait, 500)); const names = await page.evaluate(() => window.__webMcpHarness.names()); assert(names.length === 0, `Disabled preference registered tools: ${names.join(", ")}`); await page.close(); return { registeredTools: names }; } const chromeExecutable = resolveChromeExecutable(); if (!chromeExecutable) { console.error("No Chrome executable found. Set PUPPETEER_EXECUTABLE_PATH."); process.exit(2); } assert(Number.isInteger(PORT) && PORT > 0 && PORT < 65_536, `Invalid WEBMCP_E2E_PORT: ${PORT}`); mkdirSync(EVIDENCE_DIR, { recursive: true }); const tempRoot = mkdtempSync(join(tmpdir(), "hf-webmcp-edit-loop-")); const projectRoot = join(tempRoot, PROJECT_ID); const dataProjectsDir = join(STUDIO_DIR, "data/projects"); const projectLink = join(dataProjectsDir, PROJECT_ID); const serverLogs = []; let server = null; let browser = null; try { cpSync(FIXTURE_DIR, projectRoot, { recursive: true }); mkdirSync(dataProjectsDir, { recursive: true }); assert(!existsSync(projectLink), `${projectLink} already exists; refusing to replace it`); symlinkSync(projectRoot, projectLink, "dir"); globalThis.__webmcpProjectRoot = projectRoot; server = startStudioServer(serverLogs); await waitForServer(server, serverLogs); browser = await puppeteer.launch({ executablePath: chromeExecutable, headless: true, args: ["--disable-gpu", "--no-first-run", "--no-default-browser-check"], }); const page = await browser.newPage(); page.setDefaultTimeout(NAVIGATION_TIMEOUT_MS); await page.setViewport({ width: 1600, height: 1000, deviceScaleFactor: 1 }); const consoleErrors = []; const failedResponses = []; page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`)); page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(`console: ${message.text()}`); }); page.on("response", (response) => { if (response.status() >= 400) { failedResponses.push({ status: response.status(), url: response.url() }); } }); const proof = await runBrowserProof(page); const disabledPreference = await proveDisabledPreference(browser); const allowedFfmpegProbeMisses = failedResponses.filter((response) => { const url = new URL(response.url); return response.status === 404 && url.pathname === "/api/environment/ffmpeg"; }); assert( allowedFfmpegProbeMisses.length === failedResponses.length, `Unexpected failed browser response: ${JSON.stringify(failedResponses)}`, ); assert( consoleErrors.length === allowedFfmpegProbeMisses.length && consoleErrors.every((message) => message.includes("404 (Not Found)")), `Unexpected browser console error: ${JSON.stringify(consoleErrors)}`, ); const environment = { browser: await browser.version(), platform: platform(), architecture: arch(), viewport: { width: 1600, height: 1000, deviceScaleFactor: 1 }, origin: ORIGIN, headless: true, }; const report = { ok: true, environment, proof, disabledPreference, consoleErrors, failedResponses, allowedResourceWarning: { path: "/api/environment/ffmpeg", reason: "The standalone Vite server lacks the optional CLI probe; Studio treats it as unknown.", occurrences: allowedFfmpegProbeMisses.length, }, evidenceFiles: readdirSync(EVIDENCE_DIR).sort(), }; writeFileSync(join(EVIDENCE_DIR, "report.json"), `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify({ ok: true, environment, evidenceDir: EVIDENCE_DIR, proof }, null, 2)); } catch (error) { const failure = { ok: false, error: error instanceof Error ? (error.stack ?? error.message) : String(error), serverLogs: serverLogs.join("").slice(-12_000), }; console.error(failure.error); process.exitCode = 1; } finally { await browser?.close().catch(() => undefined); if (server && server.exitCode === null) { server.kill("SIGTERM"); await Promise.race([ new Promise((resolveExit) => server.once("exit", resolveExit)), new Promise((resolveWait) => setTimeout(resolveWait, 2_000)), ]); if (server.exitCode === null) server.kill("SIGKILL"); } if (existsSync(projectLink)) { const linkedRoot = realpathSync(projectLink); const expectedRoot = realpathSync(projectRoot); assert(linkedRoot === expectedRoot, `Refusing to remove unexpected project link ${linkedRoot}`); unlinkSync(projectLink); } rmSync(tempRoot, { recursive: true }); }