@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.
@@ -78,6 +78,81 @@ export interface CaptureSession {
78
78
  const BROWSER_CONSOLE_BUFFER_SIZE = 200;
79
79
  const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
80
80
 
81
+ /**
82
+ * Fixed warmup-loop iteration count used when `CaptureOptions.lockWarmupTicks`
83
+ * is `true`. Picked to roughly match the median tick count observed by the
84
+ * unlocked wall-clock loop during a typical 2s page load at 30fps — so
85
+ * `beginFrameTimeTicks` lands in a similar range regardless of host speed.
86
+ */
87
+ export const LOCKED_WARMUP_TICKS = 60;
88
+
89
+ /**
90
+ * Internal driver for the BeginFrame warmup loop.
91
+ *
92
+ * - Unlocked: exits as soon as `state.running` flips to `false`. Tick count
93
+ * varies with wall-clock page-load time.
94
+ * - Locked: ignores `state.running` entirely and exits once it has driven
95
+ * exactly `LOCKED_WARMUP_TICKS` iterations. Caller awaits this promise
96
+ * after page-readiness so `session.beginFrameTimeTicks` is identical
97
+ * across hosts.
98
+ * - `tick` errors are swallowed (Chrome's `beginFrame` is best-effort
99
+ * during page load — the page hasn't installed CDP listeners yet). When
100
+ * `tick` throws, the iteration count does NOT advance.
101
+ *
102
+ * `intervalMs` is the BeginFrame interval (≈33ms at 30fps).
103
+ *
104
+ * `frameTimeTicks` is derived as `ticks * intervalMs` and exposed via
105
+ * {@link warmupFrameTimeTicks} — not stored on the state, to keep `ticks`
106
+ * the single source of truth.
107
+ */
108
+ export interface WarmupTickState {
109
+ running: boolean;
110
+ ticks: number;
111
+ }
112
+
113
+ export interface WarmupTickOptions {
114
+ intervalMs: number;
115
+ lockWarmupTicks: boolean;
116
+ tick: (frameTimeTicks: number, intervalMs: number) => Promise<void>;
117
+ /** Injectable so tests can advance "time" without real setTimeout. */
118
+ sleep?: (ms: number) => Promise<void>;
119
+ }
120
+
121
+ const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
122
+
123
+ /**
124
+ * Derive the current simulated frame time from a warmup state. Single source
125
+ * of truth so tests and callers stay in sync.
126
+ */
127
+ export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number): number {
128
+ return state.ticks * intervalMs;
129
+ }
130
+
131
+ export async function driveWarmupTicks(
132
+ options: WarmupTickOptions,
133
+ state: WarmupTickState,
134
+ ): Promise<void> {
135
+ const sleep = options.sleep ?? realSleep;
136
+ while (true) {
137
+ if (options.lockWarmupTicks) {
138
+ // Locked mode exits on the iteration count, ignoring `state.running` —
139
+ // the caller flips `running=false` after page-readiness but we keep
140
+ // ticking until LOCKED_WARMUP_TICKS so the count is host-independent.
141
+ if (state.ticks >= LOCKED_WARMUP_TICKS) return;
142
+ } else {
143
+ // Unlocked mode is wall-clock-bounded.
144
+ if (!state.running) return;
145
+ }
146
+ try {
147
+ await options.tick(state.ticks * options.intervalMs, options.intervalMs);
148
+ state.ticks += 1;
149
+ } catch {
150
+ // Page not ready yet; keep spinning.
151
+ }
152
+ await sleep(options.intervalMs);
153
+ }
154
+ }
155
+
81
156
  async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
82
157
  let timedOut = false;
83
158
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -447,38 +522,53 @@ export async function initializeSession(session: CaptureSession): Promise<void>
447
522
 
448
523
  // In BeginFrame mode, Chrome's event loop is paused until we issue frames.
449
524
  // Start a warmup loop to drive rAF/setTimeout callbacks during page load.
450
- let warmupRunning = true;
451
- let warmupTicks = 0;
452
- let warmupFrameTime = 0;
525
+ //
526
+ // The unlocked path runs while `warmupState.running` stays true — wall-
527
+ // clock-bounded. The locked path (`options.lockWarmupTicks`) additionally
528
+ // exits at exactly `LOCKED_WARMUP_TICKS` iterations so `beginFrameTimeTicks`
529
+ // is deterministic across hosts with different page-load latencies.
453
530
  const warmupIntervalMs = 33; // ~30fps
531
+ const warmupState: WarmupTickState = {
532
+ running: true,
533
+ ticks: 0,
534
+ };
535
+ const lockWarmupTicks = session.options.lockWarmupTicks === true;
454
536
  let warmupClient: import("puppeteer-core").CDPSession | null = null;
455
537
 
456
- const warmupLoop = async () => {
538
+ const acquireWarmupClient = async (): Promise<void> => {
457
539
  try {
458
540
  warmupClient = await getCdpSession(page);
459
541
  await warmupClient.send("HeadlessExperimental.enable");
460
542
  } catch {
461
543
  /* page not ready yet */
462
544
  }
545
+ };
463
546
 
464
- while (warmupRunning) {
465
- if (warmupClient) {
466
- try {
547
+ const warmupLoopPromise = (async () => {
548
+ await acquireWarmupClient();
549
+ await driveWarmupTicks(
550
+ {
551
+ intervalMs: warmupIntervalMs,
552
+ lockWarmupTicks,
553
+ tick: async (frameTimeTicks, interval) => {
554
+ if (!warmupClient) {
555
+ // No CDP yet — let driveWarmupTicks count the tick anyway so the
556
+ // locked iteration count is reached deterministically. Throwing
557
+ // would skip the ticks++ increment, leaking host-load variance
558
+ // back into the count.
559
+ return;
560
+ }
467
561
  await warmupClient.send("HeadlessExperimental.beginFrame", {
468
- frameTimeTicks: warmupFrameTime,
469
- interval: warmupIntervalMs,
562
+ frameTimeTicks,
563
+ interval,
470
564
  noDisplayUpdates: true,
471
565
  });
472
- warmupFrameTime += warmupIntervalMs;
473
- warmupTicks++;
474
- } catch {
475
- /* ignore warmup errors */
476
- }
477
- }
478
- await new Promise((r) => setTimeout(r, warmupIntervalMs));
479
- }
480
- };
481
- warmupLoop().catch(() => {});
566
+ },
567
+ },
568
+ warmupState,
569
+ );
570
+ })();
571
+ warmupLoopPromise.catch(() => {});
482
572
 
483
573
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
484
574
 
@@ -497,7 +587,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
497
587
  `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
498
588
  );
499
589
  if (!pageReady) {
500
- warmupRunning = false;
590
+ warmupState.running = false;
501
591
  throw new Error(
502
592
  `[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
503
593
  );
@@ -521,11 +611,18 @@ export async function initializeSession(session: CaptureSession): Promise<void>
521
611
  await page.evaluate(`document.fonts?.ready`);
522
612
  await waitForOptionalTailwindReady(page, pageReadyTimeout);
523
613
 
524
- // Stop warmup
525
- warmupRunning = false;
614
+ // Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking
615
+ // until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is
616
+ // exact before deriving the baseline.
617
+ warmupState.running = false;
618
+ if (lockWarmupTicks) {
619
+ await warmupLoopPromise.catch(() => {});
620
+ }
526
621
 
527
- // Set base frame time ticks past warmup range
528
- session.beginFrameTimeTicks = (warmupTicks + 10) * session.beginFrameIntervalMs;
622
+ // Set base frame time ticks past warmup range. Locked mode pins to the
623
+ // constant so chunk workers on different hosts compute the same baseline.
624
+ const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
625
+ session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
529
626
 
530
627
  // For PNG captures, inject the transparent-background override + stylesheet
531
628
  // (see the screenshot-mode branch above for the rationale). BeginFrame mode
@@ -712,6 +809,69 @@ export async function captureFrameToBuffer(
712
809
  return { buffer, captureTimeMs };
713
810
  }
714
811
 
812
+ /**
813
+ * Type of the "inner capture" function consumed by
814
+ * {@link discardWarmupCapture}. Matches the real `captureFrameCore` signature
815
+ * with the buffer-bearing result trimmed to what the caller actually uses
816
+ * (the wrapper never inspects the buffer). Exposed so unit tests can inject
817
+ * a stub instead of driving Chrome end-to-end.
818
+ */
819
+ export type DiscardWarmupInnerCapture = (
820
+ session: CaptureSession,
821
+ frameIndex: number,
822
+ time: number,
823
+ ) => Promise<{ buffer: Buffer; quantizedTime: number; captureTimeMs: number }>;
824
+
825
+ /**
826
+ * Perform one capture, throw away the buffer, and restore any session
827
+ * side-effects (perf counters, BeginFrame damage tallies) so downstream
828
+ * captures see state identical to a fresh session.
829
+ *
830
+ * Distributed chunk workers need this because Chrome's BeginFrame screenshot
831
+ * pipeline maintains a per-process `lastFrameCache`: when a captured frame's
832
+ * `hasDamage` reports `false`, the screenshot path returns the previously
833
+ * captured buffer. For chunk N (N > 0) the worker has no prior frame in its
834
+ * cache, so the very first capture's `hasDamage` reporting diverges from
835
+ * what an in-process render at the same absolute frame index would see (the
836
+ * in-process renderer always has frame N-1 cached). One discard capture
837
+ * before the first real capture primes the cache.
838
+ *
839
+ * The function intentionally restores perf state so the warmup capture does
840
+ * NOT bias `getCapturePerfSummary()`'s per-frame averages.
841
+ *
842
+ * No file is written; the buffer is discarded.
843
+ *
844
+ * @param session — initialized capture session
845
+ * @param frameIndex — frame index to warm up with (default 0). Chunk
846
+ * workers typically pass their chunk's first absolute frame index.
847
+ * @param time — time in seconds (default 0). Chunk workers typically pass
848
+ * the corresponding `frameIndex / fps`.
849
+ * @param innerCapture — injectable for tests; defaults to the real
850
+ * `captureFrameCore`.
851
+ */
852
+ export async function discardWarmupCapture(
853
+ session: CaptureSession,
854
+ frameIndex: number = 0,
855
+ time: number = 0,
856
+ innerCapture: DiscardWarmupInnerCapture = captureFrameCore,
857
+ ): Promise<void> {
858
+ // Snapshot the side-effect counters captureFrameCore mutates. We use a
859
+ // shallow `{...}` for capturePerf because all five fields are primitive
860
+ // numbers — no nested state to deep-copy.
861
+ const perfBefore = { ...session.capturePerf };
862
+ const hasDamageBefore = session.beginFrameHasDamageCount;
863
+ const noDamageBefore = session.beginFrameNoDamageCount;
864
+ try {
865
+ await innerCapture(session, frameIndex, time);
866
+ } finally {
867
+ // Always restore — even on error. A failed warmup capture should not
868
+ // leak inflated perf counters into the real capture summary.
869
+ session.capturePerf = perfBefore;
870
+ session.beginFrameHasDamageCount = hasDamageBefore;
871
+ session.beginFrameNoDamageCount = noDamageBefore;
872
+ }
873
+ }
874
+
715
875
  export async function closeCaptureSession(session: CaptureSession): Promise<void> {
716
876
  // INVARIANT: closeCaptureSession is idempotent. The renderOrchestrator HDR
717
877
  // cleanup path tracks a `domSessionClosed` flag and may still re-call this
package/src/types.ts CHANGED
@@ -120,6 +120,20 @@ export interface CaptureOptions {
120
120
  * `--variables-file <path>`. Must be a JSON-serializable plain object.
121
121
  */
122
122
  variables?: Record<string, unknown>;
123
+ /**
124
+ * When `true`, the BeginFrame warmup loop driven during page navigation
125
+ * runs exactly `LOCKED_WARMUP_TICKS` (60) iterations regardless of how
126
+ * long the page load takes, making `session.beginFrameTimeTicks`
127
+ * deterministic across machines with different page-load latencies.
128
+ *
129
+ * Default `false`: wall-clock-bounded driver — ticks until page-readiness
130
+ * completes, accumulating whatever count the host CPU manages. Preserves
131
+ * the in-process renderer's BeginFrame timing baselines.
132
+ *
133
+ * Has no effect outside BeginFrame mode (screenshot capture never runs a
134
+ * warmup loop).
135
+ */
136
+ lockWarmupTicks?: boolean;
123
137
  }
124
138
 
125
139
  export interface CaptureVideoMetadataHint {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for assertSwiftShader and its companion readWebGlVendorInfo helper.
3
+ *
4
+ * We don't spin up a real Chrome here — the assertion's contract is "given a
5
+ * WebGL info pair, accept SwiftShader and reject anything else." Tests inject
6
+ * the info pair through the optional `readInfo` override that the
7
+ * production code path leaves as a default.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import type { Page } from "puppeteer-core";
12
+ import {
13
+ BROWSER_GPU_NOT_SOFTWARE,
14
+ SwiftShaderAssertionError,
15
+ assertSwiftShader,
16
+ } from "./assertSwiftShader.js";
17
+
18
+ // Minimal Page stub. Only assertSwiftShader's default `readInfo` ever touches
19
+ // `page.goto` / `page.evaluate`; when we inject a custom `readInfo` the page
20
+ // object is never used, so an empty cast is safe.
21
+ const stubPage = {} as unknown as Page;
22
+
23
+ describe("assertSwiftShader", () => {
24
+ it("accepts the canonical SwiftShader vendor + renderer pair", async () => {
25
+ await assertSwiftShader(stubPage, async () => ({
26
+ vendor: "Google Inc. (Google)",
27
+ renderer:
28
+ "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
29
+ }));
30
+ });
31
+
32
+ it("accepts SwiftShader regardless of trailing whitespace on vendor", async () => {
33
+ await assertSwiftShader(stubPage, async () => ({
34
+ vendor: " Google Inc. (Google) ",
35
+ renderer: "SwiftShader",
36
+ }));
37
+ });
38
+
39
+ it("accepts case-insensitive renderer token", async () => {
40
+ await assertSwiftShader(stubPage, async () => ({
41
+ vendor: "Google Inc. (Google)",
42
+ renderer: "ANGLE (Google, swiftshader Device, swiftshader driver)",
43
+ }));
44
+ });
45
+
46
+ it("throws SwiftShaderAssertionError when vendor is hardware-accelerated", async () => {
47
+ let caught: unknown;
48
+ try {
49
+ await assertSwiftShader(stubPage, async () => ({
50
+ vendor: "Google Inc. (NVIDIA Corporation)",
51
+ renderer: "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090, OpenGL 4.6)",
52
+ }));
53
+ } catch (err) {
54
+ caught = err;
55
+ }
56
+ expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
57
+ expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
58
+ expect((caught as Error).message).toContain("non-SwiftShader");
59
+ expect((caught as Error).message).toContain("--use-gl=swiftshader");
60
+ expect((caught as SwiftShaderAssertionError).vendor).toBe("Google Inc. (NVIDIA Corporation)");
61
+ });
62
+
63
+ it("throws when the renderer string lacks SwiftShader even if vendor matches", async () => {
64
+ // Google Inc. is the umbrella vendor for many ANGLE backends — vendor
65
+ // alone is not enough; the renderer must actually mention SwiftShader.
66
+ let caught: unknown;
67
+ try {
68
+ await assertSwiftShader(stubPage, async () => ({
69
+ vendor: "Google Inc. (Google)",
70
+ renderer: "ANGLE (Google, Vulkan 1.3.0 (Intel(R) UHD Graphics 630), OpenGL ES 3.0)",
71
+ }));
72
+ } catch (err) {
73
+ caught = err;
74
+ }
75
+ expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
76
+ expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
77
+ });
78
+
79
+ it("throws when both vendor and renderer are empty", async () => {
80
+ // Some chrome:// pages return empty strings before the GPU info table
81
+ // populates. We treat that as failure rather than silently passing.
82
+ let caught: unknown;
83
+ try {
84
+ await assertSwiftShader(stubPage, async () => ({ vendor: "", renderer: "" }));
85
+ } catch (err) {
86
+ caught = err;
87
+ }
88
+ expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
89
+ expect((caught as SwiftShaderAssertionError).code).toBe(BROWSER_GPU_NOT_SOFTWARE);
90
+ });
91
+
92
+ it("propagates errors from the info reader without wrapping", async () => {
93
+ const upstream = new Error("simulated CDP failure");
94
+ let caught: unknown;
95
+ try {
96
+ await assertSwiftShader(stubPage, async () => {
97
+ throw upstream;
98
+ });
99
+ } catch (err) {
100
+ caught = err;
101
+ }
102
+ // Reader errors should not be masked by SwiftShaderAssertionError —
103
+ // they are a separate failure class (probably retryable).
104
+ expect(caught).toBe(upstream);
105
+ });
106
+
107
+ it("rejects an unrelated vendor that happens to contain the SwiftShader token in the renderer", async () => {
108
+ // Defensive: if some future ANGLE build uses a non-Google vendor string
109
+ // but still mentions SwiftShader in the renderer for some reason, we
110
+ // still want to require the exact Google vendor signature.
111
+ let caught: unknown;
112
+ try {
113
+ await assertSwiftShader(stubPage, async () => ({
114
+ vendor: "Mesa/X.org",
115
+ renderer: "llvmpipe (SwiftShader compatible)",
116
+ }));
117
+ } catch (err) {
118
+ caught = err;
119
+ }
120
+ expect(caught).toBeInstanceOf(SwiftShaderAssertionError);
121
+ });
122
+ });
123
+
124
+ describe("SwiftShaderAssertionError", () => {
125
+ it("exposes the BROWSER_GPU_NOT_SOFTWARE typed-failure code", () => {
126
+ const err = new SwiftShaderAssertionError("test", "v", "r");
127
+ expect(err.code).toBe(BROWSER_GPU_NOT_SOFTWARE);
128
+ expect(err.code).toBe("BROWSER_GPU_NOT_SOFTWARE");
129
+ });
130
+ });
@@ -0,0 +1,126 @@
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
+ import type { Page } from "puppeteer-core";
19
+
20
+ /**
21
+ * Error code classifying this failure as non-retryable for distributed
22
+ * workflow adapters — a downgraded GPU on a worker will not heal on retry.
23
+ */
24
+ export const BROWSER_GPU_NOT_SOFTWARE = "BROWSER_GPU_NOT_SOFTWARE";
25
+
26
+ /**
27
+ * Error thrown when chrome://gpu reports a non-SwiftShader WebGL backend.
28
+ *
29
+ * Carries a `code` property so the adapter can match on it without parsing
30
+ * the message string — Temporal/Step Functions retry policies key off the
31
+ * code, not the message.
32
+ */
33
+ export class SwiftShaderAssertionError extends Error {
34
+ readonly code: typeof BROWSER_GPU_NOT_SOFTWARE = BROWSER_GPU_NOT_SOFTWARE;
35
+ readonly vendor: string;
36
+ readonly renderer: string;
37
+
38
+ constructor(message: string, vendor: string, renderer: string) {
39
+ super(message);
40
+ this.name = "SwiftShaderAssertionError";
41
+ this.vendor = vendor;
42
+ this.renderer = renderer;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * SwiftShader identifies itself on `chrome://gpu` and in
48
+ * `WEBGL_debug_renderer_info` with this exact vendor string. Locking to
49
+ * Google's own GL string (rather than a substring match on "swiftshader")
50
+ * avoids false-positives from third-party ANGLE backends that incidentally
51
+ * mention SwiftShader in unrelated diagnostic text.
52
+ */
53
+ const SWIFTSHADER_VENDOR_SIGNATURE = "Google Inc. (Google)";
54
+ /**
55
+ * Renderer string contains the literal "SwiftShader" token. We match
56
+ * case-insensitively and only require the substring; Chrome occasionally
57
+ * appends a build suffix (e.g. " Vulkan 1.3").
58
+ */
59
+ const SWIFTSHADER_RENDERER_TOKEN = "swiftshader";
60
+
61
+ interface WebGlInfo {
62
+ vendor: string;
63
+ renderer: string;
64
+ }
65
+
66
+ /**
67
+ * Read the WebGL vendor/renderer strings from a live `chrome://gpu` page.
68
+ *
69
+ * Extracted from `assertSwiftShader` so tests can stub the navigation +
70
+ * extraction step. Returns the raw values; callers decide how to interpret
71
+ * them. Both fields are best-effort — Chrome returns empty strings if the
72
+ * GPU info table hasn't populated yet, which the caller treats as failure.
73
+ */
74
+ export async function readWebGlVendorInfo(page: Page): Promise<WebGlInfo> {
75
+ await page.goto("chrome://gpu", { waitUntil: "domcontentloaded", timeout: 30_000 });
76
+ // The "GL_VENDOR" / "GL_RENDERER" rows live inside <info-view> shadow DOM
77
+ // in modern Chrome. We pull the structured `info_log_` payload off the
78
+ // page-level globals instead of querying the DOM, since the DOM layout has
79
+ // drifted across versions.
80
+ const info = await page.evaluate((): WebGlInfo => {
81
+ type Row = { description?: string; value?: string };
82
+ type InfoLog = { graphics_info?: { basic_info?: Row[] } };
83
+ const w = window as unknown as { browserBridge?: { gpuInfo_?: InfoLog } };
84
+ const rows: Row[] = w.browserBridge?.gpuInfo_?.graphics_info?.basic_info ?? [];
85
+ let vendor = "";
86
+ let renderer = "";
87
+ for (const row of rows) {
88
+ if (typeof row.description !== "string" || typeof row.value !== "string") continue;
89
+ if (row.description === "GL_VENDOR") vendor = row.value;
90
+ else if (row.description === "GL_RENDERER") renderer = row.value;
91
+ }
92
+ return { vendor, renderer };
93
+ });
94
+ return info;
95
+ }
96
+
97
+ /**
98
+ * Validate that the active WebGL renderer is SwiftShader. Throws
99
+ * `SwiftShaderAssertionError` otherwise.
100
+ *
101
+ * Pass an optional `readInfo` override for tests that don't have a real
102
+ * Puppeteer `Page`. The default implementation navigates to `chrome://gpu`
103
+ * and parses the GL_VENDOR / GL_RENDERER rows.
104
+ */
105
+ export async function assertSwiftShader(
106
+ page: Page,
107
+ readInfo: (page: Page) => Promise<WebGlInfo> = readWebGlVendorInfo,
108
+ ): Promise<void> {
109
+ const { vendor, renderer } = await readInfo(page);
110
+
111
+ const vendorMatches = vendor.trim() === SWIFTSHADER_VENDOR_SIGNATURE;
112
+ const rendererMatches = renderer.toLowerCase().includes(SWIFTSHADER_RENDERER_TOKEN);
113
+
114
+ if (vendorMatches && rendererMatches) return;
115
+
116
+ throw new SwiftShaderAssertionError(
117
+ `[assertSwiftShader] Chrome reported a non-SwiftShader WebGL backend. ` +
118
+ `Distributed renders require pure-software GL for pixel-identical retries. ` +
119
+ `Got vendor=${JSON.stringify(vendor)} renderer=${JSON.stringify(renderer)}; ` +
120
+ `expected vendor=${JSON.stringify(SWIFTSHADER_VENDOR_SIGNATURE)} renderer to contain "SwiftShader". ` +
121
+ `Ensure Chrome was launched with --use-gl=swiftshader --use-angle=swiftshader and that the ` +
122
+ `SwiftShader libraries are present in the runtime image.`,
123
+ vendor,
124
+ renderer,
125
+ );
126
+ }