Back to Sanity

Canvas & WebGL Testing

.agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md

5.24.012.6 KB
Original Source

Canvas & WebGL Testing

Table of Contents

  1. Canvas Basics
  2. Visual Comparison
  3. Interaction Testing
  4. WebGL Testing
  5. Chart Libraries
  6. Game & Animation Testing

Canvas Basics

Locating Canvas Elements

typescript
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}`)
})

Canvas Screenshot Testing

typescript
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')
})

Extracting Canvas Data

typescript
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
})

Visual Comparison

Screenshot Assertions

typescript
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
  })
})

Handling Animation

typescript
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()
})

Threshold Configuration

typescript
// 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',
    },
  },
})

Interaction Testing

Click on Canvas

typescript
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')
})

Drawing on Canvas

typescript
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)
})

Drag and Drop

typescript
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')
})

Touch Gestures on Canvas

typescript
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)
})

WebGL Testing

Checking WebGL Support

typescript
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)
})

WebGL Screenshot Testing

typescript
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
  })
})

Testing Three.js Applications

typescript
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)
})

Chart Libraries

Chart.js Testing

typescript
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()
})

D3.js / ECharts Testing

typescript
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}})
})

Game & Animation Testing

Frame-by-Frame Testing

typescript
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')
})

Testing Game State

typescript
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-Patterns to Avoid

Anti-PatternProblemSolution
Pixel-perfect assertionsFails across browsers/OSUse maxDiffPixelRatio threshold
Not waiting for renderBlank canvas screenshotsWait for draw completion
Testing raw pixel dataBrittle and slowUse visual comparison
Ignoring animationFlaky screenshotsPause/disable animations
Hardcoded coordinatesBreaks on resizeCalculate relative to canvas bounds