.agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md
test('find canvas', async ({page}) => {
await page.goto('/canvas-app')
// By tag
const canvas = page.locator('canvas')
// By ID or class
const gameCanvas = page.locator('canvas#game')
const chartCanvas = page.locator('canvas.chart-canvas')
// Verify canvas is present and visible
await expect(canvas).toBeVisible()
// Get canvas dimensions
const box = await canvas.boundingBox()
console.log(`Canvas size: ${box?.width}x${box?.height}`)
})
test('canvas renders correctly', async ({page}) => {
await page.goto('/chart')
// Wait for canvas to be ready (check for specific content)
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas')
const ctx = canvas?.getContext('2d')
// Check if canvas has been drawn to
return ctx && !isCanvasBlank(canvas)
function isCanvasBlank(canvas) {
const ctx = canvas.getContext('2d')
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
return !data.some((channel) => channel !== 0)
}
})
// Screenshot just the canvas
const canvas = page.locator('canvas')
await expect(canvas).toHaveScreenshot('chart.png')
})
test('verify canvas content', async ({page}) => {
await page.goto('/drawing-app')
// Get canvas image data
const imageData = await page.evaluate(() => {
const canvas = document.querySelector('canvas') as HTMLCanvasElement
return canvas.toDataURL('image/png')
})
// Verify it's not empty
expect(imageData).toMatch(/^data:image\/png;base64,.+/)
// Get pixel data at specific location
const pixelColor = await page.evaluate(() => {
const canvas = document.querySelector('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
const pixel = ctx.getImageData(100, 100, 1, 1).data
return {r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3]}
})
// Verify specific pixel color
expect(pixelColor.r).toBeGreaterThan(200) // Expecting red-ish
})
test('chart matches baseline', async ({page}) => {
await page.goto('/dashboard')
// Wait for chart animation to complete
await page.waitForTimeout(1000) // Or better: wait for specific state
// Full page screenshot
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100, // Allow small differences
})
// Just the canvas
const chart = page.locator('canvas#sales-chart')
await expect(chart).toHaveScreenshot('sales-chart.png', {
maxDiffPixelRatio: 0.01, // 1% difference allowed
})
})
test('animated canvas', async ({page}) => {
await page.goto('/animated-chart')
// Pause animation before screenshot
await page.evaluate(() => {
// Common pattern: chart libraries expose pause method
window.chartInstance?.stop?.()
// Or override requestAnimationFrame
window.requestAnimationFrame = () => 0
})
await expect(page.locator('canvas')).toHaveScreenshot()
})
test('wait for animation complete', async ({page}) => {
await page.goto('/chart-with-animation')
// Wait for animation complete event
await page.evaluate(() => {
return new Promise<void>((resolve) => {
if (window.chart?.isAnimating === false) {
resolve()
} else {
window.chart?.on('animationComplete', resolve)
}
})
})
await expect(page.locator('canvas')).toHaveScreenshot()
})
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
// Increased threshold for canvas (anti-aliasing differences)
maxDiffPixelRatio: 0.02,
threshold: 0.3, // Per-pixel color threshold
animations: 'disabled',
},
},
})
test('click on canvas element', async ({page}) => {
await page.goto('/interactive-map')
const canvas = page.locator('canvas')
// Click at specific coordinates
await canvas.click({position: {x: 150, y: 200}})
// Verify click was registered
await expect(page.locator('#info-panel')).toContainText('Location: Paris')
})
test('draw on canvas', async ({page}) => {
await page.goto('/whiteboard')
const canvas = page.locator('canvas')
const box = await canvas.boundingBox()
// Draw a line using mouse
await page.mouse.move(box!.x + 50, box!.y + 50)
await page.mouse.down()
await page.mouse.move(box!.x + 200, box!.y + 200, {steps: 10})
await page.mouse.up()
// Verify something was drawn
const hasDrawing = await page.evaluate(() => {
const canvas = document.querySelector('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d')!
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data
return data.some((v, i) => i % 4 !== 3 && v !== 255) // Non-white pixels
})
expect(hasDrawing).toBe(true)
})
test('drag canvas element', async ({page}) => {
await page.goto('/diagram-editor')
const canvas = page.locator('canvas')
const box = await canvas.boundingBox()
// Drag shape from position A to B
await page.mouse.move(box!.x + 100, box!.y + 100)
await page.mouse.down()
await page.mouse.move(box!.x + 300, box!.y + 200, {steps: 20})
await page.mouse.up()
// Verify via screenshot or state check
await expect(canvas).toHaveScreenshot('shape-moved.png')
})
test('pinch zoom on canvas', async ({page}) => {
await page.goto('/map')
const canvas = page.locator('canvas')
const box = await canvas.boundingBox()
const centerX = box!.x + box!.width / 2
const centerY = box!.y + box!.height / 2
// Simulate pinch zoom using two touch points
await page.touchscreen.tap(centerX, centerY)
// Use evaluate for complex gestures
await page.evaluate(
async ({x, y}) => {
const target = document.querySelector('canvas')!
// Simulate pinch start
const touch1 = new Touch({
identifier: 1,
target,
clientX: x - 50,
clientY: y,
})
const touch2 = new Touch({
identifier: 2,
target,
clientX: x + 50,
clientY: y,
})
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [touch1, touch2],
targetTouches: [touch1, touch2],
bubbles: true,
}),
)
// Simulate pinch out
const touch1End = new Touch({
identifier: 1,
target,
clientX: x - 100,
clientY: y,
})
const touch2End = new Touch({
identifier: 2,
target,
clientX: x + 100,
clientY: y,
})
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [touch1End, touch2End],
targetTouches: [touch1End, touch2End],
bubbles: true,
}),
)
target.dispatchEvent(new TouchEvent('touchend', {bubbles: true}))
},
{x: centerX, y: centerY},
)
// Verify zoom level changed
const zoomLevel = await page.locator('#zoom-indicator').textContent()
expect(parseFloat(zoomLevel!)).toBeGreaterThan(1)
})
test('WebGL is supported', async ({page}) => {
await page.goto('/3d-viewer')
const hasWebGL = await page.evaluate(() => {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
return !!gl
})
expect(hasWebGL).toBe(true)
})
test('3D scene renders', async ({page}) => {
await page.goto('/3d-model-viewer')
// Wait for WebGL scene to render
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas')
if (!canvas) return false
const gl = canvas.getContext('webgl') || canvas.getContext('webgl2')
if (!gl) return false
// Check if something has been drawn
const pixels = new Uint8Array(4)
gl.readPixels(canvas.width / 2, canvas.height / 2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels)
return pixels.some((p) => p > 0)
})
// Screenshot comparison (higher threshold for WebGL)
await expect(page.locator('canvas')).toHaveScreenshot('3d-scene.png', {
maxDiffPixelRatio: 0.05, // WebGL can have more variation
})
})
test('Three.js scene interaction', async ({page}) => {
await page.goto('/three-demo')
// Wait for scene to be ready
await page.waitForFunction(() => window.scene?.children?.length > 0)
// Interact with scene (orbit controls)
const canvas = page.locator('canvas')
const box = await canvas.boundingBox()
// Rotate camera by dragging
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.mouse.down()
await page.mouse.move(box!.x + box!.width / 2 + 100, box!.y + box!.height / 2, {
steps: 10,
})
await page.mouse.up()
// Verify camera position changed
const cameraRotation = await page.evaluate(() => {
return window.camera?.rotation?.y
})
expect(cameraRotation).not.toBe(0)
})
test('Chart.js renders data', async ({page}) => {
await page.goto('/chartjs-demo')
// Wait for Chart.js to initialize
await page.waitForFunction(() => {
return window.Chart && document.querySelector('canvas')?.__chart__
})
// Get chart data via Chart.js API
const chartData = await page.evaluate(() => {
const canvas = document.querySelector('canvas') as any
const chart = canvas.__chart__
return chart.data.datasets[0].data
})
expect(chartData).toEqual([12, 19, 3, 5, 2, 3])
// Screenshot test
await expect(page.locator('canvas')).toHaveScreenshot()
})
test('chart library interaction', async ({page}) => {
await page.goto('/chart-demo')
// Wait for chart to render
await page.waitForFunction(() => document.querySelector('canvas, svg.chart'))
// For SVG charts (D3)
const bars = page.locator('svg.chart rect.bar')
if ((await bars.count()) > 0) {
await bars.first().hover()
await expect(page.locator('.tooltip')).toBeVisible()
}
// For canvas charts (ECharts, Chart.js)
const canvas = page.locator('canvas')
await canvas.click({position: {x: 200, y: 150}})
})
test('game frame control', async ({page}) => {
await page.goto('/game')
// Pause and step through frames
await page.evaluate(() => window.gameLoop?.pause())
await page.evaluate(() => window.gameLoop?.tick())
await expect(page.locator('canvas')).toHaveScreenshot('frame-1.png')
for (let i = 0; i < 10; i++) {
await page.evaluate(() => window.gameLoop?.tick())
}
await expect(page.locator('canvas')).toHaveScreenshot('frame-11.png')
})
test('game state changes', async ({page}) => {
await page.goto('/game')
const initialScore = await page.evaluate(() => window.game?.score)
expect(initialScore).toBe(0)
await page.keyboard.press('Space') // Action
await page.waitForTimeout(500)
const newScore = await page.evaluate(() => window.game?.score)
expect(newScore).toBeGreaterThan(0)
})
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Pixel-perfect assertions | Fails across browsers/OS | Use maxDiffPixelRatio threshold |
| Not waiting for render | Blank canvas screenshots | Wait for draw completion |
| Testing raw pixel data | Brittle and slow | Use visual comparison |
| Ignoring animation | Flaky screenshots | Pause/disable animations |
| Hardcoded coordinates | Breaks on resize | Calculate relative to canvas bounds |