mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): make agent edits live and explicit (#3581)
This commit is contained in:
@@ -0,0 +1,836 @@
|
||||
#!/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 });
|
||||
}
|
||||
Reference in New Issue
Block a user