85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
captureRegionCrop,
|
|
clampCropRegion,
|
|
installPageFunctionGuard,
|
|
padCropRegion,
|
|
parseZoomTarget,
|
|
resolveCliChromeGpuMode,
|
|
resolveCropRegion,
|
|
runFfmpegOnce,
|
|
seekCompositionTimeline,
|
|
type CompositionSeekPage,
|
|
} from "./captureCompositionFrame.js";
|
|
|
|
function tempDir(): string {
|
|
return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-"));
|
|
}
|
|
|
|
function fakeSeekPage() {
|
|
const evaluate = vi.fn(
|
|
async (
|
|
_pageFunction: Parameters<CompositionSeekPage["evaluate"]>[0],
|
|
_value?: number,
|
|
_fallbackToBridgeAndTimelines?: boolean,
|
|
): Promise<unknown> => undefined,
|
|
);
|
|
const waitForFunction = vi.fn(
|
|
async (_pageFunction: () => boolean, _options: { timeout: number }): Promise<unknown> =>
|
|
undefined,
|
|
);
|
|
const page: CompositionSeekPage = { evaluate, waitForFunction };
|
|
return { page, evaluate, waitForFunction };
|
|
}
|
|
|
|
function runBrowserSeek(evaluate: ReturnType<typeof fakeSeekPage>["evaluate"]): void {
|
|
const seekInBrowser = evaluate.mock.calls[0]?.[0];
|
|
if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function");
|
|
Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []);
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("seekCompositionTimeline", () => {
|
|
it("keeps the raced double-frame settle and adds a bounded font wait by default", async () => {
|
|
const { page, evaluate, waitForFunction } = fakeSeekPage();
|
|
|
|
await seekCompositionTimeline(page, 1.25);
|
|
|
|
expect(waitForFunction).not.toHaveBeenCalled();
|
|
expect(evaluate).toHaveBeenCalledTimes(3);
|
|
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false);
|
|
expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)");
|
|
// Post-seek font settle: a seek can reveal glyphs whose unicode-range
|
|
// subsets only start loading after the next layout (CJK snapshot reports).
|
|
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500);
|
|
});
|
|
|
|
it("waitForFontsMs: 0 disables the post-seek font wait", async () => {
|
|
const { page, evaluate } = fakeSeekPage();
|
|
|
|
await seekCompositionTimeline(page, 1.25, { waitForFontsMs: 0 });
|
|
|
|
expect(evaluate).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("prefers renderSeek so the runtime synchronizes clip visibility", async () => {
|
|
const { page, evaluate } = fakeSeekPage();
|
|
const renderSeek = vi.fn();
|
|
const bridgeSeek = vi.fn();
|
|
const playerSeek = vi.fn();
|
|
const timelineSeek = vi.fn();
|
|
vi.stubGlobal("window", {
|
|
__player: { renderSeek, seek: playerSeek },
|
|
__hf: { seek: bridgeSeek },
|
|
__timelines: { main: { seek: timelineSeek } },
|
|
});
|
|
|
|
await seekCompositionTimeline(page, 2.25);
|
|
runBrowserSeek(evaluate);
|
|
|
|
expect(renderSeek).toHaveBeenCalledWith(2.25);
|
|
expect(bridgeSeek).not.toHaveBeenCalled();
|
|
expect(playerSeek).not.toHaveBeenCalled();
|
|
expect(timelineSeek).not.toHaveBeenCalled();
|
|
});
|
|
|
|
function fakeBridgeOnlySeekPage() {
|
|
const { page, evaluate } = fakeSeekPage();
|
|
const bridgeSeek = vi.fn();
|
|
const tickerTick = vi.fn();
|
|
vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } });
|
|
return { page, evaluate, bridgeSeek, tickerTick };
|
|
}
|
|
|
|
it("keeps bridge and raw fallbacks disabled for default capture callers", async () => {
|
|
const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage();
|
|
|
|
await seekCompositionTimeline(page, 2.5);
|
|
runBrowserSeek(evaluate);
|
|
|
|
expect(bridgeSeek).not.toHaveBeenCalled();
|
|
expect(tickerTick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opts into the bridge before player and raw timeline fallbacks", async () => {
|
|
const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage();
|
|
|
|
await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true });
|
|
runBrowserSeek(evaluate);
|
|
|
|
expect(bridgeSeek).toHaveBeenCalledWith(2.5);
|
|
expect(tickerTick).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("opts into pausing and seeking raw timelines when no preferred target exists", async () => {
|
|
const { page, evaluate } = fakeSeekPage();
|
|
const pause = vi.fn();
|
|
const seek = vi.fn();
|
|
vi.stubGlobal("window", { __timelines: { main: { pause, seek } } });
|
|
|
|
await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true });
|
|
runBrowserSeek(evaluate);
|
|
|
|
expect(pause).toHaveBeenCalledOnce();
|
|
expect(seek).toHaveBeenCalledWith(1.75);
|
|
});
|
|
|
|
it("supports validate settling without adding an animation-frame or font wait", async () => {
|
|
vi.useFakeTimers();
|
|
const { page, evaluate, waitForFunction } = fakeSeekPage();
|
|
|
|
const pending = seekCompositionTimeline(page, 3, {
|
|
fallbackToBridgeAndTimelines: true,
|
|
waitForPreferredSeekTargetMs: 500,
|
|
animationFrameSettle: "none",
|
|
waitForFontsMs: 0,
|
|
settleMs: 150,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(150);
|
|
await pending;
|
|
|
|
expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 });
|
|
expect(evaluate).toHaveBeenCalledTimes(1);
|
|
expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true);
|
|
});
|
|
|
|
it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => {
|
|
vi.useFakeTimers();
|
|
const { page, evaluate } = fakeSeekPage();
|
|
|
|
const pending = seekCompositionTimeline(page, 4, {
|
|
fallbackToBridgeAndTimelines: true,
|
|
animationFrameSettle: "double",
|
|
waitForFontsMs: 500,
|
|
settleMs: 120,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
await pending;
|
|
|
|
expect(evaluate).toHaveBeenCalledTimes(3);
|
|
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true);
|
|
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
|
|
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500);
|
|
});
|
|
});
|
|
|
|
describe("resolveCliChromeGpuMode", () => {
|
|
it("preserves validate's software-only opt-in mapping", () => {
|
|
expect(resolveCliChromeGpuMode("software")).toBe("software");
|
|
expect(resolveCliChromeGpuMode("hardware")).toBe("hardware");
|
|
expect(resolveCliChromeGpuMode("auto")).toBe("hardware");
|
|
expect(resolveCliChromeGpuMode("")).toBe("hardware");
|
|
});
|
|
});
|
|
|
|
describe("screenshot Chrome arguments", () => {
|
|
it("leaves shared capture and layout on the engine's software default", () => {
|
|
const defaultScreenshotArgs =
|
|
/args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/;
|
|
const captureSource = readFileSync(
|
|
new URL("./captureCompositionFrame.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8");
|
|
|
|
// openSettledCompositionPage threads the caller's optional browserGpuMode;
|
|
// callers that omit it (snapshot, compare) fall through to the engine's
|
|
// software default for screenshot capture.
|
|
expect(captureSource).toMatch(
|
|
/args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\},\s*\{\s*browserGpuMode:\s*options\.browserGpuMode\s*\},?\s*\),/,
|
|
);
|
|
expect(layoutSource).toMatch(defaultScreenshotArgs);
|
|
});
|
|
});
|
|
|
|
describe("parseZoomTarget", () => {
|
|
it("parses four comma-separated numbers as an exact region", () => {
|
|
expect(parseZoomTarget("100,50,400,300")).toEqual({
|
|
kind: "region",
|
|
region: { x: 100, y: 50, width: 400, height: 300 },
|
|
});
|
|
});
|
|
|
|
it("treats anything else as a CSS selector", () => {
|
|
expect(parseZoomTarget("#headline")).toEqual({ kind: "selector", selector: "#headline" });
|
|
expect(parseZoomTarget(".card:nth-of-type(2)")).toEqual({
|
|
kind: "selector",
|
|
selector: ".card:nth-of-type(2)",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clampCropRegion / padCropRegion", () => {
|
|
it("clamps a region to the canvas bounds", () => {
|
|
expect(
|
|
clampCropRegion({ x: -10, y: -10, width: 50, height: 50 }, { width: 30, height: 30 }),
|
|
).toEqual({
|
|
x: 0,
|
|
y: 0,
|
|
width: 30,
|
|
height: 30,
|
|
});
|
|
});
|
|
|
|
it("pads a region on every side when it fits within the canvas", () => {
|
|
expect(
|
|
padCropRegion({ x: 500, y: 500, width: 100, height: 40 }, { width: 1920, height: 1080 }, 24),
|
|
).toEqual({ x: 476, y: 476, width: 148, height: 88 });
|
|
});
|
|
|
|
it("pads then clamps when padding would spill outside the canvas", () => {
|
|
expect(
|
|
padCropRegion({ x: 10, y: 10, width: 20, height: 20 }, { width: 200, height: 200 }, 24),
|
|
).toEqual({ x: 0, y: 0, width: 54, height: 54 });
|
|
});
|
|
});
|
|
|
|
describe("resolveCropRegion", () => {
|
|
it("resolves a selector to its bbox, padded 24px and clamped", async () => {
|
|
const page = { evaluate: vi.fn(async () => ({ x: 500, y: 500, width: 100, height: 40 })) };
|
|
|
|
const region = await resolveCropRegion(
|
|
page,
|
|
{ kind: "selector", selector: "#headline" },
|
|
{ width: 1920, height: 1080 },
|
|
);
|
|
|
|
expect(region).toEqual({ x: 476, y: 476, width: 148, height: 88 });
|
|
});
|
|
|
|
it("crops a region exactly, without padding, when it already fits the canvas", async () => {
|
|
const page = { evaluate: vi.fn() };
|
|
|
|
const region = await resolveCropRegion(
|
|
page,
|
|
{ kind: "region", region: { x: 100, y: 50, width: 400, height: 300 } },
|
|
{ width: 1920, height: 1080 },
|
|
);
|
|
|
|
expect(region).toEqual({ x: 100, y: 50, width: 400, height: 300 });
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws a clear, loud error when the selector matches nothing", async () => {
|
|
const page = { evaluate: vi.fn(async () => null) };
|
|
|
|
await expect(
|
|
resolveCropRegion(
|
|
page,
|
|
{ kind: "selector", selector: "#missing" },
|
|
{ width: 640, height: 360 },
|
|
),
|
|
).rejects.toThrow("--zoom selector matched no element: #missing");
|
|
});
|
|
|
|
it("returns null when the clamped region is a sliver (element animated off-canvas)", async () => {
|
|
// Element slid past the right edge: raw bbox is large, but clamping the
|
|
// padded region to the canvas leaves ~1px — a useless crop.
|
|
const page = { evaluate: vi.fn(async () => ({ x: 2500, y: 400, width: 600, height: 250 })) };
|
|
|
|
const region = await resolveCropRegion(
|
|
page,
|
|
{ kind: "selector", selector: "#gone-by-now" },
|
|
{ width: 1920, height: 1080 },
|
|
);
|
|
|
|
expect(region).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("captureRegionCrop", () => {
|
|
it("raises deviceScaleFactor for the clip shot, then restores the original viewport", async () => {
|
|
const original = { width: 1920, height: 1080, deviceScaleFactor: 1 };
|
|
const setViewport = vi.fn(async () => undefined);
|
|
const screenshot = vi.fn(async () => new Uint8Array([1, 2, 3]));
|
|
const page = { viewport: () => original, setViewport, screenshot };
|
|
const region = { x: 10, y: 20, width: 100, height: 50 };
|
|
|
|
const buffer = await captureRegionCrop(page, region, 3);
|
|
|
|
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 });
|
|
expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" });
|
|
expect(setViewport).toHaveBeenNthCalledWith(2, original);
|
|
expect(buffer).toBeInstanceOf(Buffer);
|
|
expect(Array.from(buffer)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("honors an explicit scale other than the default", async () => {
|
|
const original = { width: 800, height: 600 };
|
|
const setViewport = vi.fn(async () => undefined);
|
|
const screenshot = vi.fn(async () => new Uint8Array());
|
|
const page = { viewport: () => original, setViewport, screenshot };
|
|
|
|
await captureRegionCrop(page, { x: 0, y: 0, width: 10, height: 10 }, 2);
|
|
|
|
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 2 });
|
|
});
|
|
});
|
|
|
|
describe("runFfmpegOnce", () => {
|
|
it("returns the process exit code and collected stderr", async () => {
|
|
const dir = tempDir();
|
|
try {
|
|
const script = join(dir, "fail.cjs");
|
|
writeFileSync(script, 'process.stderr.write("ffmpeg failed"); process.exit(3);\n');
|
|
|
|
const result = await runFfmpegOnce(process.execPath, [script], 1000);
|
|
|
|
expect(result).toEqual({ code: 3, stderr: "ffmpeg failed", timedOut: false });
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("terminates the process when the timeout elapses", async () => {
|
|
const dir = tempDir();
|
|
try {
|
|
const script = join(dir, "hang.cjs");
|
|
writeFileSync(script, "setTimeout(() => {}, 10000);\n");
|
|
|
|
const result = await runFfmpegOnce(process.execPath, [script], 50);
|
|
|
|
expect(result.timedOut).toBe(true);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("installPageFunctionGuard", () => {
|
|
it("defines the keepNames __name shim in the page before any script runs", async () => {
|
|
const evaluateOnNewDocument = vi.fn(async (_source: string) => undefined);
|
|
|
|
await installPageFunctionGuard({ evaluateOnNewDocument });
|
|
|
|
expect(evaluateOnNewDocument).toHaveBeenCalledOnce();
|
|
const source = evaluateOnNewDocument.mock.calls[0]?.[0] ?? "";
|
|
expect(source).toContain("self.__name");
|
|
// The shim must be a no-op passthrough so wrapped functions stay callable.
|
|
const shim = new Function(`const self = {}; ${source}; return self.__name;`)() as (
|
|
fn: unknown,
|
|
) => unknown;
|
|
const marker = () => 42;
|
|
expect(shim(marker)).toBe(marker);
|
|
});
|
|
});
|