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):
- Launch Chromium with
--enable-begin-frame-control --run-all-compositor-stages-before-draw --disable-threaded-animation --disable-threaded-scrolling. - During page load, run a real-time pump calling
HeadlessExperimental.beginFrameevery ~40 ms. Without it nothing renders in this mode — IntersectionObserver reveals and rAF loops all stall. - Per frame:
Emulation.setVirtualTimePolicy {policy:'pause'}, step the page's JS timeline hook, advance virtual time by1000/fpsms (pauseIfNetworkFetchesPending+ budget, wait forvirtualTimeBudgetExpired), thenHeadlessExperimental.beginFrame {screenshot: {format:'png'}}. One beginFrame = one rAF tick. IfscreenshotDatais absent (no damage), reuse the previous frame. - 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.