mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
* test(studio): edit accuracy evidence screenshots capture only the viewport * test(studio): edit accuracy screenshots only in the gate's confirmation re-runs * test(studio): keep the inStudio doc comment on inStudio
1169 lines
45 KiB
JavaScript
1169 lines
45 KiB
JavaScript
/** Drives one edit accuracy case in the built Studio and measures it. All distances are composition px. */
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import { classifyTweenPropertyGroup } from "../../../../parsers/src/gsapConstants.ts";
|
|
import { parseGsapScript } from "../../../../parsers/src/gsapParser.ts";
|
|
import { readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { COMPOSITION, FIXTURE_CDN, PLAYHEAD, localAsset } from "./grid.mjs";
|
|
import { traceBench } from "./trace.mjs";
|
|
import {
|
|
angleOf,
|
|
centre,
|
|
compositionMapper,
|
|
dist,
|
|
localToQuad,
|
|
mid,
|
|
normalizeAngle,
|
|
parseInset,
|
|
percentile,
|
|
quadDistance,
|
|
quadToLocal,
|
|
toPoints,
|
|
visibleQuad,
|
|
} from "./geometry.mjs";
|
|
import { frameSamplerScript, scoreTeleport, startFrames, stopFrames } from "./teleport.mjs";
|
|
import { terminateWindowsProcessTree } from "../../../../cli/src/utils/processTree.ts";
|
|
import { installWebMcpHost } from "../webmcp-host.mjs";
|
|
|
|
export const VIEWPORT = { width: 1600, height: 900 };
|
|
const STEPS = 20;
|
|
const MOVE_BY = [90, 60];
|
|
const RESIZE_BY = 60;
|
|
const ROTATE_BY = (25 * Math.PI) / 180;
|
|
const CROP_BY = 40;
|
|
const NUDGES = 5;
|
|
const ZOOM_SENSITIVITY = 0.007; // previewZoom.ts: one wheel unit scales zoom by exp(0.007)
|
|
|
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
const up = (port) =>
|
|
fetch(`http://127.0.0.1:${port}/api/projects`).then(
|
|
(r) => r.ok,
|
|
() => false,
|
|
);
|
|
const liveServers = new Set();
|
|
/** Signals the server's process group; a group that already exited is not an error. */
|
|
function signalGroup(child, signal) {
|
|
// Windows has no process groups, so taskkill /T ends the server and its children.
|
|
if (process.platform === "win32")
|
|
return void terminateWindowsProcessTree(child.pid).catch((error) => {
|
|
// taskkill exits 128 when the process is already gone.
|
|
if (!/status 128$/.test(error.message)) throw error;
|
|
});
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
} catch (error) {
|
|
if (error.code !== "ESRCH") throw error;
|
|
}
|
|
}
|
|
/** Signal-safe cleanup: servers run in their own process group so the CLI's children go with them. */
|
|
export function killServers() {
|
|
for (const child of liveServers) signalGroup(child, "SIGKILL");
|
|
}
|
|
|
|
const announcedPort = (log) => /http:\/\/localhost:(\d+)/.exec(log.join(""))?.[1];
|
|
|
|
/** The real node binary: a version manager's shim (Volta) finds node through HOME, which the server's env replaces. */
|
|
const nodeBinary = () =>
|
|
execFileSync("node", ["-p", "process.execPath"], { encoding: "utf8" }).trim();
|
|
|
|
/** Starts Studio at `port` or, when that is busy, the next free one the CLI binds; returns the port it serves. */
|
|
// fallow-ignore-next-line complexity
|
|
export async function startServer(cli, dir, port, log, home) {
|
|
const child = spawn(
|
|
nodeBinary(),
|
|
[cli, "preview", dir, "--port", String(port), "--no-open", "--foreground", "--force-new"],
|
|
{
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: true,
|
|
windowsHide: true,
|
|
// A per-case HOME keeps Studio's undo history inside the case's tmp dir.
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
HYPERFRAMES_NO_TELEMETRY: "1",
|
|
HYPERFRAMES_NO_UPDATE_CHECK: "1",
|
|
},
|
|
},
|
|
);
|
|
liveServers.add(child);
|
|
child.once("exit", () => liveServers.delete(child));
|
|
child.stdout.on("data", (d) => log.push(String(d)));
|
|
child.stderr.on("data", (d) => log.push(String(d)));
|
|
for (const deadline = Date.now() + 60_000; Date.now() < deadline; await sleep(50)) {
|
|
if (child.exitCode !== null)
|
|
throw new Error(`studio exited ${child.exitCode}: ${log.join("").slice(-500)}`);
|
|
const announced = Number(announcedPort(log));
|
|
if (announced && (await up(announced))) return { child, port: announced };
|
|
}
|
|
await stopServer(child);
|
|
throw new Error("studio did not start in 60s");
|
|
}
|
|
|
|
export async function stopServer(child) {
|
|
if (child.exitCode !== null) return;
|
|
const exited = new Promise((r) => child.once("exit", r));
|
|
signalGroup(child, "SIGTERM");
|
|
if (await Promise.race([exited.then(() => true), sleep(5000)])) return;
|
|
signalGroup(child, "SIGKILL");
|
|
await exited;
|
|
}
|
|
|
|
/** Runs in the top frame after installWebMcpHost("__editBench"): a frame-interval and long-task recorder. */
|
|
function instrumentPage() {
|
|
if (window.top !== window) return;
|
|
const rec = { on: false, frames: [], long: [] };
|
|
window.__editBench.rec = rec;
|
|
// The callback's own clock: Chrome stamps a late frame with the vsync it missed, which hides a stall.
|
|
const loop = () => {
|
|
if (rec.on) rec.frames.push(performance.now());
|
|
requestAnimationFrame(loop);
|
|
};
|
|
requestAnimationFrame(loop);
|
|
new PerformanceObserver((list) => {
|
|
if (rec.on) for (const e of list.getEntries()) rec.long.push(e.duration);
|
|
}).observe({ type: "longtask" });
|
|
}
|
|
|
|
export const nextFrame = (page, n = 1) =>
|
|
page.evaluate(
|
|
(count) =>
|
|
new Promise((r) => {
|
|
const tick = (left) => (left ? requestAnimationFrame(() => tick(left - 1)) : r());
|
|
tick(count);
|
|
}),
|
|
n,
|
|
);
|
|
|
|
export function readFiles(dir, files) {
|
|
return Object.fromEntries(files.map((f) => [f, readFileSync(join(dir, f), "utf8")]));
|
|
}
|
|
export const sameFiles = (a, b) => Object.keys(a).every((f) => a[f] === b[f]);
|
|
|
|
/** Waits until the files differ from `from` (or equal `want`, or just exist) and then hold still for 300 ms. */
|
|
// fallow-ignore-next-line complexity
|
|
export async function waitForFiles(ctx, { from, want, timeout = 5000 }) {
|
|
const deadline = Date.now() + timeout;
|
|
let last = readFiles(ctx.dir, ctx.files);
|
|
let stableSince = Date.now();
|
|
for (; Date.now() < deadline; await sleep(50)) {
|
|
const now = readFiles(ctx.dir, ctx.files);
|
|
if (!sameFiles(now, last)) [last, stableSince] = [now, Date.now()];
|
|
const reached = want ? sameFiles(now, want) : !from || !sameFiles(now, from);
|
|
if (reached && Date.now() - stableSince >= 300)
|
|
return { reached: true, files: now, at: stableSince };
|
|
}
|
|
return { reached: false, files: last };
|
|
}
|
|
|
|
const LOST_MS = 60_000;
|
|
|
|
/** The write an undo or redo key pressed at `since` causes, with its ms from the key; none after LOST_MS is lost. */
|
|
export async function timedWrite(ctx, from, since) {
|
|
const w = await waitForFiles(ctx, { from, timeout: LOST_MS });
|
|
return { ...w, ms: w.reached ? w.at - since : null };
|
|
}
|
|
|
|
/** "undo lost", "redo lost", or null when every write landed; a late one fails undo through its ms. */
|
|
export const saveFault = (writes) =>
|
|
writes.map(([name, w]) => (w.reached ? null : `${name} lost`)).find(Boolean) ?? null;
|
|
|
|
// fallow-ignore-next-line complexity
|
|
async function previewCandidate(frame, selector) {
|
|
const target = await frame.$(selector);
|
|
const host = target && (await frame.frameElement());
|
|
// Studio loads an edit in a same-size shadow iframe hidden with visibility; it is not what is on screen.
|
|
const shown =
|
|
host && (await host.evaluate((e) => e.checkVisibility({ visibilityProperty: true })));
|
|
const box = shown && (await host.boundingBox());
|
|
return box && { area: box.width * box.height, frame, target };
|
|
}
|
|
|
|
/** The largest visible preview iframe holding the target; a frame Studio detaches mid-scan is skipped. */
|
|
async function findTarget(page, selector = "#target") {
|
|
const previews = page.frames().filter((f) => f.url().includes("/preview"));
|
|
const found = await Promise.all(
|
|
previews.map((f) => previewCandidate(f, selector).catch(() => null)),
|
|
);
|
|
return found.filter(Boolean).reduce((a, b) => (!a || b.area > a.area ? b : a), null);
|
|
}
|
|
|
|
// The handle's own session: remote object ids do not resolve in any other CDP session.
|
|
async function contentQuad(handle) {
|
|
const { quads } = await handle.client.send("DOM.getContentQuads", {
|
|
objectId: handle.remoteObject().objectId,
|
|
});
|
|
if (!quads.length) throw new Error("element has no rendered box");
|
|
return toPoints(quads[0]);
|
|
}
|
|
|
|
async function findHandles(ctx) {
|
|
const found = await findTarget(ctx.page, ctx.selector);
|
|
if (!found) throw new Error(`${ctx.selector ?? "#target"} not found in preview`);
|
|
ctx.handles = { target: found.target, root: await found.frame.$('[data-composition-id="main"]') };
|
|
}
|
|
|
|
async function readQuads({ handles }) {
|
|
const read = await Promise.all([
|
|
contentQuad(handles.root),
|
|
contentQuad(handles.target),
|
|
handles.target.evaluate((e) => ({
|
|
width: e.offsetWidth,
|
|
height: e.offsetHeight,
|
|
clip: getComputedStyle(e).clipPath,
|
|
})),
|
|
]);
|
|
const shown = await handles.target.evaluate((e) => {
|
|
const host = e.ownerDocument.defaultView?.frameElement;
|
|
return !host || host.checkVisibility({ visibilityProperty: true });
|
|
});
|
|
if (!shown) throw new Error("the measured preview was retired");
|
|
return read;
|
|
}
|
|
|
|
/** The target's rendered quad, visible (cropped) quad and the screen/composition mapping, from CDP quads. */
|
|
export async function measure(ctx) {
|
|
// Studio can swap the preview into a fresh iframe; a cached handle then reads a hidden copy, so find it again.
|
|
let read = null;
|
|
for (let attempt = 0; !read; attempt++) {
|
|
read = await (ctx.handles ? readQuads(ctx) : Promise.reject(new Error("no handles"))).catch(
|
|
async (error) => {
|
|
if (attempt === 5) throw error;
|
|
await sleep(100);
|
|
await findHandles(ctx).catch(() => undefined);
|
|
return null;
|
|
},
|
|
);
|
|
}
|
|
const [rootQuad, targetQuad, box] = read;
|
|
const map = compositionMapper(rootQuad, COMPOSITION);
|
|
const quad = targetQuad.map(map.toComp);
|
|
const size = { width: box.width, height: box.height };
|
|
return { map, quad, size, visible: visibleQuad(quad, size, parseInset(box.clip)) };
|
|
}
|
|
|
|
// Still means the same reads on STILL_FRAMES animation frames in a row with the page's network quiet.
|
|
// EDIT_ACCURACY_STILL_MS restores the old check: a fixed window, in ms, of unchanged reads with no network signal.
|
|
const STILL_MS = process.env.EDIT_ACCURACY_STILL_MS
|
|
? Number(process.env.EDIT_ACCURACY_STILL_MS)
|
|
: null;
|
|
export const STILL_FRAMES = 3;
|
|
|
|
// The server's event stream stays open for the page's life; timeline thumbnails and the background lint
|
|
// behind the lint badge run on the server, can take seconds (lint over 15 s on a busy runner), and never
|
|
// change the preview. A blob: load is page memory (a thumbnail's image) and can end with no event at all.
|
|
export const backgroundRequest = (request) =>
|
|
request.resourceType() === "eventsource" ||
|
|
request.url().startsWith("blob:") ||
|
|
/\/api\/projects\/[^/]+\/(thumbnail\/|lint(\?|$))/.test(request.url());
|
|
|
|
/**
|
|
* The Studio page's network, for the stillness check: requests in flight, a counter bumped by any request or
|
|
* server event, and when the server last announced each project file. Studio's save requests, its reads of
|
|
* the files and the preview's loads all show here; a server `file-change` event can start another reload.
|
|
*/
|
|
async function watchNetwork(page) {
|
|
const net = { inflight: new Set(), activity: 0, announced: new Map(), since: Date.now() };
|
|
const done = (request) => {
|
|
net.inflight.delete(request);
|
|
net.activity++;
|
|
};
|
|
page.on("request", (request) => {
|
|
if (!backgroundRequest(request)) net.inflight.add(request);
|
|
net.activity++;
|
|
});
|
|
page.on("requestfinished", done);
|
|
page.on("requestfailed", done);
|
|
const cdp = await page.createCDPSession();
|
|
cdp.on("Network.eventSourceMessageReceived", ({ eventName, data }) => {
|
|
net.activity++;
|
|
if (eventName === "file-change") net.announced.set(JSON.parse(data).path, Date.now());
|
|
});
|
|
await cdp.send("Network.enable");
|
|
return net;
|
|
}
|
|
|
|
/** A file written after the server last announced it: its `file-change` event, and what Studio does on it, is still to come. */
|
|
const unannounced = ({ dir, files, net }) =>
|
|
files.some(
|
|
(f) => statSync(join(dir, f)).mtimeMs > Math.max(net.since, net.announced.get(f) ?? 0),
|
|
);
|
|
|
|
/**
|
|
* Whether the page is waiting on the network. A request whose frame is gone never finishes, so it is dropped,
|
|
* and counted as activity like a finished one: a window that began while it was in flight then restarts.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export function networkBusy(ctx) {
|
|
for (const request of ctx.net.inflight)
|
|
if (request.frame()?.detached !== false) {
|
|
ctx.net.inflight.delete(request);
|
|
ctx.net.activity++;
|
|
}
|
|
return { busy: ctx.net.inflight.size > 0 || unannounced(ctx), activity: ctx.net.activity };
|
|
}
|
|
|
|
// Studio reloads an edited preview in a shadow iframe (`_t` in its URL) and swaps it in when painted.
|
|
const previewFrames = (page) =>
|
|
page
|
|
.frames()
|
|
.map((f) => f.url())
|
|
.filter((u) => u.includes("/preview"))
|
|
.join(" ");
|
|
|
|
/** A hidden preview holding the target is a shadow reload not yet promoted: the visible frame is about to go stale. */
|
|
async function hiddenTarget(page, selector = "#target") {
|
|
for (const f of page.frames().filter((f) => f.url().includes("/preview"))) {
|
|
const host = await f.frameElement().catch(() => null);
|
|
const shown = await host?.evaluate((e) => e.checkVisibility({ visibilityProperty: true }));
|
|
if (shown === false && (await f.$(selector).catch(() => null))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Studio's own word that the preview is about to change: a reload asked for, loading, or an undo or redo in flight.
|
|
const previewChanging = (page) =>
|
|
Promise.resolve()
|
|
.then(() => page.evaluate(() => document.documentElement.hasAttribute("data-preview-changing")))
|
|
.catch(() => false);
|
|
|
|
// Keyframed cases only: waiting out the swap lands a later undo in the preview's burst of requests.
|
|
export const swapPending = async (ctx) =>
|
|
Boolean(ctx.keys) &&
|
|
((await previewChanging(ctx.page)) || (await hiddenTarget(ctx.page, ctx.selector)));
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const netMoved = (start, now) =>
|
|
Boolean(now.net?.busy) || now.net?.activity !== start.net?.activity;
|
|
|
|
/** Restart the stillness window: a pending swap, a changed set of preview frames, the box moved, or network. */
|
|
// fallow-ignore-next-line complexity
|
|
export const unsettledBy = (start, now) =>
|
|
start.pending ||
|
|
now.pending ||
|
|
netMoved(start, now) ||
|
|
now.frames !== start.frames ||
|
|
quadDistance(now.m.visible, start.m.visible) >= 0.01;
|
|
|
|
/** Whether a window that began at `start`, with `still` unchanged reads since, has held long enough. */
|
|
// fallow-ignore-next-line complexity
|
|
export const heldStill = (start, still, since, stillMs = STILL_MS) =>
|
|
!start.pending &&
|
|
!start.net?.busy &&
|
|
(stillMs === null ? still >= STILL_FRAMES : Date.now() - since >= stillMs);
|
|
|
|
/** Measures once the shown preview and the box have held still; Studio updates both after a save. */
|
|
// fallow-ignore-next-line complexity
|
|
export async function settled(ctx, timeout = 15_000) {
|
|
const deadline = Date.now() + timeout;
|
|
const watchNet = STILL_MS === null && ctx.net;
|
|
const read = async () => ({
|
|
m: await measure(ctx),
|
|
frames: previewFrames(ctx.page),
|
|
pending: await swapPending(ctx),
|
|
...(watchNet && { net: networkBusy(ctx) }),
|
|
});
|
|
let start = await read();
|
|
let now = start;
|
|
// Compared with the window's first read, so a drift too slow to show read to read still restarts it.
|
|
for (let [since, still] = [Date.now(), 0]; !heldStill(start, still, since); ) {
|
|
// A preview that never holds still is a Studio defect: the metrics it feeds fail, the rest still count.
|
|
if (Date.now() > deadline) return { ...now.m, unsettled: true };
|
|
await nextFrame(ctx.page);
|
|
now = await read();
|
|
if (unsettledBy(start, now)) [start, since, still] = [now, Date.now(), 0];
|
|
else still++;
|
|
}
|
|
return now.m;
|
|
}
|
|
|
|
const callTool = (page, name, args) =>
|
|
page.evaluate((n, a) => window.__editBench.call(n, a), name, args).catch(String);
|
|
|
|
/**
|
|
* Seeks to the playhead once Studio reports the composition's duration, and returns the landed seek. A seek
|
|
* before that is replayed by the runtime's timeline bind from near 0, which skips the first keyframe of a
|
|
* tween starting at the playhead: the preview then holds a box no fresh seek or render shows.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export async function seekBound(ctx) {
|
|
for (const deadline = Date.now() + 30_000; Date.now() < deadline; await sleep(50)) {
|
|
const look = await callTool(ctx.page, "studio_look", {});
|
|
if (!(look?.ok && look.duration > 0)) continue;
|
|
const seek = await callTool(ctx.page, "studio_seek", { time: ctx.playhead });
|
|
if (
|
|
seek?.ok &&
|
|
seek.duration > 0 &&
|
|
seek.playhead === ctx.playhead &&
|
|
(await findTarget(ctx.page))
|
|
)
|
|
return seek;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Ready once Studio's own seek tool reports the composition and the playhead landed. */
|
|
export async function openStudio(ctx) {
|
|
ctx.handles = null;
|
|
await ctx.page.waitForFunction(() => window.__editBench?.has("studio_seek"), { timeout: 90_000 });
|
|
if (!(await seekBound(ctx))) throw new Error("studio never reported a seekable composition");
|
|
return settled(ctx);
|
|
}
|
|
|
|
export async function seekTo(ctx, time) {
|
|
const seek = await ctx.page.evaluate(
|
|
(t) => window.__editBench.call("studio_seek", { time: t }),
|
|
time,
|
|
);
|
|
if (!seek?.ok || seek.playhead !== time)
|
|
throw new Error(`studio_seek ${time}: ${JSON.stringify(seek)}`);
|
|
return settled(ctx);
|
|
}
|
|
|
|
/** Each GSAP-animated property's value at every other keyframe time (and the box there), then back to the playhead. */
|
|
async function readKeyframes(ctx, keys, withBox = false) {
|
|
const at = {};
|
|
for (const time of keys.times) {
|
|
const m = await seekTo(ctx, time);
|
|
const values = await ctx.handles.target.evaluate((el, props) => {
|
|
const gsap = el.ownerDocument.defaultView.gsap;
|
|
return Object.fromEntries(props.map((p) => [p, Number.parseFloat(gsap.getProperty(el, p))]));
|
|
}, keys.props);
|
|
at[time] = { values, ...(withBox && { visible: m.visible }) };
|
|
}
|
|
await seekTo(ctx, ctx.playhead);
|
|
return at;
|
|
}
|
|
|
|
// GSAP's own numbers (px, deg, scale): an untouched keyframe reads back exactly.
|
|
const KEY_TOLERANCE = 0.01;
|
|
|
|
/** The largest change of an animated value at a keyframe the edit was not on; NaN (unreadable) fails. */
|
|
export function keyframeDrift(before, after) {
|
|
let worst = { diff: 0, time: null, prop: null };
|
|
for (const [time, b] of Object.entries(before))
|
|
for (const [prop, v] of Object.entries(b.values)) {
|
|
const diff = Math.abs(after[time].values[prop] - v);
|
|
if (!(diff <= worst.diff)) worst = { diff, time: Number(time), prop };
|
|
}
|
|
return { ...worst, pass: worst.diff <= KEY_TOLERANCE };
|
|
}
|
|
|
|
const scriptsIn = (html) =>
|
|
[...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map((m) => m[1]);
|
|
const animatesTarget = (anim) => anim.targetSelector === "#target" && !anim.global;
|
|
const keyframeProps = (anim) => (anim.keyframes?.keyframes ?? []).map((k) => k.properties);
|
|
const animatedProps = (anim) =>
|
|
[anim.properties, anim.fromProperties, ...keyframeProps(anim)].flatMap((props) =>
|
|
Object.keys(props ?? {}),
|
|
);
|
|
|
|
function targetGroups(files) {
|
|
const props = Object.values(files)
|
|
.flatMap(scriptsIn)
|
|
.flatMap((script) => parseGsapScript(script).animations.filter(animatesTarget))
|
|
.flatMap(animatedProps);
|
|
return new Set(props.map((prop) => classifyTweenPropertyGroup({ [prop]: 0 })).filter(Boolean));
|
|
}
|
|
|
|
/** Property groups the edit made the timeline animate on #target that it did not animate before. */
|
|
export function openedGroups(original, saved) {
|
|
const before = targetGroups(original);
|
|
return [...targetGroups(saved)].filter((group) => !before.has(group));
|
|
}
|
|
|
|
const declarations = (text = "") =>
|
|
Object.fromEntries(
|
|
text
|
|
.split(";")
|
|
.map((d) => d.split(":"))
|
|
.filter((d) => d.length > 1)
|
|
.map(([k, ...v]) => [k.trim(), v.join(":").trim()]),
|
|
);
|
|
const capture = (re, text = "") => re.exec(text)?.[1];
|
|
const targetCss = (html) => ({
|
|
rule: declarations(capture(/#target\s*\{([^}]*)\}/, html)),
|
|
inline: declarations(capture(/\bstyle="([^"]*)"/, capture(/(<[^>]*\bid="target"[^>]*>)/, html))),
|
|
});
|
|
|
|
const keyRule = (drift, opened) => ({ ...drift, opened, pass: drift.pass && opened.length === 0 });
|
|
|
|
/** Plain CSS the edit wrote for a property GSAP animates: it would override or fight the timeline. */
|
|
export function strayCss(original, saved, props) {
|
|
const changed = (a, b, file, where) =>
|
|
props
|
|
.filter((p) => a[p] !== b[p])
|
|
.map((p) => `${file} ${where} ${p}: ${a[p] ?? "-"} -> ${b[p] ?? "-"}`);
|
|
const stray = Object.keys(original).flatMap((file) => {
|
|
const [a, b] = [targetCss(original[file]), targetCss(saved[file])];
|
|
return [
|
|
...changed(a.rule, b.rule, file, "rule"),
|
|
...changed(a.inline, b.inline, file, "inline"),
|
|
];
|
|
});
|
|
return { pass: stray.length === 0, stray };
|
|
}
|
|
|
|
/** Puppeteer presses one key at a time: hold the modifiers around the last key. */
|
|
export async function chord(page, keys) {
|
|
const [key, ...mods] = keys.split("+").reverse();
|
|
for (const m of mods) await page.keyboard.down(m);
|
|
await page.keyboard.press(key);
|
|
for (const m of mods) await page.keyboard.up(m);
|
|
}
|
|
|
|
export async function blurPreview(page) {
|
|
await page.evaluate(() => {
|
|
if (document.activeElement?.tagName === "IFRAME") document.activeElement.blur();
|
|
});
|
|
}
|
|
|
|
/** Snapping deliberately pulls the box off the pointer, so the bench turns it off with Studio's own toggle. */
|
|
async function disableSnap(page) {
|
|
const title = await page.$eval('[aria-label="Toggle snap"]', (b) => b.title);
|
|
if (/enabled/i.test(title)) await page.click('[aria-label="Toggle snap"]');
|
|
const after = await page.$eval('[aria-label="Toggle snap"]', (b) => b.title);
|
|
if (!/disabled/i.test(after)) throw new Error(`snap toggle did not turn off: ${after}`);
|
|
}
|
|
|
|
const zoomOf = (page) =>
|
|
page.$eval('[data-testid="preview-zoom-stage"]', (e) => {
|
|
const m = /scale\(([\d.]+)\)/.exec(e.style.transform);
|
|
return m ? Number(m[1]) * 100 : 100;
|
|
});
|
|
|
|
/** Ctrl+wheel over the target, as a person zooms; wheel units are solved exactly from the zoom law. */
|
|
async function setZoom(ctx, percent, anchor) {
|
|
if (percent === 100) return 100;
|
|
await ctx.page.mouse.move(anchor[0], anchor[1]);
|
|
await ctx.page.keyboard.down("Control");
|
|
let units = Math.log(percent / (await zoomOf(ctx.page))) / ZOOM_SENSITIVITY;
|
|
while (Math.abs(units) > 1e-9) {
|
|
const step = Math.max(-10, Math.min(10, units));
|
|
await ctx.page.mouse.wheel({ deltaY: -step });
|
|
units -= step;
|
|
}
|
|
await ctx.page.keyboard.up("Control");
|
|
await sleep(400);
|
|
const achieved = await zoomOf(ctx.page);
|
|
if (Math.abs(achieved - percent) > 0.5)
|
|
throw new Error(`zoom ${percent}% landed at ${achieved}%`);
|
|
return achieved;
|
|
}
|
|
|
|
const overlayRect = (page, selector) =>
|
|
page.$$eval(selector, (els) =>
|
|
els.map((e) => {
|
|
const r = e.getBoundingClientRect();
|
|
return {
|
|
x: r.left + r.width / 2,
|
|
y: r.top + r.height / 2,
|
|
w: r.width,
|
|
h: r.height,
|
|
label: e.getAttribute("aria-label"),
|
|
};
|
|
}),
|
|
);
|
|
|
|
export async function selectTarget(ctx, m) {
|
|
const c = m.map.toScreen(centre(m.visible));
|
|
const want = m.visible.map(m.map.toScreen);
|
|
const isSelected = async () => {
|
|
const [box] = await overlayRect(ctx.page, "[data-dom-edit-selection-box]");
|
|
if (!box) return false;
|
|
const xs = want.map((p) => p[0]);
|
|
const ys = want.map((p) => p[1]);
|
|
const w = Math.max(...xs) - Math.min(...xs);
|
|
const h = Math.max(...ys) - Math.min(...ys);
|
|
return Math.abs(box.w - w) < 4 && Math.abs(box.h - h) < 4 && dist([box.x, box.y], c) < 4;
|
|
};
|
|
const waitSelected = async () => {
|
|
for (const deadline = Date.now() + 2000; Date.now() < deadline; await sleep(100))
|
|
if (await isSelected()) return true;
|
|
return false;
|
|
};
|
|
await ctx.page.mouse.click(c[0], c[1]);
|
|
if (await waitSelected()) return;
|
|
// A nested composition's child takes a second click to enter the composition.
|
|
await ctx.page.mouse.click(c[0], c[1], { count: 2 });
|
|
if (!(await waitSelected())) {
|
|
const boxes = await overlayRect(ctx.page, "[data-dom-edit-selection-box]");
|
|
throw new Error(
|
|
`could not select #target at ${c.map(Math.round)}; selection ${JSON.stringify(boxes)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
async function handlePoint(ctx, m, gesture) {
|
|
if (gesture === "move" || gesture === "nudge") return m.map.toScreen(centre(m.visible));
|
|
if (gesture === "rotate") {
|
|
const [h] = await overlayRect(ctx.page, '[aria-label="Rotate selection"]');
|
|
if (!h) throw new Error("no rotate handle");
|
|
return [h.x, h.y];
|
|
}
|
|
if (gesture === "crop") {
|
|
const [h] = await overlayRect(ctx.page, '[aria-label="Crop right"]');
|
|
if (!h) throw new Error("no crop right handle");
|
|
return [h.x, h.y];
|
|
}
|
|
const corner = m.map.toScreen(m.visible[2]);
|
|
const dots = await overlayRect(ctx.page, "div.pointer-events-auto.absolute.h-4.w-4");
|
|
if (!dots.length) throw new Error("no resize handles");
|
|
const dot = dots.reduce((a, b) => (dist([a.x, a.y], corner) <= dist([b.x, b.y], corner) ? a : b));
|
|
return [dot.x, dot.y];
|
|
}
|
|
|
|
async function cropOutline(ctx, map) {
|
|
const h = await ctx.page.$("[data-dom-edit-crop-frame] > div.border-dashed");
|
|
if (!h) throw new Error("no crop outline");
|
|
return (await contentQuad(h)).map(map.toComp);
|
|
}
|
|
|
|
/** Holds every save request `ms` before it is sent, so a later press lands while it is still in flight. */
|
|
function slowSaves(ms) {
|
|
const send = window.fetch;
|
|
window.fetch = (url, init) =>
|
|
/\/file-mutations\/patch-|gsap-mutations/.test(String(url))
|
|
? new Promise((resolve) => setTimeout(resolve, ms)).then(() => send(url, init))
|
|
: send(url, init);
|
|
}
|
|
|
|
/** Screen path (one point per frame) and the per-frame tracking error for each pointer gesture. */
|
|
function plan(gesture, pre, pressComp) {
|
|
const at = (p) => pre.map.toScreen(p);
|
|
const steps = Array.from({ length: STEPS }, (_, i) => (i + 1) / STEPS);
|
|
const follow = (point) => ({
|
|
point,
|
|
error: (s, s0) =>
|
|
dist([s.p[0] - s0.p[0], s.p[1] - s0.p[1]], [s.c[0] - s0.c[0], s.c[1] - s0.c[1]]),
|
|
});
|
|
if (gesture === "move") {
|
|
const local = quadToLocal(pre.quad, { width: 1, height: 1 }, pressComp);
|
|
return {
|
|
path: steps.map((k) => at([pressComp[0] + MOVE_BY[0] * k, pressComp[1] + MOVE_BY[1] * k])),
|
|
...follow((m) => localToQuad(m.quad, { width: 1, height: 1 }, local)),
|
|
};
|
|
}
|
|
if (gesture === "resize") {
|
|
const c = centre(pre.visible);
|
|
const d = dist(pre.visible[2], c);
|
|
const u = [(pre.visible[2][0] - c[0]) / d, (pre.visible[2][1] - c[1]) / d];
|
|
return {
|
|
path: steps.map((k) =>
|
|
at([pressComp[0] + u[0] * RESIZE_BY * k, pressComp[1] + u[1] * RESIZE_BY * k]),
|
|
),
|
|
...follow((m) => m.visible[2]),
|
|
};
|
|
}
|
|
if (gesture === "crop") {
|
|
const n = [pre.quad[0][0] - pre.quad[1][0], pre.quad[0][1] - pre.quad[1][1]];
|
|
const len = Math.hypot(...n);
|
|
return {
|
|
path: steps.map((k) =>
|
|
at([pressComp[0] + (n[0] / len) * CROP_BY * k, pressComp[1] + (n[1] / len) * CROP_BY * k]),
|
|
),
|
|
...follow((m) => mid(m.outline[1], m.outline[2])),
|
|
};
|
|
}
|
|
const c = centre(pre.visible);
|
|
const r = dist(pressComp, c);
|
|
const a0 = Math.atan2(pressComp[1] - c[1], pressComp[0] - c[0]);
|
|
const angle = (p) => Math.atan2(p[1] - c[1], p[0] - c[0]);
|
|
return {
|
|
path: steps.map((k) =>
|
|
at([c[0] + r * Math.cos(a0 + ROTATE_BY * k), c[1] + r * Math.sin(a0 + ROTATE_BY * k)]),
|
|
),
|
|
point: (m) => [angleOf(m.quad), 0],
|
|
error: (s, s0) => Math.abs(normalizeAngle(s.p[0] - s0.p[0] - (angle(s.c) - angle(s0.c)))) * r,
|
|
};
|
|
}
|
|
|
|
// A press made while the preview reloads waits for it: Studio draws its box at the pointer meanwhile.
|
|
async function waitingQuad(page, map) {
|
|
const waiting = await page.$("[data-dom-edit-press-waiting]");
|
|
const quad = waiting && (await contentQuad(waiting)).map(map.toComp);
|
|
await waiting?.dispose();
|
|
return quad;
|
|
}
|
|
|
|
/** The box as the preview shows it: the waiting marker's quad while a press waits for its replay, else the element. */
|
|
export async function measureShown(ctx, gesture, read = measure, quadOf = contentQuad) {
|
|
for (;;) {
|
|
const waiting = await ctx.page.$("[data-dom-edit-press-waiting]");
|
|
try {
|
|
const m = await read(ctx);
|
|
if (gesture === "crop") m.outline = await cropOutline(ctx, m.map);
|
|
const quad = waiting && (await quadOf(waiting)).map(m.map.toComp);
|
|
// Replay removes the marker before moving the element. Do not combine a pre-replay
|
|
// element read with a post-replay marker read into a position nobody displayed.
|
|
if (
|
|
waiting &&
|
|
!(await waiting.evaluate((e) => e.hasAttribute("data-dom-edit-press-waiting")))
|
|
)
|
|
continue;
|
|
return { m: quad ? { ...m, quad, visible: quad } : m, actual: m.visible };
|
|
} finally {
|
|
await waiting?.dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function sample(ctx, gesture, point, pointerScreen) {
|
|
const { m, actual } = await measureShown(ctx, gesture);
|
|
return { m, actual, p: point(m), c: m.map.toComp(pointerScreen) };
|
|
}
|
|
|
|
const TRACE_CATEGORIES = ["toplevel", "devtools.timeline", "blink.user_timing"];
|
|
const TRACE_MARK = "edit-bench-end";
|
|
|
|
/** Frame stamps plus a main-thread trace of the drag; the end mark ties performance.now() to trace time. */
|
|
export async function recording(page, on) {
|
|
if (on) await page.tracing.start({ categories: TRACE_CATEGORIES });
|
|
const rec = await page.evaluate(
|
|
(flag, mark) => {
|
|
const rec = window.__editBench.rec;
|
|
if (flag) [rec.frames, rec.long, rec.on] = [[], [], true];
|
|
else rec.on = false;
|
|
return {
|
|
frames: rec.frames,
|
|
long: rec.long,
|
|
mark: flag ? null : performance.mark(mark).startTime,
|
|
};
|
|
},
|
|
on,
|
|
TRACE_MARK,
|
|
);
|
|
if (on) return rec;
|
|
const trace = new TextDecoder().decode(await page.tracing.stop());
|
|
return { ...rec, trace: JSON.parse(trace).traceEvents };
|
|
}
|
|
|
|
const isRunTask = (e) =>
|
|
e.ph === "X" && (e.name === "RunTask" || e.name === "ThreadControllerImpl::RunTask");
|
|
|
|
/** Outermost tasks on one thread; nested RunTask events sit inside them. */
|
|
function topLevelTasks(trace, { pid, tid }) {
|
|
const runs = trace
|
|
.filter((e) => e.pid === pid && e.tid === tid && isRunTask(e))
|
|
.sort((a, b) => a.ts - b.ts || b.dur - a.dur);
|
|
const tops = [];
|
|
let end = -Infinity;
|
|
for (const e of runs) {
|
|
if (e.ts < end) continue;
|
|
tops.push(e);
|
|
end = e.ts + e.dur;
|
|
}
|
|
return tops;
|
|
}
|
|
|
|
// Thread CPU time, spread evenly over the task, so a descheduled thread does not count; untimed tasks count wall time.
|
|
const cpuUs = (e, a, b) =>
|
|
(Math.max(0, Math.min(b, e.ts + e.dur) - Math.max(a, e.ts)) * (e.tdur ?? e.dur)) / (e.dur || 1);
|
|
|
|
/** Main-thread CPU ms inside each frame interval, on the thread that ran the end mark; null when unknown. */
|
|
function mainThreadPerFrame({ frames, mark, trace }) {
|
|
const anchor = trace.find((e) => e.name === TRACE_MARK && e.cat.includes("user_timing"));
|
|
// Without the mark the work is unknown, which fails smoothness alone.
|
|
if (!anchor) return { work: null, wallTimed: 0, unknown: "no end mark in the trace" };
|
|
const toTrace = (ms) => anchor.ts + (ms - mark) * 1000;
|
|
const [from, to] = [toTrace(frames[0]), toTrace(frames.at(-1))];
|
|
const tasks = topLevelTasks(trace, anchor);
|
|
const inFrames = (e) => e.ts < to && e.ts + e.dur > from;
|
|
const wallTimed = tasks.filter((e) => e.tdur === undefined && inFrames(e)).length;
|
|
const work = frames.slice(1).map((t, i) => {
|
|
const [a, b] = [toTrace(frames[i]), toTrace(t)];
|
|
return tasks.reduce((sum, e) => sum + cpuUs(e, a, b), 0) / 1000;
|
|
});
|
|
return { work, wallTimed };
|
|
}
|
|
|
|
const hundredth = (v) => Math.round(v * 100) / 100;
|
|
|
|
export function smoothness(rec) {
|
|
const intervals = rec.frames.slice(1).map((t, i) => t - rec.frames[i]);
|
|
const { work, wallTimed, unknown } = mainThreadPerFrame(rec);
|
|
return {
|
|
p95: percentile(intervals, 95),
|
|
frames: intervals.length,
|
|
longTasks: rec.long.length,
|
|
intervals: intervals.map(hundredth),
|
|
work: work && work.map(hundredth),
|
|
wallTimed,
|
|
...(unknown && { unknown }),
|
|
};
|
|
}
|
|
|
|
const CONTROL_PAGE = `data:text/html,<body style="margin:0;background:%23202020"><div id="box"
|
|
style="position:absolute;left:600px;top:300px;width:240px;height:160px;background:%23f0c020"></div>`;
|
|
|
|
// Reported, never gated, so it runs once per gesture per browser rather than once per case.
|
|
const controls = new WeakMap();
|
|
|
|
/** The case's drag schedule and per-frame reads on a blank page in the same Chrome: the machine's own frame drops. */
|
|
export function controlDrag(browser, gesture) {
|
|
const byGesture = controls.get(browser) ?? new Map();
|
|
controls.set(browser, byGesture);
|
|
if (!byGesture.has(gesture)) {
|
|
const measured = measureControl(browser, gesture);
|
|
// A failed control is not cached, so one transient failure does not fail every later case.
|
|
measured.catch(() => byGesture.get(gesture) === measured && byGesture.delete(gesture));
|
|
byGesture.set(gesture, measured);
|
|
}
|
|
return byGesture.get(gesture);
|
|
}
|
|
|
|
async function measureControl(browser, gesture) {
|
|
const context = await browser.createBrowserContext();
|
|
try {
|
|
const page = await context.newPage();
|
|
await page.setViewport(VIEWPORT);
|
|
await page.evaluateOnNewDocument(installWebMcpHost, "__editBench");
|
|
await page.evaluateOnNewDocument(instrumentPage);
|
|
// The real drags run the frame sampler, so the control pays its cost too.
|
|
await page.evaluateOnNewDocument(frameSamplerScript);
|
|
await page.goto(CONTROL_PAGE);
|
|
const box = await page.$("#box");
|
|
const ctx = { page, handles: { target: box, root: box } };
|
|
const read = () => readQuads(ctx);
|
|
if (gesture === "nudge") {
|
|
await recording(page, true);
|
|
for (let i = 0; i < NUDGES; i++) {
|
|
await page.keyboard.press("ArrowRight");
|
|
await nextFrame(page);
|
|
}
|
|
await nextFrame(page, 2);
|
|
return smoothness(await recording(page, false));
|
|
}
|
|
await page.mouse.move(700, 380);
|
|
await startFrames(page, "#box");
|
|
await page.mouse.down();
|
|
await nextFrame(page);
|
|
await read();
|
|
await recording(page, true);
|
|
for (let i = 1; i <= STEPS; i++) {
|
|
await page.mouse.move(700 + (MOVE_BY[0] * i) / STEPS, 380 + (MOVE_BY[1] * i) / STEPS);
|
|
await nextFrame(page);
|
|
await read();
|
|
}
|
|
const smooth = smoothness(await recording(page, false));
|
|
await page.mouse.up();
|
|
await stopFrames(page);
|
|
return smooth;
|
|
} finally {
|
|
await context.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/** The move Chromium resends at the last known point after a layout change: no button, capture kept. A CDP move
|
|
* with no button ends the capture instead, so it goes to the captured box (the mouse is pointer 1). */
|
|
async function strayMove(page, [x, y]) {
|
|
const sent = await page.evaluate(
|
|
([clientX, clientY]) =>
|
|
document.querySelector("[data-dom-edit-selection-box]")?.dispatchEvent(
|
|
new PointerEvent("pointermove", {
|
|
bubbles: true,
|
|
pointerId: 1,
|
|
pointerType: "mouse",
|
|
isPrimary: true,
|
|
buttons: 0,
|
|
clientX,
|
|
clientY,
|
|
}),
|
|
),
|
|
[x, y],
|
|
);
|
|
if (sent === undefined) throw new Error("no selection box to send the stray move to");
|
|
await nextFrame(page);
|
|
}
|
|
|
|
/** `route`, given the press point, replaces the gesture's straight path.
|
|
* `{ pause }` holds still; `{ stray }`: see strayMove. */
|
|
// fallow-ignore-next-line complexity
|
|
export async function pointerGesture(ctx, gesture, pre, route) {
|
|
const quad = await waitingQuad(ctx.page, pre.map);
|
|
if (quad) pre = { ...pre, quad, visible: quad };
|
|
const press = await handlePoint(ctx, pre, gesture);
|
|
const pressComp = pre.map.toComp(press);
|
|
const g = { ...plan(gesture, pre, pressComp), ...(route && { path: route(press) }) };
|
|
const hit = await ctx.page.evaluate(([x, y]) => {
|
|
const e = document.elementFromPoint(x, y);
|
|
return e
|
|
? `${e.tagName.toLowerCase()}${e.getAttribute("aria-label") ? `[${e.getAttribute("aria-label")}]` : ""}`
|
|
: null;
|
|
}, press);
|
|
await ctx.page.mouse.move(press[0], press[1]);
|
|
await startFrames(ctx.page, ctx.selector ?? "#target");
|
|
await nextFrame(ctx.page, 2);
|
|
traceBench("press", { gesture });
|
|
await ctx.page.mouse.down();
|
|
await nextFrame(ctx.page);
|
|
const s0 = await sample(ctx, gesture, g.point, press);
|
|
traceBench("pressed", { gesture, jump: pre && quadDistance(s0.m.visible, pre.visible) });
|
|
await recording(ctx.page, true);
|
|
const errors = [];
|
|
let last = s0;
|
|
for (const p of g.path) {
|
|
if (p.pause) {
|
|
await sleep(p.pause);
|
|
continue;
|
|
}
|
|
if (p.stray) {
|
|
await strayMove(ctx.page, p.stray);
|
|
continue;
|
|
}
|
|
await ctx.page.mouse.move(p[0], p[1]);
|
|
await nextFrame(ctx.page);
|
|
last = await sample(ctx, gesture, g.point, p);
|
|
errors.push(g.error(last, s0));
|
|
}
|
|
const rec = await recording(ctx.page, false);
|
|
const smooth = smoothness(rec);
|
|
traceBench("release", { gesture, worst: Math.max(...errors) });
|
|
await ctx.page.mouse.up();
|
|
const lastQuad = gesture === "crop" ? last.m.outline : last.m.visible;
|
|
return {
|
|
errors,
|
|
lastQuad,
|
|
lastMeasure: last.m,
|
|
pressJump: quadDistance(s0.m.visible, pre.visible),
|
|
smooth,
|
|
diag: {
|
|
hit,
|
|
actualAtRelease: last.actual,
|
|
shownAtRelease: lastQuad,
|
|
grabOffset: gesture === "rotate" ? 0 : dist(s0.p, s0.c),
|
|
errors: errors.map((e) => Math.round(e * 1000) / 1000),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function nudgeGesture(ctx, pre) {
|
|
await recording(ctx.page, true);
|
|
for (let i = 0; i < NUDGES; i++) {
|
|
await ctx.page.keyboard.press("ArrowRight");
|
|
await nextFrame(ctx.page);
|
|
}
|
|
await nextFrame(ctx.page, 2);
|
|
const smooth = smoothness(await recording(ctx.page, false));
|
|
const m = await measure(ctx);
|
|
const [a, b] = [centre(pre.visible), centre(m.visible)];
|
|
return {
|
|
errors: [dist([b[0] - a[0], b[1] - a[1]], [NUDGES, 0])],
|
|
lastQuad: m.visible,
|
|
pressJump: null,
|
|
smooth,
|
|
diag: {},
|
|
};
|
|
}
|
|
|
|
const blockedCdnUrls = new Set();
|
|
|
|
/** Serves the fixtures' CDN requests from the repo; any other CDN URL is blocked and named once. */
|
|
export async function serveFixtureAssetsLocally(page) {
|
|
const cdp = await page.createCDPSession();
|
|
cdp.on("Fetch.requestPaused", ({ requestId, request }) => {
|
|
const file = localAsset(request.url);
|
|
if (!file) {
|
|
if (!blockedCdnUrls.has(request.url)) console.warn(`edit bench: blocked ${request.url}`);
|
|
blockedCdnUrls.add(request.url);
|
|
cdp
|
|
.send("Fetch.failRequest", { requestId, errorReason: "BlockedByClient" })
|
|
.catch(() => undefined);
|
|
return;
|
|
}
|
|
// A request whose frame went away rejects; that must not end the run.
|
|
cdp
|
|
.send("Fetch.fulfillRequest", {
|
|
requestId,
|
|
responseCode: 200,
|
|
responseHeaders: [{ name: "Content-Type", value: "text/javascript" }],
|
|
body: readFileSync(file).toString("base64"),
|
|
})
|
|
.catch(() => undefined);
|
|
});
|
|
await cdp.send("Fetch.enable", { patterns: [{ urlPattern: `${FIXTURE_CDN}*` }] });
|
|
}
|
|
|
|
/**
|
|
* Takes evidence screenshots into `evidence.shots` only when `shots` is on (the gate's confirmation re-runs); an
|
|
* error screen is always kept. Studio fills the viewport; capturing beyond it makes Chrome resize the page.
|
|
*/
|
|
export function shooter(page, evidence, shots) {
|
|
evidence.shots = {};
|
|
return async (name) => {
|
|
if (!shots && name !== "error") return;
|
|
evidence.shots[name] = await page.screenshot({
|
|
type: "jpeg",
|
|
quality: 70,
|
|
captureBeyondViewport: false,
|
|
optimizeForSpeed: true,
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Studio open on the case in a fresh browser context, snapping off, at the case's zoom, target selected;
|
|
* `drive` measures the rest. A failure keeps a screenshot, and the context always closes.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export async function inStudio({ browser, spec, dir, files, url, evidence, shots = true }, drive) {
|
|
const context = await browser.createBrowserContext();
|
|
const page = await context.newPage();
|
|
await serveFixtureAssetsLocally(page);
|
|
const ctx = {
|
|
page,
|
|
dir,
|
|
files,
|
|
handles: null,
|
|
playhead: spec.playhead ?? PLAYHEAD,
|
|
keys: spec.keys,
|
|
net: await watchNetwork(page),
|
|
};
|
|
const consoleErrors = [];
|
|
page.on("pageerror", (e) => consoleErrors.push(e.message));
|
|
page.on(
|
|
"console",
|
|
(m) =>
|
|
m.text().startsWith("[hf-reload]") && traceBench("reload", { line: m.text().slice(0, 400) }),
|
|
);
|
|
const shoot = shooter(page, evidence, shots);
|
|
try {
|
|
await page.setViewport(VIEWPORT);
|
|
if (process.env.HF_WRITE_TRACE)
|
|
await page.evaluateOnNewDocument(() => localStorage.setItem("hf-reload-debug", "1"));
|
|
await page.evaluateOnNewDocument(installWebMcpHost, "__editBench");
|
|
await page.evaluateOnNewDocument(instrumentPage);
|
|
if (spec.slowSaves) await page.evaluateOnNewDocument(slowSaves, spec.slowSaves);
|
|
await page.evaluateOnNewDocument(frameSamplerScript);
|
|
await page.goto(url);
|
|
let pre = await openStudio(ctx);
|
|
await disableSnap(page);
|
|
const zoom = await setZoom(ctx, spec.zoom, pre.map.toScreen(centre(pre.visible)));
|
|
// The animated values at the other keyframes, read before anything is selected or edited.
|
|
const keysBefore = spec.keys && (await readKeyframes(ctx, spec.keys));
|
|
pre = await settled(ctx);
|
|
await selectTarget(ctx, pre);
|
|
pre = await settled(ctx);
|
|
return await drive({ ctx, page, pre, zoom, shoot, consoleErrors, keysBefore });
|
|
} catch (error) {
|
|
await shoot("error").catch(() => undefined);
|
|
throw error;
|
|
} finally {
|
|
await context.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/** One case, end to end, against a Studio already serving `dir`. */
|
|
export async function runCase(args) {
|
|
const control = await controlDrag(args.browser, args.spec.gesture);
|
|
return inStudio(args, (session) => measureCase(args, session, control));
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
async function measureCase(
|
|
{ spec, dir, files, evidence },
|
|
{ ctx, page, pre, zoom, shoot, consoleErrors, keysBefore },
|
|
control,
|
|
) {
|
|
const original = readFiles(dir, files);
|
|
|
|
const drive =
|
|
spec.gesture === "nudge"
|
|
? await nudgeGesture(ctx, pre)
|
|
: await pointerGesture(ctx, spec.gesture, pre);
|
|
const releasedAt = Date.now();
|
|
const save = await waitForFiles(ctx, {
|
|
from: original,
|
|
timeout: spec.gesture === "nudge" ? 6000 : 5000,
|
|
});
|
|
await nextFrame(page, 2);
|
|
await blurPreview(page);
|
|
await page.keyboard.press("Escape");
|
|
const committed = await settled(ctx);
|
|
const frames = await stopFrames(page);
|
|
await shoot("committed");
|
|
const committedFiles = readFiles(dir, files);
|
|
evidence.files = committedFiles;
|
|
const saved = !sameFiles(committedFiles, original);
|
|
|
|
// Undo and redo run before any reload, each timed from its key to its own write; redo waits for undo.
|
|
const landed = (from, since) =>
|
|
saved ? timedWrite(ctx, from, since) : { reached: true, files: from, ms: null };
|
|
let since = Date.now();
|
|
await chord(page, "Control+z");
|
|
const undo = await landed(committedFiles, since);
|
|
const undone = await settled(ctx);
|
|
await shoot("undone");
|
|
let [redo, redone] = [{ reached: false }, null];
|
|
if (undo.reached) {
|
|
await blurPreview(page);
|
|
since = Date.now();
|
|
await chord(page, "Control+Shift+z");
|
|
redo = await landed(undo.files, since);
|
|
redone = await settled(ctx);
|
|
}
|
|
// A late write must not land under the reload.
|
|
await waitForFiles(ctx, { timeout: 15_000 });
|
|
|
|
await page.reload();
|
|
const reloaded = await openStudio(ctx);
|
|
await shoot("reloaded");
|
|
// From the saved file: the other keyframes keep their values, and no animated property gets plain CSS.
|
|
const keysAfter = spec.keys && (await readKeyframes(ctx, spec.keys, true));
|
|
const quads = Object.fromEntries(
|
|
Object.entries({ pre, committed, undone, redone, reloaded }).filter(([, m]) => m),
|
|
);
|
|
const round = (m) => m.visible.map((p) => p.map((v) => Math.round(v * 100) / 100));
|
|
return {
|
|
zoom,
|
|
saved,
|
|
tracking: {
|
|
max: Math.max(...drive.errors),
|
|
p95: percentile(drive.errors, 95),
|
|
frames: drive.errors.length,
|
|
},
|
|
pressJump: drive.pressJump,
|
|
teleport: spec.gesture === "nudge" ? null : scoreTeleport(spec.gesture, frames[0] ?? []),
|
|
drop: quadDistance(drive.lastQuad, committed.visible),
|
|
// Also against the box the gesture left, so a write the file drops shows here and not only as drop.
|
|
reload: Math.max(
|
|
quadDistance(committed.visible, reloaded.visible),
|
|
quadDistance(drive.lastQuad, reloaded.visible),
|
|
),
|
|
undo: {
|
|
bytes: saved && undo.reached && sameFiles(undo.files, original),
|
|
box: quadDistance(undone.visible, pre.visible),
|
|
redoBytes: saved && redo.reached && sameFiles(redo.files, committedFiles),
|
|
redoBox: redone && quadDistance(redone.visible, committed.visible),
|
|
ms: undo.ms ?? null,
|
|
redoMs: redo.ms ?? null,
|
|
},
|
|
// From release (or the last nudge key) to the edit's file write.
|
|
saveMs: save.at ? save.at - releasedAt : null,
|
|
// A lost undo or redo write fails undo; a redo that was never sent is untested.
|
|
undoTimeout: saved
|
|
? saveFault([
|
|
["undo", undo],
|
|
["redo", redo],
|
|
])
|
|
: null,
|
|
smooth: { ...drive.smooth, control },
|
|
unsettled: Object.keys(quads).filter((k) => quads[k].unsettled),
|
|
reloaded,
|
|
...(spec.keys && {
|
|
keys: keyRule(
|
|
keyframeDrift(keysBefore, keysAfter),
|
|
openedGroups(original, readFiles(dir, files)),
|
|
),
|
|
css: strayCss(original, readFiles(dir, files), spec.keys.css),
|
|
keyRender: { time: spec.keys.render, visible: keysAfter[spec.keys.render].visible },
|
|
}),
|
|
diag: {
|
|
...drive.diag,
|
|
consoleErrors: consoleErrors.slice(0, 5),
|
|
quads: Object.fromEntries(Object.entries(quads).map(([k, m]) => [k, round(m)])),
|
|
},
|
|
};
|
|
}
|