Context
While debugging a WebXR test suite, we isolated a repeatable bug in Playwright's Windows WebKit: after page.setViewportSize() resizes a canvas's WebGL drawing buffer, page.screenshot() can render the page background where the canvas should be — even though the WebGL context is healthy (no gl.getError(), no gl.isContextLost()) and the canvas keeps rendering. Only a page reload, or a WebGL context loss/restore, brings the canvas back in future screenshots.
We reduced this to a ~20-line, dependency-free repro and ran the identical script on Windows and macOS with the same Playwright/WebKit build. Windows fails 5/5 runs; macOS passes 5/5 runs. This strongly suggests a Windows-port-specific issue in the WebKit screenshot path, not an app bug.
Environment
- Playwright:
1.63.0
- WebKit build:
26.6 (playwright webkit v2359) — same build on both platforms
- Failing: Windows,
Microsoft Windows [Version 10.0.26200.9550], win32
- Passing: macOS, Darwin, arm64 (Apple Silicon)
Minimal repro
index.html (served locally, no external dependencies — raw WebGL, no framework):
<!doctype html>
<html><body style="margin:0">
<canvas id="c"></canvas>
<script>
const gl = document.getElementById('c').getContext('webgl')
function resize() {
const c = gl.canvas
c.width = innerWidth
c.height = innerHeight
gl.viewport(0, 0, c.width, c.height)
}
addEventListener('resize', resize)
resize()
function frame() {
gl.clearColor(1, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
requestAnimationFrame(frame)
}
frame()
window.__ready = true
</script>
</body></html>
repro.mjs:
import { webkit } from 'playwright'
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
const html = await readFile(fileURLToPath(new URL('./index.html', import.meta.url)))
const server = createServer((req, res) => {
res.setHeader('Content-Type', 'text/html')
res.end(html)
}).listen(4321, '127.0.0.1')
const browser = await webkit.launch()
const page = await browser.newPage({ viewport: { width: 390, height: 664 } })
await page.goto('http://127.0.0.1:4321')
await page.waitForFunction(() => window.__ready === true)
await page.waitForTimeout(300)
await page.setViewportSize({ width: 390, height: 844 })
await page.waitForTimeout(300)
const png = await page.screenshot()
const centerPixel = await page.evaluate((base64) => new Promise((resolve) => {
const img = new Image()
img.onload = () => {
const c = document.createElement('canvas')
c.width = img.width; c.height = img.height
const ctx = c.getContext('2d')
ctx.drawImage(img, 0, 0)
resolve([...ctx.getImageData(c.width >> 1, c.height >> 1, 1, 1).data])
}
img.src = 'data:image/png;base64,' + base64
}), png.toString('base64'))
console.log('browser:', browser.version(), 'platform:', process.platform)
console.log('composed screenshot center pixel:', centerPixel)
console.log('expected [255,0,0,255] (red); anything else (e.g. white/page bg) means the canvas went blank in the screenshot')
await browser.close()
server.close()
Steps to reproduce
npm i playwright@1.63.0 && npx playwright install webkit
- Save the two files above in the same directory.
node repro.mjs
Expected
composed screenshot center pixel: [ 255, 0, 0, 255 ] on every run, on every platform — the canvas is continuously cleared to red, so the screenshot taken 300ms after the resize should show red.
Actual
- macOS (Darwin, arm64):
[ 255, 0, 0, 255 ] on 5/5 runs. Correct.
- Windows (
10.0.26200.9550, win32): [ 255, 255, 255, 255 ] on 5/5 runs. The screenshot shows white (the page background) instead of the red canvas.
The WebGL context itself is unaffected — reading back the live GPU buffer with gl.readPixels() immediately before the screenshot still returns the correct color on Windows; only the screenshot's composited image is wrong. A page reload restores correct screenshots until the next resize.
What we ruled out (on the larger app this was first found in, before reducing to this minimal repro)
- A drawing-buffer size change is the trigger — 40/40 buffer resizes (taller, shorter, rotated, narrower, even back to the original size) went blank; when only the camera aspect changed and the buffer size stayed the same, screenshots stayed correct.
- It isn't transient — the canvas stayed blank in screenshots for 100+ frames, after swapping the render loop for a bare
gl.clear() loop, and after a second resize.
- App/GL state isn't the cause — resetting pixel-store settings, all texture/renderbuffer/sampler/VAO bindings, scissor, masks, or
antialias/alpha/premultipliedAlpha/preserveDrawingBuffer/powerPreference all still failed.
- Single-run testing is misleading on a complex page — a bisection needs repeats (
REPS=3+) before treating a variant as causal.
Hypothesis (unconfirmed — we did not instrument WebKit internals)
Playwright's WebKit screenshots go through Page.snapshotRect, a software paint path with compositing layers flattened, rather than reading the GPU compositor's actual output. In that path, HTMLCanvasElement::paint appears to skip drawing when it believes the WebGL display buffer is transparent/stale after a resize (possibly related to surfaceBufferToImageBuffer caching a transparent buffer when a display-buffer read fails). Whether the real on-screen (non-headless, non-screenshot) composition is also affected is unverified — this repro only exercises page.screenshot().
Workarounds we're aware of (neither is acceptable for our use case)
- Never resize the drawing buffer after creation (breaks correct aspect-ratio/rotation handling).
- Force a WebGL context loss + restore on every resize (expensive, and not something we want in a real device path — only useful for satisfying this screenshot check).
Happy to provide the full probe harness (67 variants tested) if useful — trimmed here to the smallest repro that reliably shows the platform difference.
Context
While debugging a WebXR test suite, we isolated a repeatable bug in Playwright's Windows WebKit: after
page.setViewportSize()resizes a canvas's WebGL drawing buffer,page.screenshot()can render the page background where the canvas should be — even though the WebGL context is healthy (nogl.getError(), nogl.isContextLost()) and the canvas keeps rendering. Only a page reload, or a WebGL context loss/restore, brings the canvas back in future screenshots.We reduced this to a ~20-line, dependency-free repro and ran the identical script on Windows and macOS with the same Playwright/WebKit build. Windows fails 5/5 runs; macOS passes 5/5 runs. This strongly suggests a Windows-port-specific issue in the WebKit screenshot path, not an app bug.
Environment
1.63.026.6(playwright webkit v2359) — same build on both platformsMicrosoft Windows [Version 10.0.26200.9550], win32Minimal repro
index.html(served locally, no external dependencies — raw WebGL, no framework):repro.mjs:Steps to reproduce
npm i playwright@1.63.0 && npx playwright install webkitnode repro.mjsExpected
composed screenshot center pixel: [ 255, 0, 0, 255 ]on every run, on every platform — the canvas is continuously cleared to red, so the screenshot taken 300ms after the resize should show red.Actual
[ 255, 0, 0, 255 ]on 5/5 runs. Correct.10.0.26200.9550, win32):[ 255, 255, 255, 255 ]on 5/5 runs. The screenshot shows white (the page background) instead of the red canvas.The WebGL context itself is unaffected — reading back the live GPU buffer with
gl.readPixels()immediately before the screenshot still returns the correct color on Windows; only the screenshot's composited image is wrong. A page reload restores correct screenshots until the next resize.What we ruled out (on the larger app this was first found in, before reducing to this minimal repro)
gl.clear()loop, and after a second resize.antialias/alpha/premultipliedAlpha/preserveDrawingBuffer/powerPreferenceall still failed.REPS=3+) before treating a variant as causal.Hypothesis (unconfirmed — we did not instrument WebKit internals)
Playwright's WebKit screenshots go through
Page.snapshotRect, a software paint path with compositing layers flattened, rather than reading the GPU compositor's actual output. In that path,HTMLCanvasElement::paintappears to skip drawing when it believes the WebGL display buffer is transparent/stale after a resize (possibly related tosurfaceBufferToImageBuffercaching a transparent buffer when a display-buffer read fails). Whether the real on-screen (non-headless, non-screenshot) composition is also affected is unverified — this repro only exercisespage.screenshot().Workarounds we're aware of (neither is acceptable for our use case)
Happy to provide the full probe harness (67 variants tested) if useful — trimmed here to the smallest repro that reliably shows the platform difference.