@hyperframes/engine 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * assertSwiftShader — verify Chrome's WebGL is rendered by SwiftShader.
3
+ *
4
+ * Distributed renders pixel-lock on the GPU backend: hardware GL is bitwise
5
+ * unstable across worker machines (different drivers, driver versions, GL
6
+ * extension sets, even differing fp32 rounding on the same vendor). Chunk
7
+ * workers launch Chrome with `--use-gl=swiftshader --use-angle=swiftshader`
8
+ * so every worker uses the same pure-software GL implementation.
9
+ *
10
+ * Those Chrome flags are advisory: a misconfigured base image, a missing
11
+ * SwiftShader library, or a `chrome://gpu` blocklist override can silently
12
+ * downgrade to system GL. The distributed pipeline cannot detect the
13
+ * downgrade by sampling pixels (one machine = one render), so we read
14
+ * `chrome://gpu` directly after launch and refuse to render if the active
15
+ * GL renderer is anything other than SwiftShader.
16
+ */
17
+ /**
18
+ * Error code classifying this failure as non-retryable for distributed
19
+ * workflow adapters — a downgraded GPU on a worker will not heal on retry.
20
+ */
21
+ export const BROWSER_GPU_NOT_SOFTWARE = "BROWSER_GPU_NOT_SOFTWARE";
22
+ /**
23
+ * Error thrown when chrome://gpu reports a non-SwiftShader WebGL backend.
24
+ *
25
+ * Carries a `code` property so the adapter can match on it without parsing
26
+ * the message string — Temporal/Step Functions retry policies key off the
27
+ * code, not the message.
28
+ */
29
+ export class SwiftShaderAssertionError extends Error {
30
+ code = BROWSER_GPU_NOT_SOFTWARE;
31
+ vendor;
32
+ renderer;
33
+ constructor(message, vendor, renderer) {
34
+ super(message);
35
+ this.name = "SwiftShaderAssertionError";
36
+ this.vendor = vendor;
37
+ this.renderer = renderer;
38
+ }
39
+ }
40
+ /**
41
+ * SwiftShader identifies itself on `chrome://gpu` and in
42
+ * `WEBGL_debug_renderer_info` with this exact vendor string. Locking to
43
+ * Google's own GL string (rather than a substring match on "swiftshader")
44
+ * avoids false-positives from third-party ANGLE backends that incidentally
45
+ * mention SwiftShader in unrelated diagnostic text.
46
+ */
47
+ const SWIFTSHADER_VENDOR_SIGNATURE = "Google Inc. (Google)";
48
+ /**
49
+ * Renderer string contains the literal "SwiftShader" token. We match
50
+ * case-insensitively and only require the substring; Chrome occasionally
51
+ * appends a build suffix (e.g. " Vulkan 1.3").
52
+ */
53
+ const SWIFTSHADER_RENDERER_TOKEN = "swiftshader";
54
+ /**
55
+ * Read the WebGL vendor/renderer strings from a live `chrome://gpu` page.
56
+ *
57
+ * Extracted from `assertSwiftShader` so tests can stub the navigation +
58
+ * extraction step. Returns the raw values; callers decide how to interpret
59
+ * them. Both fields are best-effort — Chrome returns empty strings if the
60
+ * GPU info table hasn't populated yet, which the caller treats as failure.
61
+ */
62
+ export async function readWebGlVendorInfo(page) {
63
+ await page.goto("chrome://gpu", { waitUntil: "domcontentloaded", timeout: 30_000 });
64
+ // The "GL_VENDOR" / "GL_RENDERER" rows live inside <info-view> shadow DOM
65
+ // in modern Chrome. We pull the structured `info_log_` payload off the
66
+ // page-level globals instead of querying the DOM, since the DOM layout has
67
+ // drifted across versions.
68
+ const info = await page.evaluate(() => {
69
+ const w = window;
70
+ const rows = w.browserBridge?.gpuInfo_?.graphics_info?.basic_info ?? [];
71
+ let vendor = "";
72
+ let renderer = "";
73
+ for (const row of rows) {
74
+ if (typeof row.description !== "string" || typeof row.value !== "string")
75
+ continue;
76
+ if (row.description === "GL_VENDOR")
77
+ vendor = row.value;
78
+ else if (row.description === "GL_RENDERER")
79
+ renderer = row.value;
80
+ }
81
+ return { vendor, renderer };
82
+ });
83
+ return info;
84
+ }
85
+ /**
86
+ * Validate that the active WebGL renderer is SwiftShader. Throws
87
+ * `SwiftShaderAssertionError` otherwise.
88
+ *
89
+ * Pass an optional `readInfo` override for tests that don't have a real
90
+ * Puppeteer `Page`. The default implementation navigates to `chrome://gpu`
91
+ * and parses the GL_VENDOR / GL_RENDERER rows.
92
+ */
93
+ export async function assertSwiftShader(page, readInfo = readWebGlVendorInfo) {
94
+ const { vendor, renderer } = await readInfo(page);
95
+ const vendorMatches = vendor.trim() === SWIFTSHADER_VENDOR_SIGNATURE;
96
+ const rendererMatches = renderer.toLowerCase().includes(SWIFTSHADER_RENDERER_TOKEN);
97
+ if (vendorMatches && rendererMatches)
98
+ return;
99
+ throw new SwiftShaderAssertionError(`[assertSwiftShader] Chrome reported a non-SwiftShader WebGL backend. ` +
100
+ `Distributed renders require pure-software GL for pixel-identical retries. ` +
101
+ `Got vendor=${JSON.stringify(vendor)} renderer=${JSON.stringify(renderer)}; ` +
102
+ `expected vendor=${JSON.stringify(SWIFTSHADER_VENDOR_SIGNATURE)} renderer to contain "SwiftShader". ` +
103
+ `Ensure Chrome was launched with --use-gl=swiftshader --use-angle=swiftshader and that the ` +
104
+ `SwiftShader libraries are present in the runtime image.`, vendor, renderer);
105
+ }
106
+ //# sourceMappingURL=assertSwiftShader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assertSwiftShader.js","sourceRoot":"","sources":["../../src/utils/assertSwiftShader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH;;;GAGG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAC;AAEnE;;;;;;GAMG;AACH,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IACzC,IAAI,GAAoC,wBAAwB,CAAC;IACjE,MAAM,CAAS;IACf,QAAQ,CAAS;IAE1B,YAAY,OAAe,EAAE,MAAc,EAAE,QAAgB;QAC3D,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;QACxC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,4BAA4B,GAAG,sBAAsB,CAAC;AAC5D;;;;GAIG;AACH,MAAM,0BAA0B,GAAG,aAAa,CAAC;AAOjD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,IAAU;IAClD,MAAM,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IACpF,0EAA0E;IAC1E,uEAAuE;IACvE,2EAA2E;IAC3E,2BAA2B;IAC3B,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAc,EAAE;QAG/C,MAAM,CAAC,GAAG,MAA+D,CAAC;QAC1E,MAAM,IAAI,GAAU,CAAC,CAAC,aAAa,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,IAAI,EAAE,CAAC;QAC/E,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,QAAQ,GAAG,EAAE,CAAC;QAClB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,IAAI,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;gBAAE,SAAS;YACnF,IAAI,GAAG,CAAC,WAAW,KAAK,WAAW;gBAAE,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC;iBACnD,IAAI,GAAG,CAAC,WAAW,KAAK,aAAa;gBAAE,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC;QACnE,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,IAAU,EACV,WAA+C,mBAAmB;IAElE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;IAElD,MAAM,aAAa,GAAG,MAAM,CAAC,IAAI,EAAE,KAAK,4BAA4B,CAAC;IACrE,MAAM,eAAe,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC,CAAC;IAEpF,IAAI,aAAa,IAAI,eAAe;QAAE,OAAO;IAE7C,MAAM,IAAI,yBAAyB,CACjC,uEAAuE;QACrE,4EAA4E;QAC5E,cAAc,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,aAAa,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI;QAC7E,mBAAmB,IAAI,CAAC,SAAS,CAAC,4BAA4B,CAAC,sCAAsC;QACrG,4FAA4F;QAC5F,yDAAyD,EAC3D,MAAM,EACN,QAAQ,CACT,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  "linkedom": "^0.18.12",
20
20
  "puppeteer": "^24.0.0",
21
21
  "puppeteer-core": "^24.39.1",
22
- "@hyperframes/core": "^0.6.4"
22
+ "@hyperframes/core": "^0.6.5"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^25.0.10",
package/src/index.ts CHANGED
@@ -65,11 +65,13 @@ export {
65
65
  closeCaptureSession,
66
66
  captureFrame,
67
67
  captureFrameToBuffer,
68
+ discardWarmupCapture,
68
69
  getCompositionDuration,
69
70
  getCapturePerfSummary,
70
71
  prepareCaptureSessionForReuse,
71
72
  type CaptureSession,
72
73
  type BeforeCaptureHook,
74
+ type DiscardWarmupInnerCapture,
73
75
  } from "./services/frameCapture.js";
74
76
 
75
77
  // ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
@@ -155,6 +157,13 @@ export {
155
157
  // ── Utilities ──────────────────────────────────────────────────────────────────
156
158
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
157
159
 
160
+ export {
161
+ assertSwiftShader,
162
+ readWebGlVendorInfo,
163
+ SwiftShaderAssertionError,
164
+ BROWSER_GPU_NOT_SOFTWARE,
165
+ } from "./utils/assertSwiftShader.js";
166
+
158
167
  export {
159
168
  extractMediaMetadata,
160
169
  extractVideoMetadata,
@@ -166,7 +175,12 @@ export {
166
175
  } from "./utils/ffprobe.js";
167
176
 
168
177
  export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
169
- export { runFfmpeg, type RunFfmpegOptions, type RunFfmpegResult } from "./utils/runFfmpeg.js";
178
+ export {
179
+ runFfmpeg,
180
+ formatFfmpegError,
181
+ type RunFfmpegOptions,
182
+ type RunFfmpegResult,
183
+ } from "./utils/runFfmpeg.js";
170
184
 
171
185
  export {
172
186
  decodePng,
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for `discardWarmupCapture` — the helper distributed chunk workers
3
+ * run before their first real capture to prime `lastFrameCache`.
4
+ *
5
+ * The helper is a thin wrapper around the inner `captureFrameCore`
6
+ * machinery, so its testable contract is post-conditional rather than
7
+ * pixel-level:
8
+ *
9
+ * 1. The wrapper invokes the inner capture exactly once with the supplied
10
+ * `(frameIndex, time)`.
11
+ * 2. After the wrapper returns, the session's perf and BeginFrame damage
12
+ * counters look exactly as they did before — even though the inner
13
+ * capture mutated them.
14
+ * 3. The wrapper writes no file to disk (no path is plumbed through;
15
+ * asserted indirectly by observing that `outputDir` is never read).
16
+ * 4. State is restored even if the inner capture throws.
17
+ *
18
+ * The inner capture is stubbed (the helper accepts an injectable
19
+ * `innerCapture` for exactly this reason). We don't need a real Chrome.
20
+ */
21
+
22
+ import { describe, expect, it } from "vitest";
23
+ import { existsSync, readdirSync, mkdtempSync, rmSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+ import { discardWarmupCapture, type CaptureSession } from "./frameCapture.js";
27
+
28
+ function makeFakeSession(): CaptureSession {
29
+ // The discardWarmupCapture wrapper only reads `capturePerf`,
30
+ // `beginFrameHasDamageCount`, `beginFrameNoDamageCount`. Everything else
31
+ // is unused — leave it as bare-minimum stubs cast through `unknown`.
32
+ return {
33
+ browser: {} as unknown,
34
+ page: {} as unknown,
35
+ options: { width: 1, height: 1, fps: { num: 30, den: 1 } },
36
+ serverUrl: "",
37
+ outputDir: mkdtempSync(join(tmpdir(), "discard-warmup-")),
38
+ onBeforeCapture: null,
39
+ isInitialized: true,
40
+ browserConsoleBuffer: [],
41
+ capturePerf: {
42
+ frames: 7,
43
+ seekMs: 100,
44
+ beforeCaptureMs: 50,
45
+ screenshotMs: 200,
46
+ totalMs: 350,
47
+ },
48
+ captureMode: "beginframe",
49
+ beginFrameTimeTicks: 0,
50
+ beginFrameIntervalMs: 33,
51
+ beginFrameHasDamageCount: 4,
52
+ beginFrameNoDamageCount: 3,
53
+ } as unknown as CaptureSession;
54
+ }
55
+
56
+ describe("discardWarmupCapture", () => {
57
+ it("calls the inner capture exactly once with (frameIndex=0, time=0) by default", async () => {
58
+ const session = makeFakeSession();
59
+ try {
60
+ let calls = 0;
61
+ let receivedFrameIndex = -1;
62
+ let receivedTime = -1;
63
+ await discardWarmupCapture(session, undefined, undefined, async (_s, fi, t) => {
64
+ calls++;
65
+ receivedFrameIndex = fi;
66
+ receivedTime = t;
67
+ return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
68
+ });
69
+ expect(calls).toBe(1);
70
+ expect(receivedFrameIndex).toBe(0);
71
+ expect(receivedTime).toBe(0);
72
+ } finally {
73
+ rmSync(session.outputDir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ it("passes through caller-supplied (frameIndex, time)", async () => {
78
+ const session = makeFakeSession();
79
+ try {
80
+ let received: { fi: number; t: number } | null = null;
81
+ await discardWarmupCapture(session, 240, 8, async (_s, fi, t) => {
82
+ received = { fi, t };
83
+ return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
84
+ });
85
+ expect(received).toEqual({ fi: 240, t: 8 });
86
+ } finally {
87
+ rmSync(session.outputDir, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it("restores perf counters after the inner capture mutates them", async () => {
92
+ const session = makeFakeSession();
93
+ const before = { ...session.capturePerf };
94
+ try {
95
+ await discardWarmupCapture(session, 0, 0, async (s) => {
96
+ s.capturePerf.frames += 1;
97
+ s.capturePerf.seekMs += 12;
98
+ s.capturePerf.beforeCaptureMs += 5;
99
+ s.capturePerf.screenshotMs += 33;
100
+ s.capturePerf.totalMs += 50;
101
+ return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 50 };
102
+ });
103
+ expect(session.capturePerf).toEqual(before);
104
+ } finally {
105
+ rmSync(session.outputDir, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ it("restores BeginFrame damage counters after the inner capture mutates them", async () => {
110
+ const session = makeFakeSession();
111
+ const hasBefore = session.beginFrameHasDamageCount;
112
+ const noBefore = session.beginFrameNoDamageCount;
113
+ try {
114
+ await discardWarmupCapture(session, 0, 0, async (s) => {
115
+ s.beginFrameHasDamageCount += 10;
116
+ s.beginFrameNoDamageCount += 1;
117
+ return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 0 };
118
+ });
119
+ expect(session.beginFrameHasDamageCount).toBe(hasBefore);
120
+ expect(session.beginFrameNoDamageCount).toBe(noBefore);
121
+ } finally {
122
+ rmSync(session.outputDir, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ it("restores state even when the inner capture throws", async () => {
127
+ const session = makeFakeSession();
128
+ const perfBefore = { ...session.capturePerf };
129
+ const hasBefore = session.beginFrameHasDamageCount;
130
+ const noBefore = session.beginFrameNoDamageCount;
131
+ try {
132
+ let thrown: unknown;
133
+ try {
134
+ await discardWarmupCapture(session, 0, 0, async (s) => {
135
+ s.capturePerf.frames += 5;
136
+ s.beginFrameNoDamageCount += 2;
137
+ throw new Error("simulated capture failure");
138
+ });
139
+ } catch (err) {
140
+ thrown = err;
141
+ }
142
+ expect((thrown as Error).message).toBe("simulated capture failure");
143
+ // The whole point of `finally { restore }`: failure must not leak
144
+ // inflated counters into the real capture summary.
145
+ expect(session.capturePerf).toEqual(perfBefore);
146
+ expect(session.beginFrameHasDamageCount).toBe(hasBefore);
147
+ expect(session.beginFrameNoDamageCount).toBe(noBefore);
148
+ } finally {
149
+ rmSync(session.outputDir, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ it("writes no output file to the session's outputDir", async () => {
154
+ const session = makeFakeSession();
155
+ try {
156
+ expect(existsSync(session.outputDir)).toBe(true);
157
+ const before = readdirSync(session.outputDir);
158
+ await discardWarmupCapture(session, 0, 0, async () => ({
159
+ buffer: Buffer.from([0xff, 0xff, 0xff]),
160
+ quantizedTime: 0,
161
+ captureTimeMs: 0,
162
+ }));
163
+ const after = readdirSync(session.outputDir);
164
+ expect(after).toEqual(before);
165
+ } finally {
166
+ rmSync(session.outputDir, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ it("returns undefined (no result type, so the buffer can't escape)", async () => {
171
+ const session = makeFakeSession();
172
+ try {
173
+ const result = await discardWarmupCapture(session, 0, 0, async () => ({
174
+ buffer: Buffer.from([0x01]),
175
+ quantizedTime: 0,
176
+ captureTimeMs: 1,
177
+ }));
178
+ expect(result).toBeUndefined();
179
+ } finally {
180
+ rmSync(session.outputDir, { recursive: true, force: true });
181
+ }
182
+ });
183
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Tests for the `driveWarmupTicks` BeginFrame warmup driver.
3
+ *
4
+ * The wall-clock warmup loop in `initializeSession` accumulates a different
5
+ * number of ticks per host CPU speed, which shifts `beginFrameTimeTicks`
6
+ * and breaks byte-identical captures across distributed workers.
7
+ *
8
+ * `lockWarmupTicks=true` clamps the loop to a fixed iteration count
9
+ * (`LOCKED_WARMUP_TICKS = 60`) so the baseline becomes host-independent.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ LOCKED_WARMUP_TICKS,
15
+ driveWarmupTicks,
16
+ warmupFrameTimeTicks,
17
+ type WarmupTickState,
18
+ } from "./frameCapture.js";
19
+
20
+ function makeState(): WarmupTickState {
21
+ return { running: true, ticks: 0 };
22
+ }
23
+
24
+ // Run the warmup driver "concurrently" with a simulated page-load that
25
+ // flips `state.running` to false after `pageLoadIterations` of the
26
+ // `tick` callback. Returns the final state.
27
+ async function runWithSimulatedPageLoad(
28
+ lockWarmupTicks: boolean,
29
+ pageLoadIterations: number,
30
+ tickDelay: () => Promise<void> = () => Promise.resolve(),
31
+ ): Promise<WarmupTickState> {
32
+ const state = makeState();
33
+ const intervalMs = 33;
34
+
35
+ const tick = async (): Promise<void> => {
36
+ if (state.ticks + 1 === pageLoadIterations) {
37
+ state.running = false;
38
+ }
39
+ await tickDelay();
40
+ };
41
+
42
+ await driveWarmupTicks(
43
+ {
44
+ intervalMs,
45
+ lockWarmupTicks,
46
+ tick,
47
+ sleep: () => Promise.resolve(),
48
+ },
49
+ state,
50
+ );
51
+ return state;
52
+ }
53
+
54
+ describe("driveWarmupTicks — unlocked", () => {
55
+ it("stops when state.running flips false", async () => {
56
+ const state = await runWithSimulatedPageLoad(false, 10);
57
+ expect(state.ticks).toBe(10);
58
+ });
59
+
60
+ it("yields different tick counts for different simulated page-load lengths", async () => {
61
+ const fast = await runWithSimulatedPageLoad(false, 5);
62
+ const slow = await runWithSimulatedPageLoad(false, 50);
63
+ expect(fast.ticks).toBe(5);
64
+ expect(slow.ticks).toBe(50);
65
+ expect(fast.ticks).not.toBe(slow.ticks);
66
+ });
67
+
68
+ it("frame time derived as ticks * intervalMs", async () => {
69
+ const state = await runWithSimulatedPageLoad(false, 7);
70
+ expect(warmupFrameTimeTicks(state, 33)).toBe(7 * 33);
71
+ });
72
+
73
+ it("does not iterate when state.running starts false", async () => {
74
+ const state: WarmupTickState = { running: false, ticks: 0 };
75
+ await driveWarmupTicks(
76
+ {
77
+ intervalMs: 33,
78
+ lockWarmupTicks: false,
79
+ tick: async () => {},
80
+ sleep: () => Promise.resolve(),
81
+ },
82
+ state,
83
+ );
84
+ expect(state.ticks).toBe(0);
85
+ });
86
+
87
+ it("counts ticks even when tick callback throws (page not ready yet)", async () => {
88
+ // Pre-acquisition errors are swallowed — the loop must keep spinning so
89
+ // it can drive the page once CDP comes up.
90
+ let stopAt = 5;
91
+ const state = makeState();
92
+ await driveWarmupTicks(
93
+ {
94
+ intervalMs: 33,
95
+ lockWarmupTicks: false,
96
+ tick: async () => {
97
+ if (--stopAt <= 0) state.running = false;
98
+ throw new Error("CDP not ready");
99
+ },
100
+ sleep: () => Promise.resolve(),
101
+ },
102
+ state,
103
+ );
104
+ // We don't count ticks on throw, but the loop kept running until
105
+ // state.running flipped — so it exited cleanly.
106
+ expect(state.running).toBe(false);
107
+ expect(state.ticks).toBe(0);
108
+ });
109
+ });
110
+
111
+ describe("driveWarmupTicks — locked", () => {
112
+ it("runs exactly LOCKED_WARMUP_TICKS iterations regardless of state.running", async () => {
113
+ // Simulate a fast page load: state.running flips false after just 5 ticks.
114
+ // The locked loop must still run to LOCKED_WARMUP_TICKS.
115
+ const state = await runWithSimulatedPageLoad(true, 5);
116
+ expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
117
+ });
118
+
119
+ it("runs exactly LOCKED_WARMUP_TICKS iterations on slow simulated load", async () => {
120
+ // state.running stays true past the locked count — loop still stops at
121
+ // LOCKED_WARMUP_TICKS.
122
+ const state = await runWithSimulatedPageLoad(true, 9999);
123
+ expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
124
+ });
125
+
126
+ it("yields IDENTICAL tick counts across simulated fast and slow loads", async () => {
127
+ // THE determinism property the locked mode exists for.
128
+ const fast = await runWithSimulatedPageLoad(true, 5);
129
+ const slow = await runWithSimulatedPageLoad(true, 200);
130
+ expect(fast.ticks).toBe(slow.ticks);
131
+ expect(fast.ticks).toBe(LOCKED_WARMUP_TICKS);
132
+ expect(warmupFrameTimeTicks(fast, 33)).toBe(warmupFrameTimeTicks(slow, 33));
133
+ });
134
+
135
+ it("yields LOCKED_WARMUP_TICKS even when state.running starts false", async () => {
136
+ // Guards the locked contract independently of the caller's running flag.
137
+ const state: WarmupTickState = { running: false, ticks: 0 };
138
+ await driveWarmupTicks(
139
+ {
140
+ intervalMs: 33,
141
+ lockWarmupTicks: true,
142
+ tick: async () => {},
143
+ sleep: () => Promise.resolve(),
144
+ },
145
+ state,
146
+ );
147
+ expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
148
+ });
149
+
150
+ it("does not exceed LOCKED_WARMUP_TICKS even if the caller never stops it", async () => {
151
+ const state = makeState();
152
+ let observedMax = 0;
153
+ await driveWarmupTicks(
154
+ {
155
+ intervalMs: 33,
156
+ lockWarmupTicks: true,
157
+ tick: async () => {
158
+ observedMax = Math.max(observedMax, state.ticks + 1);
159
+ },
160
+ sleep: () => Promise.resolve(),
161
+ },
162
+ state,
163
+ );
164
+ expect(observedMax).toBe(LOCKED_WARMUP_TICKS);
165
+ expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
166
+ });
167
+
168
+ it("baseline frame time matches (LOCKED_WARMUP_TICKS * intervalMs)", async () => {
169
+ // initializeSession derives session.beginFrameTimeTicks from the final
170
+ // tick count — locked mode pins this to a host-independent value.
171
+ const state = await runWithSimulatedPageLoad(true, 5);
172
+ expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
173
+ });
174
+ });