Skip to content

Playwright in Docker — visual verification & deterministic capture

How Wim wants browser verification done, and the capture recipe that came out of it. Cross-project; first established on kenze. #tooling #playwright #docker

The preference

Visual verification of web pages goes through Playwright inside Docker, never a locally installed browser. Wim rejected probing for local browsers outright and asked for the Docker route explicitly.

Setup: mcr.microsoft.com/playwright:v1.54.0-jammy (already pulled locally), site mounted read-only at /site, npm i playwright@1.54.0 in /tmp, script run from there.

Two gotchas that cost time: - Node resolves modules from the script's own directory — copy the script next to node_modules before running it. - Override scroll-behavior: auto when scrolling programmatically on pages that use smooth scroll, or positions land wrong.

Deterministic video capture

Real-time recordVideo is janky and time-inaccurate under SwiftShader. What works (reference implementation: teaser/record/capture-frames.js in the kenze repo):

  1. Launch Chromium with --enable-begin-frame-control --run-all-compositor-stages-before-draw --disable-threaded-animation --disable-threaded-scrolling.
  2. During page load, run a real-time pump calling HeadlessExperimental.beginFrame every ~40 ms. Without it nothing renders in this mode — IntersectionObserver reveals and rAF loops all stall.
  3. Per frame: Emulation.setVirtualTimePolicy {policy:'pause'}, step the page's JS timeline hook, advance virtual time by 1000/fps ms (pauseIfNetworkFetchesPending + budget, wait for virtualTimeBudgetExpired), then HeadlessExperimental.beginFrame {screenshot: {format:'png'}}. One beginFrame = one rAF tick. If screenshotData is absent (no damage), reuse the previous frame.
  4. Assemble with ffmpeg from ffmpeg-static@5, npm-installed inside the container (full build with libx264).

Pitfalls confirmed the hard way: - Without begin-frame-control, Emulation.virtualTimeBudgetExpired can simply never fire on heavy pages — a silent hang — and rAF does not follow virtual time. - An iframe's contentDocument.readyState === 'complete' is true for the initial about:blank document. Check contentWindow.location.pathname before trusting it, or you end up awaiting fonts.ready on the wrong document and hang forever. - A fully clipped iframe (clip-path: inset(0 0 100% 0)) gets render-throttled: IO-based reveal animations inside will not fire until it is unclipped. Hide it behind an opaque layer instead of clipping it.