Files
hyperframes/packages/cli/src/utils/checkBrowser.test.ts
T
Miguel Ángel e64a22893a fix(studio): preserve alpha proxy playback (#2625)
* fix(studio): preserve alpha proxy playback

* test(cli): pin VP8 alpha proxy pre-resolution

* fix(studio): emit textarea field-sizing CSS

* test(studio): verify textarea CSS generation
2026-07-17 21:56:27 -04:00

609 lines
21 KiB
TypeScript

// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
openSettledCompositionPage,
type OpenSettledCompositionPageOptions,
} from "../capture/captureCompositionFrame.js";
import { DEFAULT_CHECK_OPTIONS, runAuditGrid } from "./checkPipeline.js";
import {
captureOverviewShot,
preResolveHostileMediaProxies,
runBrowserCheck,
} from "./checkBrowser.js";
import type { ProjectDir } from "./project.js";
const mocks = vi.hoisted(() => ({
bundleWithLocalizedFonts: vi.fn(async () => "<html></html>"),
serverClose: vi.fn(async () => undefined),
resolveProxy: vi.fn<(projectDir: string, absoluteSourcePath: string) => Promise<string>>(),
scanProjectMediaCodecMap: vi.fn<
(...args: unknown[]) => Promise<
Record<
string,
{
codecName: string;
browserHostile: boolean;
representativeMime: string | null;
hasAlpha: boolean;
}
>
>
>(async () => ({})),
}));
vi.mock("./bundleWithLocalizedFonts.js", () => ({
// Keep browser-check unit tests focused on the page driver and audit pipeline.
// The real helper cold-loads the producer/font graph, which is covered by its
// own tests and can exhaust this suite's timeout under Windows CI contention.
bundleWithLocalizedFonts: mocks.bundleWithLocalizedFonts,
}));
vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
// Partial mock: constants (AUDIT_SEEK_OPTIONS, DEFAULT_ZOOM_*) stay real so
// they remain single-sourced; only the browser-touching functions are faked.
...(await importOriginal<typeof import("../capture/captureCompositionFrame.js")>()),
openSettledCompositionPage: vi.fn(),
resolveCliChromeGpuMode: vi.fn(() => "hardware"),
seekCompositionTimeline: vi.fn(async () => undefined),
waitForPreferredSeekTarget: vi.fn(async () => undefined),
}));
vi.mock("../commands/validate.js", async (importOriginal) => ({
// Partial mock: shouldIgnoreRequestFailure stays real; the clip audit is
// faked so tests control its findings without loading real media.
...(await importOriginal<typeof import("../commands/validate.js")>()),
auditClipDurations: vi.fn(async () => [] as Array<{ level: "error" | "warning"; text: string }>),
}));
vi.mock("./staticProjectServer.js", () => ({
serveStaticProjectHtml: vi.fn(async () => ({
url: "http://127.0.0.1:3000",
close: mocks.serverClose,
})),
}));
// `preResolveHostileMediaProxies` reaches these two studio-server helpers via
vi.mock("@hyperframes/studio-server/media-codec-map", async (importOriginal) => ({
...(await importOriginal<typeof import("@hyperframes/studio-server/media-codec-map")>()),
scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap,
proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp8" : "h264"),
}));
vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
resolveProxy: mocks.resolveProxy,
}));
const PROJECT: ProjectDir = {
dir: "/project",
name: "project",
indexPath: "/project/index.html",
};
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.innerHTML = "";
Reflect.deleteProperty(window, "__hyperframesGeometryCandidates");
Reflect.deleteProperty(window, "__hyperframesLayoutAudit");
Reflect.deleteProperty(window, "__contrastAuditPrepare");
Reflect.deleteProperty(window, "__contrastAuditFinish");
Reflect.deleteProperty(window, "__contrastAuditRestores");
Reflect.deleteProperty(window, "__contrastAuditRestoreIfPending");
mocks.scanProjectMediaCodecMap.mockResolvedValue({});
});
function installSessionMock(page: ReturnType<typeof fakePage>): void {
const browser = Object.assign(Object.create(null), {
close: vi.fn(async () => undefined),
});
vi.mocked(openSettledCompositionPage).mockImplementation(
async (_html: string, _url: string, options: OpenSettledCompositionPageOptions) => {
await options.beforeNavigate?.(page);
return { page, browser, renderReadyTimedOut: false };
},
);
}
function mountCanvasFixture(inner = ""): void {
document.body.innerHTML = `
<div data-composition-id="main" data-duration="10" data-width="640" data-height="360">${inner}</div>
`;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 640 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 360 });
}
it("carries raw browser geometry through the page driver and pipeline", async () => {
vi.spyOn(Date, "now")
.mockReturnValueOnce(100)
.mockReturnValueOnce(160)
.mockReturnValueOnce(200)
.mockReturnValueOnce(240);
mountCanvasFixture(`
<section data-composition-file="scenes/hero.html">
<img id="hero-image" data-layout-name="hero" src="data:image/png;base64,AA==" />
</section>
`);
installRects();
const page = fakePage();
installSessionMock(page);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false, frameCheck: {} },
{ kind: "none" },
runAuditGrid,
);
expect(mocks.bundleWithLocalizedFonts).toHaveBeenCalledWith(PROJECT.dir);
expect(result.layoutIssues).toEqual([
expect.objectContaining({
code: "frame_out_of_frame",
severity: "warning",
selector: "#hero-image",
sourceFile: "scenes/hero.html",
dataAttributes: { "data-layout-name": "hero" },
bbox: { x: 600, y: 80, width: 200, height: 100 },
rect: { left: 600, top: 80, right: 800, bottom: 180, width: 200, height: 100 },
overflow: { right: 160 },
time: 5,
}),
]);
expect(result.timings).toEqual({ launchSettleMs: 60, seekLoopMs: 40, contrastMs: 0 });
expect(mocks.serverClose).toHaveBeenCalledOnce();
});
it("round-trips the browser script's raw contrast candidates back into finish", async () => {
// The U2 regression class: Node parses prepare's candidates for reporting,
// but must hand the UNTOUCHED objects back to __contrastAuditFinish — the
// page script samples pixels via its own bbox shape (w/h). A normalized
// candidate (width/height) makes every sample rect NaN and the audit
// silently reports zero checked elements as green.
vi.spyOn(Date, "now").mockReturnValue(100);
mountCanvasFixture(`
<div id="headline">Readable copy</div>
`);
const root = document.querySelector("[data-composition-id]");
const headline = document.querySelector("#headline");
if (!root || !headline) throw new Error("Contrast fixture failed to mount");
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40));
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255,255,255)",
fill: "",
backgroundColor: "rgba(0,0,0,0)",
backgroundImage: "none",
fontSize: "32px",
fontWeight: "700",
}) as unknown as CSSStyleDeclaration,
);
// happy-dom can't decode PNGs: stub Image (sync onload) and the canvas 2D
// context the way layout-audit.browser.test.ts's contrast harness does, so
// the REAL __contrastAuditFinish runs its sampling path end to end.
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 640;
naturalHeight = 360;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal("Image", MockImage);
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as {
mockReturnValue(value: CanvasRenderingContext2D): void;
};
getContextSpy.mockReturnValue({
drawImage() {},
getImageData() {
return { data: new Uint8ClampedArray(640 * 360 * 4).fill(255) };
},
} as unknown as CanvasRenderingContext2D);
const received: Array<Record<string, unknown>> = [];
const page = fakePage();
const injectScript = page.addScriptTag;
page.addScriptTag = vi.fn(async (arg: { content: string }) => {
await injectScript(arg);
const w = window as unknown as {
__contrastAuditFinish?: ((...args: unknown[]) => Promise<unknown>) & { wrapped?: boolean };
};
const finish = w.__contrastAuditFinish;
if (finish && !finish.wrapped) {
const wrapper = Object.assign(
async (...args: unknown[]) => {
const candidates = args[2];
if (Array.isArray(candidates)) {
received.push(...(candidates as Array<Record<string, unknown>>));
}
return finish(...args);
},
{ wrapped: true },
);
w.__contrastAuditFinish = wrapper;
}
});
page.screenshot = vi.fn(async () => "c3R1Yg==");
installSessionMock(page);
await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true },
{ kind: "none" },
runAuditGrid,
);
expect(received.length).toBeGreaterThan(0);
for (const candidate of received) {
const bbox = candidate.bbox as Record<string, unknown>;
// The page script's own shape (w/h), not Node's envelope shape (width/height):
expect(typeof bbox.w).toBe("number");
expect(typeof bbox.h).toBe("number");
}
});
it("captures check overview snapshots after contrast restores hidden text", async () => {
vi.spyOn(Date, "now").mockReturnValue(100);
mountCanvasFixture(`<div id="headline">Readable copy</div>`);
const root = document.querySelector("[data-composition-id]");
const headline = document.querySelector<HTMLElement>("#headline");
if (!root || !headline) throw new Error("Contrast fixture failed to mount");
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
vi.spyOn(headline, "getBoundingClientRect").mockReturnValue(new DOMRect(50, 50, 300, 40));
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(255,255,255)",
fill: "",
clipPath: "none",
fontSize: "32px",
fontWeight: "700",
}) as unknown as CSSStyleDeclaration,
);
const page = fakePage();
const injectScript = page.addScriptTag;
page.addScriptTag = vi.fn(async (arg: { content: string }) => {
await injectScript(arg);
const w = window as unknown as {
__contrastAuditFinish?: (...args: unknown[]) => Promise<unknown>;
__contrastAuditRestoreIfPending?: () => void;
};
if (w.__contrastAuditFinish) {
w.__contrastAuditFinish = async () => {
w.__contrastAuditRestoreIfPending?.();
return [];
};
}
});
page.screenshot = vi.fn(async () =>
headline.style.getPropertyValue("color") === "transparent"
? "text-hidden-base64"
: "text-visible-base64",
);
installSessionMock(page);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: true, snapshots: true },
{ kind: "none" },
runAuditGrid,
);
expect(page.screenshot).toHaveBeenCalledTimes(2);
expect(result.screenshots[0]?.pngBase64).toBe("text-visible-base64");
});
it("carries validate's clip-duration audit into the runtime findings", async () => {
vi.spyOn(Date, "now").mockReturnValue(100);
mountCanvasFixture();
const validateModule = await import("../commands/validate.js");
vi.mocked(validateModule.auditClipDurations).mockResolvedValue([
{
level: "warning",
text: "Audio is 22.10s but its slot (data-duration) is 30.00s — the slot is shortened to the media length when rendered.",
},
]);
const page = fakePage();
installSessionMock(page);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false },
{ kind: "none" },
runAuditGrid,
);
expect(result.runtimeFindings).toEqual([
expect.objectContaining({
code: "clip_media_fit",
severity: "warning",
message: expect.stringContaining("slot is shortened"),
}),
]);
});
it("surfaces the runtime's media-proxy-fallback console.info line as an info finding, ignoring unrelated info logs", async () => {
vi.spyOn(Date, "now").mockReturnValue(100);
mountCanvasFixture();
const page = fakePage();
const fallbackMessage = fakeConsoleMessage(
"info",
'[hyperframes] runtime_media_proxy_fallback: "assets/clip.mp4" uses a codec (hevc) this browser can\'t decode; ' +
"auto-swapped to an authoring proxy for this preview only. Render output is unaffected.",
);
const unrelatedInfo = fakeConsoleMessage("info", "[hyperframes] render runtime fps 30");
const authorInfo = fakeConsoleMessage("info", "debug runtime_media_proxy_probe");
page.on = vi.fn(
(event: string, handler: (message: ReturnType<typeof fakeConsoleMessage>) => void) => {
if (event === "console") {
handler(fallbackMessage);
handler(unrelatedInfo);
handler(authorInfo);
}
},
);
installSessionMock(page);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false },
{ kind: "none" },
runAuditGrid,
);
expect(result.runtimeFindings).toContainEqual(
expect.objectContaining({
code: "media_proxy_fallback",
severity: "info",
message: fallbackMessage.text(),
}),
);
expect(result.runtimeFindings.some((finding) => finding.message === unrelatedInfo.text())).toBe(
false,
);
expect(result.runtimeFindings.some((finding) => finding.message === authorInfo.text())).toBe(
false,
);
});
it("surfaces the runtime's media-proxy-unavailable console.info line as its own info finding", async () => {
vi.spyOn(Date, "now").mockReturnValue(100);
mountCanvasFixture();
const page = fakePage();
const unavailableMessage = fakeConsoleMessage(
"info",
'[hyperframes] runtime_media_proxy_unavailable: "https://cdn.example.com/video.mp4" (cross_origin): ' +
"video reports zero decodable width but its source is cross-origin; no local proxy can be served for it",
);
page.on = vi.fn(
(event: string, handler: (message: ReturnType<typeof fakeConsoleMessage>) => void) => {
if (event === "console") {
handler(unavailableMessage);
}
},
);
installSessionMock(page);
const result = await runBrowserCheck(
PROJECT,
{ ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false },
{ kind: "none" },
runAuditGrid,
);
expect(result.runtimeFindings).toContainEqual(
expect.objectContaining({
code: "media_proxy_unavailable",
severity: "info",
message: unavailableMessage.text(),
}),
);
});
describe("preResolveHostileMediaProxies", () => {
const dirs: string[] = [];
const mkProjectDir = (): string => {
const d = mkdtempSync(join(tmpdir(), "hf-check-preresolve-"));
dirs.push(d);
return d;
};
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
it("awaits resolveProxy for every browser-hostile entry before returning", async () => {
const projectDir = mkProjectDir();
mocks.scanProjectMediaCodecMap.mockResolvedValue({
"/clip.mp4": {
codecName: "hevc",
browserHostile: true,
representativeMime: null,
hasAlpha: false,
},
"/plain.mp4": {
codecName: "h264",
browserHostile: false,
representativeMime: null,
hasAlpha: false,
},
});
let resolveTranscode: (() => void) | undefined;
mocks.resolveProxy.mockImplementation(
() =>
new Promise<string>((resolveIt) => {
resolveTranscode = () => resolveIt("/cache/clip.mp4");
}),
);
let settled = false;
const pending = preResolveHostileMediaProxies(projectDir, "<html></html>").then(() => {
settled = true;
});
await Promise.resolve();
await Promise.resolve();
expect(mocks.resolveProxy).toHaveBeenCalledTimes(1);
expect(mocks.resolveProxy).toHaveBeenCalledWith(
projectDir,
join(projectDir, "clip.mp4"),
"h264",
);
expect(settled).toBe(false); // still waiting on the hostile entry's transcode
resolveTranscode?.();
await pending;
expect(settled).toBe(true);
});
it("does nothing (no scan, no resolveProxy) when autoProxy is off in hyperframes.json", async () => {
const projectDir = mkProjectDir();
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ media: { autoProxy: false } }),
);
await preResolveHostileMediaProxies(projectDir, "<html></html>");
expect(mocks.scanProjectMediaCodecMap).not.toHaveBeenCalled();
expect(mocks.resolveProxy).not.toHaveBeenCalled();
});
it("swallows a resolveProxy rejection instead of throwing", async () => {
const projectDir = mkProjectDir();
mocks.scanProjectMediaCodecMap.mockResolvedValue({
"/clip.mp4": {
codecName: "hevc",
browserHostile: true,
representativeMime: null,
hasAlpha: false,
},
});
mocks.resolveProxy.mockRejectedValue(new Error("ffmpeg exited with code 1"));
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
await expect(
preResolveHostileMediaProxies(projectDir, "<html></html>"),
).resolves.toBeUndefined();
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("media proxy pre-resolve: 0/1 ready, 1 failed"),
);
});
it("pre-resolves an alpha VP9 asset through the Chromium-compatible VP8 proxy", async () => {
const projectDir = mkProjectDir();
mocks.scanProjectMediaCodecMap.mockResolvedValue({
"/alpha.webm": {
codecName: "vp9",
browserHostile: true,
representativeMime: 'video/webm; codecs="vp09.00.10.08"',
hasAlpha: true,
},
});
await preResolveHostileMediaProxies(projectDir, "<html></html>");
expect(mocks.resolveProxy).toHaveBeenCalledTimes(1);
expect(mocks.resolveProxy).toHaveBeenCalledWith(
projectDir,
join(projectDir, "alpha.webm"),
"vp8",
);
});
it("is a no-op when the codec map has no hostile entries", async () => {
const projectDir = mkProjectDir();
mocks.scanProjectMediaCodecMap.mockResolvedValue({
"/plain.mp4": {
codecName: "h264",
browserHostile: false,
representativeMime: null,
hasAlpha: false,
},
});
await preResolveHostileMediaProxies(projectDir, "<html></html>");
expect(mocks.resolveProxy).not.toHaveBeenCalled();
});
});
describe("captureOverviewShot", () => {
it("injects the annotation overlay before the overview shot and removes it right after", async () => {
const calls: string[] = [];
const evaluate = vi.fn(async (fn: unknown, ...args: unknown[]) => {
calls.push("evaluate");
return typeof fn === "function" ? Reflect.apply(fn, undefined, args) : undefined;
});
const screenshot = vi.fn(async () => {
calls.push("screenshot");
return "annotated-base64";
});
const page = Object.assign(Object.create(null), { evaluate, screenshot });
const result = await captureOverviewShot(
page,
[{ label: "1 clipped_text", bbox: { x: 0, y: 0, width: 10, height: 10 } }],
"measurement-base64",
);
// inject overlay -> take the shot -> remove overlay, in that order —
// never present while any audit (which runs before this is called) collects.
expect(calls).toEqual(["evaluate", "screenshot", "evaluate"]);
expect(result).toBe("annotated-base64");
});
it("skips the overlay entirely and returns the plain screenshot when there's nothing to annotate", async () => {
const evaluate = vi.fn();
const screenshot = vi.fn();
const page = Object.assign(Object.create(null), { evaluate, screenshot });
const result = await captureOverviewShot(page, [], "measurement-base64");
expect(evaluate).not.toHaveBeenCalled();
expect(screenshot).not.toHaveBeenCalled();
expect(result).toBe("measurement-base64");
});
});
function installRects(): void {
const root = document.querySelector("[data-composition-id]");
const image = document.querySelector("#hero-image");
if (!root || !image) throw new Error("Geometry fixture failed to mount");
vi.spyOn(root, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 640, 360));
vi.spyOn(image, "getBoundingClientRect").mockReturnValue(new DOMRect(600, 80, 200, 100));
}
function fakeConsoleMessage(type: string, text: string) {
return {
type: () => type,
text: () => text,
location: () => ({ url: "http://127.0.0.1:3000/index.html", lineNumber: 1 }),
};
}
function fakePage() {
return Object.assign(Object.create(null), {
on: vi.fn(),
addScriptTag: vi.fn(async ({ content }: { content: string }) => {
window.eval(content);
}),
evaluate: vi.fn(async (callback: unknown, ...args: unknown[]) => {
if (typeof callback !== "function") throw new Error("Expected an evaluate callback");
return Reflect.apply(callback, window, args);
}),
});
}