diff --git a/packages/core/src/compiler/htmlDocument.test.ts b/packages/core/src/compiler/htmlDocument.test.ts index ba1ab3f3cdc..9925b25e705 100644 --- a/packages/core/src/compiler/htmlDocument.test.ts +++ b/packages/core/src/compiler/htmlDocument.test.ts @@ -141,12 +141,15 @@ describe("htmlDocument helpers", () => { expect(stripEmbeddedRuntimeScripts(html)).toBe(html); }); + const injectedTag = (code: string) => + ``; + it("injects head and body scripts without replacement-token interpolation", () => { const html = ""; const injected = injectScriptsIntoHtml(html, ["window.x = '$&';"], ["window.y = '$&';"]); - expect(injected).toContain("\n"); - expect(injected).toContain("\n"); + expect(injected).toContain(`${injectedTag("window.x = '$&';")}\n`); + expect(injected).toContain(`${injectedTag("window.y = '$&';")}\n`); }); it("injects early head scripts before authored head scripts", () => { @@ -175,8 +178,8 @@ describe("htmlDocument helpers", () => { const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], ["window.b = 1;"]); expect(injected.split(``)).toHaveLength(3); - expect(injected).toContain("\n"); - expect(injected).toContain("\n"); + expect(injected).toContain(`${injectedTag("window.h = 1;")}\n`); + expect(injected).toContain(`${injectedTag("window.b = 1;")}\n`); }); it("falls back to the document's own when is omitted", () => { @@ -184,7 +187,7 @@ describe("htmlDocument helpers", () => { const html = `

x

`; const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], []); - expect(injected).toContain(`\n`); + expect(injected).toContain(`${injectedTag("window.h = 1;")}\n`); }); it("injects at head start past a script that prints , and before without a head", () => { @@ -205,8 +208,8 @@ describe("htmlDocument helpers", () => { it("keeps indexes right after a character that lowercases to two (İ)", () => { const page = "İzmir

İstanbul

"; const injected = injectScriptsIntoHtml(page, ["a=1"], ["b=2"]); - expect(injected).toContain("\n"); - expect(injected).toContain("\n"); + expect(injected).toContain(`${injectedTag("a=1")}\n`); + expect(injected).toContain(`${injectedTag("b=2")}\n`); const stripped = stripEmbeddedRuntimeScripts( '

İİ

kept

', @@ -214,7 +217,7 @@ describe("htmlDocument helpers", () => { expect(stripped).toBe("

İİ

kept

"); const escaped = injectScriptsIntoHtml(page, ['x="İİ"'], []); - expect(escaped).toContain(''); + expect(escaped).toContain(injectedTag('x="İİ<\\/SCRIPT>"')); }); it("skips a script tag written inside an attribute value", () => { @@ -242,7 +245,7 @@ describe("htmlDocument helpers", () => { it("treats a quote as a value only after =, like the browser", () => { const page = "

don't

"; expect(injectScriptsIntoHtml(page, ["H"], [])).toContain( - "\n", + `${injectedTag("H")}\n`, ); for (const meta of [ diff --git a/packages/core/src/compiler/htmlDocument.ts b/packages/core/src/compiler/htmlDocument.ts index 13310beb4d2..d23e0d53e0d 100644 --- a/packages/core/src/compiler/htmlDocument.ts +++ b/packages/core/src/compiler/htmlDocument.ts @@ -201,7 +201,10 @@ function escapeCaseInsensitiveToken( } function inlineScriptTags(scripts: readonly string[]): string { - return scripts.map((source) => ``).join("\n"); + return scripts + .map((source, index) => `${source}\n//# sourceURL=hyperframes://injected/${index}`) + .map((source) => ``) + .join("\n"); } const RAW_TEXT_TAGS = ["script", "style", "title", "textarea"] as const; diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index 1e5a9a27642..06e102e96ce 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -69,6 +69,7 @@ export { deferScriptsUntilFonts, headStyleRuns, inlineScriptRuns, + isJavaScriptType, styleElementsFor, type CompositionStyle, type InlineScriptRun, diff --git a/packages/core/src/compiler/scriptRuns.ts b/packages/core/src/compiler/scriptRuns.ts index 6f1e0b946bf..b2ee24b44f6 100644 --- a/packages/core/src/compiler/scriptRuns.ts +++ b/packages/core/src/compiler/scriptRuns.ts @@ -70,6 +70,7 @@ export const AFTER_FONTS_SCRIPTS = `script[type="${AFTER_FONTS_SCRIPT_TYPE}"], s export const AFTER_FONTS_CLAIM = "__hfAfterFontsClaimed"; export const INLINED_FILE_ATTR = "data-hf-inlined-src"; +export const COMPOSITION_SOURCE_URL = "hyperframes-composition://body"; export const DEFERRED_FILE = `[defer][src], [defer][${INLINED_FILE_ATTR}]`; const AFTER_FONTS_FALLBACK_ATTR = "data-hf-after-fonts-fallback"; @@ -96,7 +97,8 @@ const afterFontsFallback = () => `document.addEventListener("DOMContentLoaded", el.replaceWith(s); if (!waits) next(); })(); -});`; +}); +//# sourceURL=hyperframes://after-fonts-fallback`; /** Gives each body script a type the browser does not run, so the runtime can run it once web fonts are ready. */ export function deferScriptsUntilFonts( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c1f1725a0cb..e7a2e2fbc1d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -182,6 +182,7 @@ export { type SubCompositionValidityReason, } from "./compiler/subCompositionValidity"; export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument"; +export { COMPOSITION_SOURCE_URL } from "./compiler/scriptRuns"; export { queryByAttr } from "./utils/cssSelector"; export { AUDIBLE_MEDIA_SELECTOR, diff --git a/packages/core/src/runtime/vfx.test.ts b/packages/core/src/runtime/vfx.test.ts index 0d550aa8f81..a540b6bb81b 100644 --- a/packages/core/src/runtime/vfx.test.ts +++ b/packages/core/src/runtime/vfx.test.ts @@ -225,6 +225,12 @@ function clearCompositeSlot(): void { delete compositeWindow().__hf_page_composite_resolve; } +/** The chain registers nothing, and its one error matches every pattern. */ +function expectRefused(...patterns: RegExp[]): void { + expect(initVfx(document.body, 30)).toHaveLength(0); + for (const pattern of patterns) expect(String(errors[0]![1])).toMatch(pattern); +} + describe("vfx runtime", () => { beforeEach(() => installVfxHarness(null)); afterEach(releaseVfxHarness); @@ -279,8 +285,7 @@ describe("vfx runtime", () => { it("reports an unknown effect type loudly", () => { makeHost('{"version":1,"nodes":[{"type":"nope","id":"n1","params":{}}]}'); - expect(initVfx(document.body, 30)).toHaveLength(0); - expect(String(errors[0]![1])).toMatch(/unknown effect type/); + expectRefused(/unknown effect type/, /^vfx: /); }); it("reports an unavailable WebGL2 context loudly and registers nothing", () => { @@ -492,8 +497,7 @@ describe("vfx runtime — self capture", () => { it("names the Chrome flag when drawElementImage is missing", () => { makeCaptureHost({ clearRect: () => {} }); - expect(initVfx(document.body, 30)).toHaveLength(0); - expect(String(errors[0]![1])).toMatch(/chrome:\/\/flags\/#canvas-draw-element/); + expectRefused(/chrome:\/\/flags\/#canvas-draw-element/); }); it("arms the page-composite protocol instead of painting inline in engine mode", () => { @@ -722,6 +726,7 @@ describe("vfx runtime — self capture", () => { expect(errors[0]![0]).toBe(LABEL); expect(String(errors[0]![1])).toMatch(/#cap-stalled/); expect(String(errors[0]![1])).toMatch(/no paint arrived within 2000ms/); + expect(String(errors[0]![1]).startsWith("vfx-frame: ")).toBe(true); expect(String(errors[0]![1])).toMatch(/BeginFrame/); } finally { vi.useRealTimers(); @@ -839,6 +844,7 @@ describe("vfx runtime — backdrop capture", () => { expect(errors).toHaveLength(1); expect(String(errors[0]![1])).toMatch(/measures 0×0/); expect(String(errors[0]![1])).toMatch(/explicit width and height in px/); + expect(String(errors[0]![1]).startsWith("vfx-frame: ")).toBe(true); }); it("refuses a .hf-vfx-in that is not the capture canvas's immediate child", () => { @@ -940,24 +946,19 @@ describe("vfx runtime — ref (second source) params", () => { makeRefTarget(undefined, "matte-1", false); makeCaptureHost(createMockCtx2d(), "cap", REF_NODE); - expect(initVfx(document.body, 30)).toHaveLength(0); - expect(String(errors[0]![1])).toMatch(/"matte" source/); - expect(String(errors[0]![1])).toMatch(/hf-vfx-src/); + expectRefused(/"matte" source/, /hf-vfx-src/); }); it("refuses a ref naming an element that is not in the composition", () => { makeCaptureHost(createMockCtx2d(), "cap", REF_NODE); - expect(initVfx(document.body, 30)).toHaveLength(0); - expect(String(errors[0]![1])).toMatch(/#matte-1/); - expect(String(errors[0]![1])).toMatch(/not in the composition/); + expectRefused(/#matte-1/, /not in the composition/); }); it("refuses a node that names no ref element at all", () => { makeCaptureHost(createMockCtx2d(), "cap", ONE_NODE); - expect(initVfx(document.body, 30)).toHaveLength(0); - expect(String(errors[0]![1])).toMatch(/needs a "matte" param/); + expectRefused(/needs a "matte" param/); }); it("captures a hidden matte as empty instead of failing the frame", async () => { diff --git a/packages/core/src/runtime/vfx.ts b/packages/core/src/runtime/vfx.ts index 4cf1cafed32..30973dddb24 100644 --- a/packages/core/src/runtime/vfx.ts +++ b/packages/core/src/runtime/vfx.ts @@ -11,9 +11,8 @@ * randomness, no state carried between paints. That is the determinism * contract the exporter's gate depends on. * - * Every failure is loud: the `[HyperFrames] composition script error:` prefix - * is what the engine turns into `runtime-error:` and fails fast on, so - * a broken chain stops a render instead of silently rendering the wrong frame. + * Every failure is loud: the engine records the `[HyperFrames] composition script error:` + * prefix as a composition script error, which fails a render whose timeline wait times out. */ import { @@ -44,7 +43,7 @@ import { isCanvasElement, isHtmlElement } from "./domRealm"; */ const VFX_REF_VISIBLE_ATTR = "data-vfx-ref-visible"; -/** The prefix `frameCapture.ts` matches to fail a render fast. */ +/** The prefix `frameCapture.ts` records as a composition script error. */ const VFX_ERROR_LABEL = "[HyperFrames] composition script error:"; /** @@ -118,6 +117,8 @@ interface VfxCaptureSource { visible: boolean; /** `.hf-vfx-in` measured 0×0 and that has already been reported once. */ emptyBoxReported: boolean; + /** `.hf-vfx-in` measured 0×0 at registration or the last seek, captured or not. */ + laidOutEmpty: boolean; } /** @@ -213,6 +214,12 @@ function reportVfxError(message: string): void { console.error(VFX_ERROR_LABEL, `vfx: ${message}`); } +/** One frame's capture failed; later frames may still paint, so the engine does not stop the render on it. */ +function reportVfxFrameError(message: string): void { + // eslint-disable-next-line no-console + console.error(VFX_ERROR_LABEL, `vfx-frame: ${message}`); +} + function compileShader( gl: WebGL2RenderingContext, type: number, @@ -511,6 +518,7 @@ function resolveCaptureSource( // Only a `ref` source may be visible; `resolveRefSource` sets it. visible: false, emptyBoxReported: false, + laidOutEmpty: deviceSize(inner) === null, }; } @@ -907,6 +915,18 @@ function resizeCaptureCanvas(src: VfxCaptureSource, size: { width: number; heigh if (src.canvas.height !== size.height) src.canvas.height = size.height; } +function captureEmpty( + entry: VfxEntry, + src: VfxCaptureSource, + size: { width: number; height: number }, + mode: CaptureMode, +): true { + resizeCaptureCanvas(src, size); + src.ctx.clearRect(0, 0, size.width, size.height); + if (mode.upload) uploadCaptureTexture(entry.gl, src); + return true; +} + /** * Read one source's pixels into its texture. Both `clearRect`s matter: the * first because `drawElementImage` composites onto whatever is there, the @@ -945,28 +965,23 @@ function captureSource( // first. An empty `u_src2` is also the right answer — under Alpha the layer // it mattes disappears, under Alpha Inverted it passes, which is what After // Effects does with a matte that is not there yet. - if (!isPaintableSource(src)) { - resizeCaptureCanvas(src, size); - src.ctx.clearRect(0, 0, size.width, size.height); - if (mode.upload) uploadCaptureTexture(entry.gl, src); - return true; - } + if (!isPaintableSource(src)) return captureEmpty(entry, src, size, mode); // The one capture failure Chrome does NOT report: inside a `layoutsubtree` // canvas a child sized by `inset`/percentages measures 0×0, and // `drawElementImage` then succeeds and draws nothing at all — no throw, no // warning, a blank layer. Measured (vault `layoutsubtree-capture-rules`), - // so it is checked here and said out loud, once per source rather than once - // per frame. + // so it is said out loud once per source, and the frame paints from an empty + // capture rather than repeating the last one. if (deviceSize(src.inner) === null) { if (!src.emptyBoxReported) { src.emptyBoxReported = true; - reportVfxError( + reportVfxFrameError( `${describeHost(entry.host)}: the .hf-vfx-in wrapper measures 0×0, so its capture ` + `would be empty. Inside a layoutsubtree canvas an inset or percentage box has no ` + `size — the wrapper must state an explicit width and height in px.`, ); } - return false; + return captureEmpty(entry, src, size, mode); } resizeCaptureCanvas(src, size); src.ctx.clearRect(0, 0, size.width, size.height); @@ -1201,7 +1216,7 @@ function awaitCanvasPaint(canvas: HTMLCanvasElement): Promise<"painted" | "timeo async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]): Promise { const outcomes = await Promise.all(sources.map((source) => awaitCanvasPaint(source.canvas))); if (!outcomes.includes("timeout")) return true; - reportVfxError( + reportVfxFrameError( `${describeHost(entry.host)}: no paint arrived within ${CAPTURE_PAINT_TIMEOUT_MS}ms, so ` + `this frame's capture was skipped (a BeginFrame-controlled compositor without a tick ` + `for this frame is a known cause).`, @@ -1304,6 +1319,15 @@ async function capturePreviewThenPaint( await Promise.all(entries.map((entry) => capturePaintedHost(entry, t, seq, speculative))); } +/** A `.hf-vfx-in` first painted at 0×0 and then sized can stay blank in its capture until re-inserted (measured). */ +function reinsertRegrownSources(): void { + for (const src of registry.flatMap(entrySources)) { + const empty = deviceSize(src.inner) === null; + if (src.laidOutEmpty && !empty) src.canvas.insertBefore(src.inner, src.inner.nextSibling); + src.laidOutEmpty = empty; + } +} + /** * Repaint every registered chain for composition-local time `t`. Called from * the runtime transport's `seek` (preview) and `renderSeek` (engine) — the two @@ -1337,6 +1361,7 @@ async function capturePreviewThenPaint( export function paintVfx(t: number, options?: { engineMode?: boolean }): void { lastPaintTime = t; const seq = ++paintSeq; + reinsertRegrownSources(); const capturing: VfxEntry[] = []; for (const entry of registry) { // Reported once, at the moment of loss; repeating it per frame is spam. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index fb90a59be9c..0f952ca5dd1 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -134,6 +134,7 @@ export { type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, + VfxFailureError, } from "./services/frameCapture.js"; export { CaptureFailure, diff --git a/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts b/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts index 76efad4fe32..4666832f5c6 100644 --- a/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts +++ b/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts @@ -41,6 +41,24 @@ describe("pollSubCompositionTimelines fail-fast", () => { expect(Date.now() - started).toBeLessThan(5_000); }); + it("stops at once when the caller says so, without waiting out the timeout", async () => { + const page = makeMockPage((expr) => + expr.includes("__hfForceTimelineRebind") ? undefined : false, + ); + const shouldStop = vi.fn(() => true); + const outcome = await pollSubCompositionTimelines( + page, + 60_000, + 10, + () => [], + undefined, + undefined, + shouldStop, + ); + expect(outcome).toBe("timeout"); + expect(shouldStop).toHaveBeenCalledTimes(1); + }); + it("waits the full timeout when timelines are missing but no script failed", async () => { const page = makeMockPage(() => false); const outcome = await pollSubCompositionTimelines(page, 120, 10, () => []); diff --git a/packages/engine/src/services/frameCapture-subTimelineWarning.test.ts b/packages/engine/src/services/frameCapture-subTimelineWarning.test.ts index f9dd6186b9d..8789f71a821 100644 --- a/packages/engine/src/services/frameCapture-subTimelineWarning.test.ts +++ b/packages/engine/src/services/frameCapture-subTimelineWarning.test.ts @@ -6,6 +6,7 @@ import { pollSubCompositionTimelines, recordSubTimelineWarning } from "./frameCa function makeSession(overrides: Partial = {}): CaptureSession { return { scriptLoadFailures: [], + pageErrors: [], warnings: [], ...overrides, } as unknown as CaptureSession; @@ -81,6 +82,56 @@ describe("recordSubTimelineWarning", () => { expect(warning.message).toContain("https://example.test/scene.js"); expect(warning.message).not.toContain("data-no-timeline"); }); + + it("turns a timeout with an uncaught page error into a script failure naming data-no-timeline", () => { + const session = makeSession({ + subTimelineWaitOutcome: "timeout", + pageErrors: ["runtime-error:ReferenceError: gsap is not defined"], + }); + recordSubTimelineWarning(session, 45_000); + + expect(session.subTimelineWaitOutcome).toBe("timeout"); + const [warning] = session.warnings; + expect(warning.code).toBe("sub_timeline_script_failure"); + expect(warning.message).toContain("ReferenceError: gsap is not defined"); + expect(warning.message).toContain("data-no-timeline"); + expect(warning.details).toMatchObject({ + sources: ["runtime-error:ReferenceError: gsap is not defined"], + }); + }); + + it("names the missing script, not the error it caused, when both are present", () => { + const session = makeSession({ + subTimelineWaitOutcome: "script_failure", + scriptLoadFailures: ["https://example.test/gsap.js"], + pageErrors: ["runtime-error:ReferenceError: gsap is not defined"], + }); + recordSubTimelineWarning(session, 45_000); + + expect(session.warnings[0].message).toContain("failed to load"); + }); + + it("names an integrity-blocked script as one that failed to load", () => { + const session = makeSession({ + subTimelineWaitOutcome: "script_failure", + scriptLoadFailures: ["runtime-error:subresource-integrity"], + }); + recordSubTimelineWarning(session, 45_000); + + expect(session.warnings[0].message).toContain("failed to load"); + }); + + // An unrelated error on a page whose timelines did register must not fail the render. + it("ignores an uncaught page error when the timelines registered", () => { + const session = makeSession({ + subTimelineWaitOutcome: "ready", + pageErrors: ["runtime-error:Error: third-party widget failed"], + }); + recordSubTimelineWarning(session, 45_000); + + expect(session.subTimelineWaitOutcome).toBe("ready"); + expect(session.warnings).toEqual([]); + }); }); // The warning text is only as good as the ids handed to it, and that handoff diff --git a/packages/engine/src/services/frameCapture.test.ts b/packages/engine/src/services/frameCapture.test.ts index da1b3bc0ce2..d0513ffc712 100644 --- a/packages/engine/src/services/frameCapture.test.ts +++ b/packages/engine/src/services/frameCapture.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect } from "vitest"; +import type { Protocol } from "puppeteer-core"; +import { COMPOSITION_SOURCE_URL } from "@hyperframes/core"; +import type { CaptureSession } from "./frameCapture.js"; import { buildZeroDurationDiagnostic, + captureFramesBatchPipelined, + captureFrameToBufferPipelined, + classifyConsoleScriptError, classifyConsoleScriptFailure, + classifyPageError, DrawElementVerificationError, formatHttpErrorDiagnostic, formatConsoleDiagnostic, @@ -9,10 +16,12 @@ import { formatNavigationStartDiagnostic, formatRequestFailureDiagnostic, HF_READY_DIAGNOSTIC_EXPR, + initializeSession, getDrawElementVerificationDetails, isFontResourceError, isDrawElementVerificationError, sanitizeDiagnosticUrl, + VfxFailureError, shouldIgnoreRequestFailureDiagnostic, } from "./frameCapture.js"; @@ -35,6 +44,196 @@ describe("classifyConsoleScriptFailure", () => { ).toBeNull(); expect(classifyConsoleScriptFailure("error", "Integrity metadata is present.")).toBeNull(); }); + + // A thrown composition script may still register its timeline, so it is a page error, not a load failure. + it("leaves a composition script's logged throw to classifyConsoleScriptError", () => { + const logged = "[HyperFrames] composition script error: scene TypeError: x"; + expect(classifyConsoleScriptFailure("error", logged)).toBeNull(); + expect(classifyConsoleScriptError("error", `${logged}\r\n at scene.js:1`)).toBe( + "runtime-error:scene TypeError: x", + ); + expect(classifyConsoleScriptError("warning", logged)).toBeNull(); + }); +}); + +describe("classifyPageError", () => { + const server = "http://localhost:4100"; + const doc = `${server}/index.html`; + // Shapes as Chromium 152 reports them through Runtime.exceptionThrown. + const thrown = ( + description: string | undefined, + url: string, + frames: string[], + value?: string, + ): Protocol.Runtime.ExceptionDetails => ({ + exceptionId: 1, + text: "Uncaught", + lineNumber: 0, + columnNumber: 0, + url, + exception: { type: description ? "object" : "string", description, value }, + stackTrace: { + callFrames: frames.map((frameUrl) => ({ url: frameUrl }) as Protocol.Runtime.CallFrame), + }, + }); + + const comp = `${server}/comp.js`; + const served = new Set([comp]); + const named = COMPOSITION_SOURCE_URL; + + it("records an error thrown by a script file the page loaded from the server, by its first line", () => { + const error = + "TypeError: Cannot read properties of null (reading 'timeline')\n at build (comp.js:1:37)"; + expect(classifyPageError(thrown(error, comp, [comp]), served)).toBe( + "runtime-error:TypeError: Cannot read properties of null (reading 'timeline')", + ); + }); + + it("records a syntax error and a thrown string, which carry no error stack", () => { + const syntax = thrown("SyntaxError: Unexpected token ';'", comp, []); + const plain = thrown(undefined, named, [named], "plain string"); + expect(classifyPageError(syntax, served)).toBe( + "runtime-error:SyntaxError: Unexpected token ';'", + ); + expect(classifyPageError(plain, served)).toBe("runtime-error:plain string"); + }); + + it("records the composition's inline code, named by the render compiler, and a library it called", () => { + const lib = "https://cdn.example/lib.js"; + expect(classifyPageError(thrown("TypeError: x", named, [named]), served)).toBe( + "runtime-error:TypeError: x", + ); + expect(classifyPageError(thrown("TypeError: x", lib, [lib, named]), served)).toBe( + "runtime-error:TypeError: x", + ); + }); + + // A widget's failed img.decode() and the composition's failed r.json() report the same shape, + // naming the current document: after a hash change, a replaceState, or inside an iframe. + it.each([doc, `${doc}#consent`, `${server}/frame.html`])( + "ignores a frameless rejection naming %s", + (url) => { + const decode = thrown("EncodingError: The source image cannot be decoded.", url, []); + expect(classifyPageError({ ...decode, text: "Uncaught (in promise)" }, served)).toBeNull(); + }, + ); + + // An inline handler a widget inserts reports the current document, which the widget can move anywhere on the server. + it.each([doc, `${doc}#c`, `${server}/elsewhere/page?q=1`])( + "ignores an inline handler reporting the document at %s", + (url) => { + expect(classifyPageError(thrown("TypeError: x", url, [url]), served)).toBeNull(); + }, + ); + + // Chromium names no URL for eval'd or script-inserted code, so it has no owner unless a named frame calls it. + it("ignores unnamed code a widget evals or inserts, even when the widget's frame is on the stack", () => { + const widget = "http://127.0.0.1:4200/w.js"; + expect(classifyPageError(thrown("TypeError: x", "", [""]), served)).toBeNull(); + expect(classifyPageError(thrown("TypeError: x", "", ["", widget]), served)).toBeNull(); + }); + + it("ignores an error whose frames are all named off the page: an inlined CDN script and the runtime", () => { + const cdn = "http://127.0.0.1:4200/sync.js"; + expect( + classifyPageError(thrown("Error: w", cdn, [cdn, "hyperframes://injected/0"]), served), + ).toBeNull(); + }); + + it("records a rejection that carries the composition's frames", () => { + const rejected = { + ...thrown("Error: init failed", comp, [comp]), + text: "Uncaught (in promise)", + }; + expect(classifyPageError(rejected, served)).toBe("runtime-error:Error: init failed"); + }); + + it("ignores a frameless error naming the document itself", () => { + expect( + classifyPageError(thrown("SyntaxError: Unexpected token ';'", doc, []), served), + ).toBeNull(); + }); + + it("ignores errors from other origins and the benign play/pause race", () => { + const widget = "http://127.0.0.1:4100/widget.js"; + const abort = + "AbortError: The play() request was interrupted by a call to pause(). https://goo.gl/LdLk22"; + expect(classifyPageError(thrown("Error: widget failed", widget, [widget]), served)).toBeNull(); + expect(classifyPageError(thrown(abort, named, [named]), served)).toBeNull(); + }); + + // initializeSession registers its listeners before the incomplete fake session makes it throw. + async function listenedSession() { + const runtimeListeners = new Map void>(); + const pageListeners = new Map void>(); + const listen = + (listeners: typeof pageListeners) => (event: string, listener: (event: unknown) => void) => + listeners.set(event, listener); + const client = { on: listen(runtimeListeners), send: async () => ({}) }; + const page = { on: listen(pageListeners), createCDPSession: async () => client }; + const session = { + page, + serverUrl: server, + scriptLoadFailures: [] as string[], + pageErrors: [] as string[], + warnings: [] as { code: string }[], + browserConsoleBuffer: [], + } as unknown as CaptureSession; + await initializeSession(session).catch(() => {}); + return { session, pageListeners, runtimeListeners }; + } + + it("records the page's uncaught errors from scripts it loaded from the server", async () => { + const { session, pageListeners, runtimeListeners } = await listenedSession(); + const loaded = (url: string, resourceType: string) => ({ + status: () => 200, + url: () => url, + request: () => ({ resourceType: () => resourceType }), + }); + pageListeners.get("response")?.(loaded(comp, "script")); + pageListeners.get("response")?.(loaded(`${server}/data.js`, "fetch")); + const exception = (url: string) => + runtimeListeners.get("Runtime.exceptionThrown")?.({ + exceptionDetails: thrown(`ReferenceError: ${url}`, url, [url]), + }); + for (const url of [comp, comp, `${server}/data.js`, doc]) exception(url); + expect(session.pageErrors).toEqual([`runtime-error:ReferenceError: ${comp}`]); + expect(session.scriptLoadFailures).toEqual([]); + }); + + it("records the first VFX error to stop the render, and a scene's logged throw as a page error", async () => { + const { session, pageListeners } = await listenedSession(); + const logged = (detail: string) => + pageListeners.get("console")?.({ + type: () => "error", + text: () => `[HyperFrames] composition script error: ${detail}`, + location: () => ({}), + }); + logged("vfx: #host: unknown effect"); + logged("vfx: #host: WebGL context lost."); + logged("vfx-frame: #host: no paint arrived"); + logged("scene TypeError: x"); + expect(session.vfxFailure).toBe("runtime-error:vfx: #host: unknown effect"); + expect(session.warnings.map((warning) => warning.code)).toEqual(["vfx_failure"]); + expect(session.pageErrors).toEqual([ + "runtime-error:vfx-frame: #host: no paint arrived", + "runtime-error:scene TypeError: x", + ]); + }); + + it("refuses to capture another frame once a VFX chain has failed", async () => { + const session = { + isInitialized: true, + vfxFailure: "runtime-error:vfx: #host: unknown effect", + options: { fps: { num: 30, den: 1 } }, + } as unknown as CaptureSession; + await expect(captureFrameToBufferPipelined(session, 0, 0)).rejects.toBeInstanceOf( + VfxFailureError, + ); + await expect(captureFramesBatchPipelined(session, [0], [0])).rejects.toBeInstanceOf( + VfxFailureError, + ); + }); }); describe("isFontResourceError", () => { diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 339d148f2f9..42a40db3969 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -8,10 +8,17 @@ * via Chrome's BeginFrame API or Page.captureScreenshot fallback. */ -import { type Browser, type Page, type Viewport, type ConsoleMessage } from "puppeteer-core"; +import { + type Browser, + type Page, + type Protocol, + type Viewport, + type ConsoleMessage, +} from "puppeteer-core"; import { existsSync, mkdirSync, writeFileSync } from "fs"; import { join } from "path"; import { + COMPOSITION_SOURCE_URL, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, @@ -165,6 +172,10 @@ export interface CaptureSession { * were hitting that wall — a 705-render spike at the 45s setup bucket). */ scriptLoadFailures: string[]; + /** Uncaught page errors; a timed-out timeline wait with any of these becomes a script failure. */ + pageErrors: string[]; + /** The first VFX chain error; its frames would lack the effect, so it stops the render. */ + vfxFailure?: string; /** Outcome of the sub-composition timeline wait: ready | timeout | script_failure. */ subTimelineWaitOutcome?: SubTimelineWaitOutcome; /** @@ -1503,6 +1514,7 @@ async function constructCaptureSession( isInitialized: false, browserConsoleBuffer: [], scriptLoadFailures: [], + pageErrors: [], warnings: [], capturePerf: { frames: 0, @@ -1755,6 +1767,8 @@ export async function pollSubCompositionTimelines( // Reports the composition ids still unregistered at bail time, so the caller // can put them in the structured warning as well as in stderr. onPending?: (ids: readonly string[]) => void, + // Ends the wait at once; the caller then fails the render for its own reason. + shouldStop?: () => boolean, ): Promise { // Hosts may opt out of the timeline wait with `data-no-timeline` — // compositions driven purely by CSS animations / rAF (the render-compat @@ -1778,7 +1792,7 @@ export async function pollSubCompositionTimelines( let scriptFailureBail = false; for (;;) { ready = Boolean(await page.evaluate(expression)); - if (ready) break; + if (ready || shouldStop?.()) break; const now = Date.now(); if (now >= deadline) break; const failures = getScriptLoadFailures?.() ?? []; @@ -2008,26 +2022,30 @@ function recordCaptureWarnings(session: CaptureSession, warnings: readonly Captu } export function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void { - if (session.subTimelineWaitOutcome === "ready" || !session.subTimelineWaitOutcome) return; - const scriptFailure = session.subTimelineWaitOutcome === "script_failure"; - const hasRuntimeErrors = session.scriptLoadFailures.some((f) => f.startsWith("runtime-error:")); + const outcome = session.subTimelineWaitOutcome; + if (outcome === "ready" || !outcome) return; + const threwThenTimedOut = outcome === "timeout" && session.pageErrors.length > 0; + const scriptFailure = outcome === "script_failure" || threwThenTimedOut; + const sources = [...session.scriptLoadFailures, ...session.pageErrors]; const pending = session.pendingTimelineIds ?? []; const pendingSuffix = pending.length > 0 ? ` (still unregistered: ${pending.join(", ")})` : ""; recordCaptureWarnings(session, [ { code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout", - message: scriptFailure - ? hasRuntimeErrors - ? `A sub-composition script threw during execution — timeline registration never arrived (${session.scriptLoadFailures.join(", ")})` - : `A sub-composition timeline script failed to load (${session.scriptLoadFailures.join(", ")})` - : `Sub-composition timelines did not become ready within ${timeoutMs}ms${pendingSuffix}. ` + - `This can be intentional: a composition driven by CSS animations or rAF never registers ` + - `window.__timelines[id], and marking its host with data-no-timeline skips the wait entirely. ` + - `Otherwise, a composition that sets up asynchronously must register window.__timelines[id] ` + - `once setup completes.`, + message: threwThenTimedOut + ? `A composition script threw and a timeline did not register within ${timeoutMs}ms` + + `${pendingSuffix} (${sources.join(", ")}). Fix the error; a composition animated by ` + + `CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.` + : scriptFailure + ? `A sub-composition timeline script failed to load (${sources.join(", ")})` + : `Sub-composition timelines did not become ready within ${timeoutMs}ms${pendingSuffix}. ` + + `This can be intentional: a composition driven by CSS animations or rAF never registers ` + + `window.__timelines[id], and marking its host with data-no-timeline skips the wait entirely. ` + + `Otherwise, a composition that sets up asynchronously must register window.__timelines[id] ` + + `once setup completes.`, details: { timeoutMs, - sources: [...session.scriptLoadFailures], + sources, pendingCompositionIds: [...pending], }, }, @@ -2177,6 +2195,34 @@ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Prom } } +/** Thrown once a VFX chain fails: the frames it paints would render without the effect. */ +export class VfxFailureError extends Error { + constructor(readonly detail: string) { + super(`A VFX chain failed, so its frames would render without the effect (${detail})`); + this.name = "VfxFailureError"; + } +} + +function assertVfxIntact(session: CaptureSession): void { + if (session.vfxFailure) throw new VfxFailureError(session.vfxFailure); +} + +// A `vfx:` detail is a chain that cannot paint, so every frame is wrong; `vfx-frame:` is one frame, a page error. +function recordConsoleScriptError(session: CaptureSession, error: string | null): void { + if (!error?.startsWith("runtime-error:vfx:")) { + recordPageError(session, error); + return; + } + if (session.vfxFailure) return; + session.vfxFailure = error; + const message = new VfxFailureError(error).message; + recordCaptureWarnings(session, [{ code: "vfx_failure", message, details: { sources: [error] } }]); +} + +function recordPageError(session: CaptureSession, error: string | null): void { + if (error && !session.pageErrors.includes(error)) session.pageErrors.push(error); +} + // A 4xx `response` and a `requestfailed` can both fire for the same script // (e.g. a `requestfailed` following the 4xx), and repeated \n', + ), + "utf-8", + ); + const failingPlanDir = join(runRoot, "plan-missing-script"); + mkdirSync(failingPlanDir, { recursive: true }); + await plan( + failingDir, + { fps: 30, width: 160, height: 120, format: "png-sequence" }, + failingPlanDir, + ); + + const error = await renderChunk(failingPlanDir, 0, join(runRoot, "chunk-missing-script")).then( + () => null, + (err: unknown) => err, + ); + if (error instanceof Error && HOST_CHROME_FAILURE_PATTERNS.test(error.message)) { + console.warn( + "[renderChunk.test] skipping script-failure test — host Chrome stack can't render. Diagnostic:", + error.message.slice(0, 240), + ); + return; + } + expect(error).toBeInstanceOf(RenderQualityError); + expect((error as Error).message).toContain("sub_timeline_script_failure"); + }, 60_000); +}); + describe("resolvePresetForLockedEncoder", () => { // Tiny fast tests for the codec-override helper. No Chrome, no ffmpeg — // exists so a refactor that moves the override (e.g. into diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index e0a288a52ed..12d52af8019 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -63,6 +63,7 @@ import { resolveConfig, } from "@hyperframes/engine"; import { defaultLogger } from "../../logger.js"; +import { applyRenderWarningPolicy } from "../renderOrchestrator.js"; import { runEncodeStage } from "../render/stages/encodeStage.js"; import { runCaptureStage } from "../render/stages/captureStage.js"; import { resolveVideoCaptureBeyondViewport } from "../render/captureBeyondViewport.js"; @@ -924,6 +925,11 @@ export async function renderChunk( }, }); captureStageMs = Date.now() - captureStarted; + applyRenderWarningPolicy( + job, + capturePerfs.flatMap((perf) => perf.warnings ?? []), + log, + ); framesEncoded = framesInChunk; // ── Encode the chunk ── diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 6101bd1ba0f..977a89b447a 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -95,7 +95,7 @@ describe("injectScriptsIntoHtml", () => { `; const injected = injectScriptsAtHeadStart(html, [VIRTUAL_TIME_SHIM]); - const injectedShimTag = ``; + const injectedShimTag = ``; const authoredHeadTag = ``; expect(injected.indexOf(injectedShimTag)).toBeGreaterThanOrEqual(0); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index e603fd58581..14ce3fa9363 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -15,6 +15,7 @@ import { createReadStream, existsSync, mkdirSync, readFileSync } from "fs"; import { join, dirname, resolve, basename, relative } from "path"; import { parseHTML } from "linkedom"; import { + COMPOSITION_SOURCE_URL, compileTimingAttrs, injectDurations, extractResolvedMedia, @@ -45,6 +46,7 @@ import { ensureExternalScriptTag, deferScriptsUntilFonts, emitMountedModuleScripts, + isJavaScriptType, prepareFlattenedInnerRoot, emitRootCompositionVariableStyles, readDeclaredDefaults, @@ -129,8 +131,22 @@ function parseSubCompHtmlForValidity(html: string): ParsableDocumentLike { return parseHTML(html).document as unknown as ParsableDocumentLike; } -function deferBodyScriptsUntilFonts(html: string): string { +function endsWithSourceUrl(code: string): boolean { + const trimmed = code.trimEnd(); + const lastLine = trimmed.slice(trimmed.lastIndexOf("\n") + 1).trimStart(); + return lastLine.startsWith("//# sourceURL=") || lastLine.startsWith("//@ sourceURL="); +} + +// One name for the composition's inline code lets a render keep its errors and not a widget's. +function prepareCompositionScripts(html: string): string { const { document } = parseHTML(html); + for (const el of document.querySelectorAll("script:not([src])")) { + const isModule = (el.getAttribute("type") || "").trim().toLowerCase() === "module"; + const runsAsScript = isModule || isJavaScriptType(el as unknown as Element); + const code = el.textContent ?? ""; + if (!runsAsScript || endsWithSourceUrl(code)) continue; + el.textContent = `${code}\n//# sourceURL=${COMPOSITION_SOURCE_URL}`; + } deferScriptsUntilFonts(document as unknown as Document); return document.toString(); } @@ -140,7 +156,7 @@ export function injectSdkPositionEditsRenderScript(html: string): string { return html; } const scriptBody = getPositionEditsRenderScript().replace(/<\/script/gi, "<\\/script"); - const script = ``; + const script = ``; return insertBeforeCloseTag(html, "body", script) ?? `${html}${script}`; } @@ -1008,7 +1024,6 @@ function inlineSubCompositions( // Mirrors the preview bundler: a sub-composition's SIBLING assets resolve // against its own directory, project-root refs stay as authored. assetExists: (path: string) => existsSync(resolve(projectDir, path)), - scriptErrorLabel: "[Compiler] Composition script failed", // Preserve the authored root wrapper as a child of the host, matching // the preview bundler's shape (htmlBundler.ts's prepareFlattenedInnerRoot, // which the runtime compositionLoader mirrors with its own copy for the @@ -1243,7 +1258,7 @@ export async function inlineExternalScripts(html: string): Promise { if (attr.name.toLowerCase() === "src") continue; inlineScript.setAttribute(attr.name, attr.value); } - inlineScript.textContent = `/* inlined: ${src} */\n${safeText}\n`; + inlineScript.textContent = `/* inlined: ${src} */\n${safeText}\n//# sourceURL=${new URL(src).href}\n`; el.replaceWith(inlineScript); defaultLogger.info(`[Compiler] Inlined CDN script: ${src}`); } else { @@ -2042,10 +2057,10 @@ export async function compileForRender( ? (insertBeforeCloseTag( assembledHtml, "body", - ``, + ``, ) ?? assembledHtml) : assembledHtml; - const htmlWithDeferredScripts = deferBodyScriptsUntilFonts( + const htmlWithDeferredScripts = prepareCompositionScripts( injectSdkPositionEditsRenderScript(htmlWithPositionScript), ); diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 0a042cf06fc..4a3d8b851d5 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -65,6 +65,9 @@ import { shouldUseStreamingEncode, resolveObservedCaptureMode, createCaptureObservabilityUpdater, + applyRenderWarningPolicy, + createRenderJob, + RenderQualityError, } from "./renderOrchestrator.js"; import { probeRequiresBrowser } from "./render/stages/probeStage.js"; import { EncoderInterruptedError } from "./render/encoderInterruption.js"; @@ -3887,3 +3890,15 @@ describe("isParallelStreamForced: the plan carries the manual interleave opt-in" ); }); }); + +describe("applyRenderWarningPolicy", () => { + it("fails a best-effort render whose VFX chain failed, since its layer renders without the effect", () => { + const job = createRenderJob({ fps: 30, quality: "standard" }); + const vfx = { + code: "vfx_failure" as const, + message: "A VFX chain failed", + details: { sources: ["runtime-error:vfx: #host: unknown effect"] }, + }; + expect(() => applyRenderWarningPolicy(job, [vfx])).toThrow(RenderQualityError); + }); +}); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index d147c31a4f2..f26bcbf7dae 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -797,11 +797,11 @@ export function applyRenderWarningPolicy( ); // A script failure means the composition's GSAP timelines can never // register — the render produces a degenerate 2-frame output that looks - // like a still image. Fail loudly rather than shipping garbage (#3352). - const hasSubTimelineScriptFailure = job.warnings.some( - (warning) => warning.code === "sub_timeline_script_failure", + // like a still image. Fail loudly rather than shipping garbage (#3352). A failed VFX chain drops its layer. + const hasScriptOrVfxFailure = job.warnings.some( + (warning) => warning.code === "sub_timeline_script_failure" || warning.code === "vfx_failure", ); - if (strictness === "strict" || hasAudioProcessingFailure || hasSubTimelineScriptFailure) { + if (strictness === "strict" || hasAudioProcessingFailure || hasScriptOrVfxFailure) { throw new RenderQualityError(job.warnings); } } diff --git a/packages/producer/src/services/scriptFailureAttribution.test.ts b/packages/producer/src/services/scriptFailureAttribution.test.ts new file mode 100644 index 00000000000..b1818fd64dc --- /dev/null +++ b/packages/producer/src/services/scriptFailureAttribution.test.ts @@ -0,0 +1,253 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + captureFrameToBuffer, + closeCaptureSession, + createCaptureSession, + initializeSession, + VfxFailureError, +} from "@hyperframes/engine"; +import { createFileServer } from "./fileServer.js"; +import { compileForRender } from "./htmlCompiler.js"; +import { writeCompiledArtifacts } from "./render/shared.js"; + +// Real Chromium on the render path (compiled, CDN scripts inlined, body scripts re-run after fonts). +// The timeline never registers in any case; what differs is whose script threw. +const composition = (scripts: string) => ` + +
+ ${scripts} +`; + +const handler = + "document.body.insertAdjacentHTML('beforeend', '');"; +const decode = "var image = new Image(); image.src = 'data:image/png;base64,AAAA'; image.decode();"; +const widgetScripts: Record = { + "/sync.js": "throw new Error('widget failed at load');", + "/widget.js": "setTimeout(function widget() { throw new Error('widget failed'); }, 0);", + "/decode.js": decode, + "/hash-decode.js": `location.hash = 'consent'; ${decode}`, + "/eval.js": "eval('null.timeline');", + "/eval-later.js": "eval('setTimeout(function () { null.timeline; }, 0)');", + "/insert-later.js": + "var s = document.createElement('script'); s.text = 'setTimeout(function () { null.timeline; }, 0)'; document.body.appendChild(s);", + "/string-timer.js": "setTimeout('null.timeline', 0);", + "/inline-handler.js": handler, + "/moved-inline-handler.js": `history.replaceState(null, '', '/elsewhere/page'); ${handler}`, +}; + +let root: string; +let widgetServer: Server; +let widgetOrigin: string; + +beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "hf-script-attribution-")); + widgetServer = createServer((request, response) => { + response.writeHead(200, { "content-type": "text/javascript" }); + response.end(widgetScripts[request.url ?? ""] ?? ""); + }); + await new Promise((resolve) => widgetServer.listen(0, "127.0.0.1", resolve)); + const address = widgetServer.address(); + if (!address || typeof address === "string") throw new Error("widget server has no port"); + widgetOrigin = `http://127.0.0.1:${address.port}`; +}); + +afterAll(() => { + widgetServer?.close(); + rmSync(root, { recursive: true, force: true }); +}); + +interface SessionErrors { + codes: string[]; + scriptLoadFailures: string[]; + pageErrors: string[]; + vfxFailure?: string; +} + +async function timelineWarningCodes(files: Record): Promise { + return (await sessionErrors(files)).codes; +} + +// Console errors can land after the session is ready, so a case that expects one waits for it. +// VFX layers first paint when a frame is captured, so a VFX case captures `frames` first. +async function sessionErrors( + files: Record, + expectedPageError?: string, + frames = 0, +): Promise { + const projectDir = mkdtempSync(join(root, "case-")); + const workDir = mkdtempSync(join(root, "work-")); + for (const [name, body] of Object.entries(files)) writeFileSync(join(projectDir, name), body); + const compiled = await compileForRender( + projectDir, + join(projectDir, "index.html"), + join(workDir, "downloads"), + ); + writeCompiledArtifacts(compiled, workDir, false); + const server = await createFileServer({ + projectDir, + compiledDir: join(workDir, "compiled"), + port: 0, + }); + const session = await createCaptureSession( + server.url, + join(workDir, "frames"), + { width: 160, height: 120, fps: { num: 30, den: 1 }, format: "jpeg", quality: 80 }, + null, + // Past the 2 s script-load grace, so a missing script still takes the fail-fast path. + { browserGpuMode: "software", playerReadyTimeout: 4_000 }, + ); + try { + await initializeSession(session); + for (let frame = 0; frame < frames; frame++) + await captureFrameToBuffer(session, frame, frame / 30); + if (expectedPageError) { + await vi.waitFor(() => expect(session.pageErrors.join("\n")).toContain(expectedPageError), { + timeout: 10_000, + interval: 50, + }); + } + return { + codes: session.warnings + .map((warning) => warning.code) + .filter((code) => code.startsWith("sub_")), + scriptLoadFailures: session.scriptLoadFailures, + pageErrors: session.pageErrors, + vfxFailure: session.vfxFailure, + }; + } finally { + await closeCaptureSession(session).catch(() => {}); + server.close(); + } +} + +// A CSS-only page (nothing waits on a timeline) whose one layer runs a VFX chain. +const vfxComposition = (chain: string, innerStyle = "") => ` + +
+
+
+ +
+
+`; +const vfxNode = (type: string, params: Record) => + JSON.stringify({ version: 1, nodes: [{ type, id: "n1", params }] }); + +// Measured: with a failed chain the layer is not drawn at all, so every frame is wrong. +describe("a failed VFX chain", () => { + it("fails the render at once, whether or not anything waits on a timeline", async () => { + const files = { "index.html": vfxComposition(vfxNode("no-such-effect", {})) }; + await expect(timelineWarningCodes(files)).rejects.toBeInstanceOf(VfxFailureError); + }, 30_000); + + const warp = { waveType: 1, direction: 0, speed: 0, pinning: 1, phase: 0, height: 30, width: 60 }; + + it("leaves a working chain alone", async () => { + const files = { "index.html": vfxComposition(vfxNode("wave-warp", warp)) }; + expect(await timelineWarningCodes(files)).toEqual([]); + }, 30_000); + + // A wrapper with no size reports one empty capture per source: a page error, not a stop. + it("keeps a wrapper with no size a page error, not a stop", async () => { + const files = { "index.html": vfxComposition(vfxNode("wave-warp", warp), "width:0") }; + const errors = await sessionErrors(files, "vfx-frame:", 2); + expect(errors.codes).toEqual([]); + expect(errors.vfxFailure).toBeUndefined(); + expect(errors.pageErrors).toEqual([expect.stringContaining("vfx-frame:")]); + }, 30_000); +}); + +describe("which uncaught errors fail a timeline that never registers", () => { + it.each([ + [ + "an inline script in the composition throws", + { "index.html": composition("") }, + ], + [ + "an inline script in the composition throws after an await", + { + "index.html": composition( + "", + ), + }, + ], + [ + "the composition evals its own throw", + { "index.html": composition(``) }, + ], + [ + "an inline script in the composition's head throws", + { + "index.html": composition("").replace( + "", + "", + ), + }, + ], + [ + "a sub-composition's inline script throws", + { + "index.html": composition("").replace( + ">", + '>
', + ), + "scene.html": + '', + }, + ], + [ + "a script file from the project throws", + { + "index.html": composition(''), + "comp.js": "function build() { var tl = null; tl.timeline(); }\nbuild();", + }, + ], + [ + "a script file from the project does not parse", + { "index.html": composition(''), "comp.js": "var x = {;" }, + ], + [ + "a script from the project is missing", + { "index.html": composition('') }, + ], + ])( + "fails when %s", + async (_case, files) => { + expect(await timelineWarningCodes(files)).toEqual(["sub_timeline_script_failure"]); + }, + 30_000, + ); + + // A load failure cuts the timeline wait short; a scene that threw may still register, so it must not. + it("keeps a scene's throw after it registered as a page error, never a load failure", async () => { + const register = (id: string) => `(window.__timelines = window.__timelines || {}).${id} = {};`; + const files = { + "index.html": composition(``).replace( + ">", + '>
', + ), + "scene.html": ``, + }; + const errors = await sessionErrors(files, "optionalBadge"); + expect(errors.codes).toEqual([]); + expect(errors.scriptLoadFailures).toEqual([]); + expect(errors.pageErrors).toEqual([expect.stringContaining("optionalBadge")]); + }, 30_000); + + it.each(Object.keys(widgetScripts))( + "keeps the error a readiness warning when the cross-origin script %s throws or rejects", + async (script) => { + const files = { + "index.html": composition(``), + }; + expect(await timelineWarningCodes(files)).toEqual(["sub_timeline_readiness_timeout"]); + }, + 30_000, + ); +}); diff --git a/packages/producer/src/services/vfxDeterminism.test.ts b/packages/producer/src/services/vfxDeterminism.test.ts index 7c0624c1902..ab0e5da7b17 100644 --- a/packages/producer/src/services/vfxDeterminism.test.ts +++ b/packages/producer/src/services/vfxDeterminism.test.ts @@ -62,7 +62,7 @@ function waveWarpChain(params: Record): string { * collapses to 0×0 and `drawElementImage` silently draws nothing — the same * collapse the page-side compositor avoids by staging scene copies in a sized frame. */ -function fixture(chain: string, innerStyle = "", hostStyle = ""): string { +function fixture(chain: string, innerStyle = "", hostStart = 0): string { return `
-
+
@@ -573,8 +573,27 @@ describe("data-vfx-chain in the browser", () => { return errors; } - async function bootRuntime(page: Page, errors: string[]): Promise { - await page.addScriptTag({ content: runtime }); + async function bootRuntime( + page: Page, + errors: string[], + registeredStyle?: string, + ): Promise { + // The wrapper registers with `registeredStyle` and gets its own back before any paint. + if (registeredStyle === undefined) await page.addScriptTag({ content: runtime }); + else + await page.evaluate( + (src: string, style: string) => { + const inner = document.querySelector(".hf-vfx-in") as HTMLElement; + const own = inner.style.cssText; + inner.style.cssText = style; + const script = document.createElement("script"); + script.textContent = src; + document.head.append(script); + inner.style.cssText = own; + }, + runtime, + registeredStyle, + ); await page.waitForFunction( () => (window as CompositeWindow).__playerReady === true && @@ -583,12 +602,16 @@ describe("data-vfx-chain in the browser", () => { expect(errors).toEqual([]); } - async function open(html: string, viewport = { width: 320, height: 240 }): Promise { + async function open( + html: string, + viewport = { width: 320, height: 240 }, + registeredStyle?: string, + ): Promise { const page = await browser.newPage(); const errors = watchPage(page); await page.setViewport({ ...viewport, deviceScaleFactor: 1 }); await page.setContent(html); - await bootRuntime(page, errors); + await bootRuntime(page, errors, registeredStyle); return page; } @@ -686,6 +709,96 @@ describe("data-vfx-chain in the browser", () => { } }, 60_000); + const CLEAR = [0, 0, 0, 0]; + // `pixel` null: the host has not started yet. The options override the wrapper's style at + // load and at registration, and the host's start. + it.each< + [ + string, + [style: string, pixel: number[] | null][], + number, + { load?: string; registered?: string; hostStart?: number }?, + ] + >([ + [ + "grows from 0×0: it paints on the next frame, not empty for the rest", + [ + ["width:0px", CLEAR], + ["", red], + ], + 1, + ], + [ + "is sized at registration but first painted at 0×0 (a GSAP fromTo): it paints once it grows", + [ + ["width:0px", CLEAR], + ["", red], + ], + 1, + { registered: "" }, + ], + [ + "shrinks to 0×0: it paints empty, not the previous frame again", + [ + ["", red], + ["width:0px", CLEAR], + ], + 1, + ], + [ + "is hidden at 0×0, then shown and sized: it paints on that frame", + [ + ["width:0px;visibility:hidden", CLEAR], + ["", red], + ], + 0, + ], + [ + "is 0×0 under a host that starts later, then sized: it paints once the host shows", + [ + ["width:0px", null], + ["", red], + ], + 0, + { registered: "", hostStart: 0.02 }, + ], + [ + "is 0×0 at load and sized before the first seek: it paints on that seek", + [["", red]], + 0, + { load: "width:0px" }, + ], + ])( + "a layer that %s", + async (_case, frames, reports, { load = frames[0]![0], registered, hostStart } = {}) => { + const chain = waveWarpChain({ height: 0, width: 93.4 }); + const page = await open(fixture(chain, load, hostStart), undefined, registered); + const reported: string[] = []; + try { + for (const [i, [innerStyle, pixel]] of frames.entries()) { + await page.evaluate((style: string) => { + (document.querySelector(".hf-vfx-in") as HTMLElement).style.cssText = style; + }, innerStyle); + if (pixel === null) { + const armed = await page.evaluate((t: number) => { + (window as CompositeWindow).__player!.renderSeek(t); + return (window as CompositeWindow).__hf_page_composite_pending === true; + }, i / 30); + expect(armed).toBe(false); + continue; + } + expect(await seekAndResolve(page, i / 30)).toBe(true); + expect((await sample(page, [60])).left).toEqual(pixel); + reported.push(...pageErrors.get(page)!.splice(0)); + } + expect(reported).toEqual(Array(reports).fill(expect.stringContaining("vfx-frame:"))); + } finally { + await page.close(); + } + }, + 60_000, + ); + /** * `hyperframes snapshot` (and `check`/`compare`/`validate`/`layout`, and * Studio's thumbnail capture) seek through the same `renderSeek` the engine diff --git a/scripts/test-reachability.json b/scripts/test-reachability.json index 19ca15cddb6..22ffe21a960 100644 --- a/scripts/test-reachability.json +++ b/scripts/test-reachability.json @@ -61,7 +61,7 @@ ], "sources": { "packages/producer/scripts/run-test-lane.mjs": "bff02680f19eebcce0df72cdeedbbc05edfdcede3f3f60658fc0cb18ed8c59f6", - "packages/producer/scripts/test-classification.mjs": "1c0490f70042787a081a47ffe70fb8688785015cbf50fbbce712ba1fa1cca889", + "packages/producer/scripts/test-classification.mjs": "ae0cd9e09bf93df932d41649855f86ebb2230b25aabf3e56355fed8f101a0322", ".github/workflows/ci.yml#job#producer-source-tests": "45da72aa6cf08cdfd5bcf5311e89f12af4842f9eebc6735df19da8c02f4d4864", "packages/producer/package.json#script#test:unit": "ae6beda8319d166774be958ea5593cd27dbf7f6c9bce1d787a3fe28dfc3477b1", "packages/producer/package.json#script#test:integration": "008589f37d026877595a13501cbb66abd5cbe50e21fdcac6904f45466c3f475d",