fix(engine,cli): resolve drawElement to a Chrome build that actually has it

canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).

Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
  depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
  confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
  resolve straight to our pinned/cached build, skipping both the shared
  puppeteer-cache preference and system Chrome. Rendering shouldn't depend
  on whatever arbitrary Chrome a machine happens to have — that's exactly
  how this regressed (any Mac with Chrome.app installed bypassed the CLI's
  pin entirely).
- A runtime capability probe in the engine, right before any other
  drawElement work: if `drawElementImage` isn't a function on the injected
  canvas, route to the existing screenshot-fallback gate instead of
  crashing. This is the real backstop — it protects every resolution path
  (env override, stale cache entry, a future Chrome regression), not just
  the ones `preferManagedChrome` reaches.

Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.

Fixes #2060.
This commit is contained in:
Vance Ingalls
2026-07-08 14:14:28 -07:00
parent 38b27c4d82
commit 8854bad8f9
11 changed files with 187 additions and 74 deletions
@@ -525,6 +525,36 @@ async function initDrawElementOrTransparentBackground(
await armStaticDedup(session, page, logInitPhase);
}
}
// Capability gate: `canvas.drawElementImage` is an unlaunched Blink feature
// that only exists on recent Dev/Canary Chrome builds (~151+); it is absent
// from Stable and from most pinned/system Chrome installs. The
// `--enable-features=CanvasDrawElement` flag no-ops silently on a build that
// doesn't implement it, so without this probe the first drawElementImage()
// call throws `TypeError: ... is not a function` deep inside the capture
// loop and takes the whole render down instead of falling back (HF#2060).
// Cheap (no paint-wait) and must run before any other drawElement work.
// Not gated by forceDE (HF_FORCE_DRAWELEMENT, an R&D knob that bypasses the
// quality gates below to measure raw damage) — there's no "forced but
// degraded" mode for a method that doesn't exist, only a crash, so this
// always routes to the fallback instead.
const supportsDrawElement = await page.evaluate(() => {
const c = document.createElement("canvas");
const ctx = c.getContext("2d");
return (
typeof (ctx as unknown as { drawElementImage?: unknown })?.drawElementImage === "function"
);
});
if (!supportsDrawElement) {
session.deGateReason = "unsupported_chrome";
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
"this Chrome build does not implement canvas.drawElementImage (Dev/Canary-only " +
"feature, ~151+); run `hyperframes doctor` or set HYPERFRAMES_BROWSER_PATH to a " +
"build that supports it.",
);
await routeToFallback();
return;
}
// SwiftShader gate: drawElement's only advantage is skipping the GPU→CPU
// screenshot-readback IPC. On a software rasterizer (Docker/CI, no GPU) both
// paths block on identical software raster, so drawElement is parity-or-slower