Files
hyperframes/packages/engine/src/services/frameCapture-subTimelinePoll.test.ts
T
Vance IngallsandClaude Fable 5 54359f3d6a fix(engine): fail-fast the sub-composition timeline wait when a script 404s
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.

- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
  response, listeners that already existed for diagnostics) in
  session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
  2s grace once any script failed, with a loud warning naming the URL(s).
  Late-registering fetch-async comps are unaffected: no script failure means
  the full timeout still applies, and a registration landing inside the
  grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
  "script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
  (worst across sessions) -> render_complete sub_timeline_wait, so the wild
  rate becomes directly trackable instead of setup-histogram forensics.

Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.

Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:09:47 -07:00

67 lines
2.5 KiB
TypeScript

/**
* pollSubCompositionTimelines fail-fast contract: a script resource that
* failed to load can never register its `window.__timelines[id]`, so the
* poll must cut to the short grace window instead of burning the full
* playerReadyTimeout (measured wild: a 705-render spike at the 45s setup
* bucket over 30 days — ~1% of local renders).
*/
import { describe, expect, it, vi } from "vitest";
import type { Page } from "puppeteer-core";
import { pollSubCompositionTimelines } from "./frameCapture.js";
function makeMockPage(evaluateResults: (expr: string) => unknown): Page {
return {
evaluate: vi.fn(async (expr: string) => evaluateResults(expr)),
} as unknown as Page;
}
describe("pollSubCompositionTimelines fail-fast", () => {
it("returns ready and forces a timeline rebind when timelines register", async () => {
const page = makeMockPage(() => true);
const outcome = await pollSubCompositionTimelines(page, 1_000, 10);
expect(outcome).toBe("ready");
// Second evaluate is the __hfForceTimelineRebind call.
expect((page.evaluate as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
});
it("bails after the grace window when a script resource failed to load", async () => {
const page = makeMockPage((expr) =>
expr.includes("__hfForceTimelineRebind") ? undefined : false,
);
const started = Date.now();
const outcome = await pollSubCompositionTimelines(
page,
60_000, // full timeout must NOT be waited
10,
() => ["http://localhost/animations.js"],
50, // grace
);
expect(outcome).toBe("script_failure");
expect(Date.now() - started).toBeLessThan(5_000);
});
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, () => []);
expect(outcome).toBe("timeout");
});
it("keeps waiting through the grace window when failures appear but timelines register late", async () => {
let calls = 0;
const page = makeMockPage((expr) => {
if (expr.includes("__hfForceTimelineRebind")) return undefined;
calls++;
return calls >= 3; // registers on the 3rd poll tick, inside the grace window
});
const outcome = await pollSubCompositionTimelines(
page,
60_000,
10,
() => ["http://localhost/late.js"],
10_000, // generous grace — registration lands first
);
expect(outcome).toBe("ready");
});
});