Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Latest commit

 

History

History
494 lines (377 loc) · 18.6 KB

File metadata and controls

494 lines (377 loc) · 18.6 KB

Canvas and WebGL Testing

When to use: When your application renders content on <canvas> elements -- charts (Chart.js, D3), maps (Mapbox, Leaflet), games, image editors, WebGL visualizations, drawing tools, signature pads. Prerequisites: core/assertions-and-waiting.md, core/locators.md

Quick Reference

// Screenshot comparison — the primary strategy for canvas
await expect(page.locator('canvas#chart')).toHaveScreenshot('revenue-chart.png');

// Click at specific coordinates on canvas
await page.locator('canvas').click({ position: { x: 200, y: 150 } });

// Read canvas state via page.evaluate
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] };
});

Patterns

Screenshot Comparison (Visual Regression)

Use when: Verifying the visual output of canvas-rendered content -- charts, graphs, maps, drawings. This is the most reliable approach because canvas pixels are not queryable via DOM. Avoid when: The canvas content is dynamic on every render (animations, timestamps). Use threshold or mask options.

TypeScript

import { test, expect } from '@playwright/test';

test('revenue chart renders correctly', async ({ page }) => {
  await page.goto('/dashboard');

  // Wait for the chart to finish rendering
  await expect(page.locator('canvas#revenue-chart')).toBeVisible();

  // Optionally wait for a loading indicator to disappear
  await expect(page.getByTestId('chart-loading')).toBeHidden();

  // Screenshot comparison against a baseline
  await expect(page.locator('canvas#revenue-chart')).toHaveScreenshot('revenue-chart.png', {
    maxDiffPixelRatio: 0.01,  // Allow 1% pixel difference for anti-aliasing
  });
});

test('chart updates after date range change', async ({ page }) => {
  await page.goto('/dashboard');

  // Change date range
  await page.getByRole('combobox', { name: 'Date range' }).selectOption('Last 30 days');

  // Wait for chart to re-render
  await expect(page.getByTestId('chart-loading')).toBeHidden();

  // Compare against a different baseline
  await expect(page.locator('canvas#revenue-chart')).toHaveScreenshot('revenue-chart-30d.png', {
    maxDiffPixelRatio: 0.01,
  });
});

test('mask dynamic areas in canvas screenshot', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page.locator('canvas#chart')).toHaveScreenshot('chart-stable.png', {
    // Mask the timestamp area that changes every render
    mask: [page.locator('.chart-timestamp')],
    maxDiffPixelRatio: 0.02,
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test('revenue chart renders correctly', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.locator('canvas#revenue-chart')).toBeVisible();
  await expect(page.getByTestId('chart-loading')).toBeHidden();

  await expect(page.locator('canvas#revenue-chart')).toHaveScreenshot('revenue-chart.png', {
    maxDiffPixelRatio: 0.01,
  });
});

test('chart updates after date range change', async ({ page }) => {
  await page.goto('/dashboard');
  await page.getByRole('combobox', { name: 'Date range' }).selectOption('Last 30 days');
  await expect(page.getByTestId('chart-loading')).toBeHidden();

  await expect(page.locator('canvas#revenue-chart')).toHaveScreenshot('revenue-chart-30d.png', {
    maxDiffPixelRatio: 0.01,
  });
});

Interacting with Canvas via Coordinates

Use when: Testing user interactions on canvas -- clicking chart data points, dragging on a drawing tool, selecting map regions. Avoid when: The element has an accessible DOM overlay (many chart libraries render tooltips as HTML). Interact with the overlay instead.

TypeScript

import { test, expect } from '@playwright/test';

test('click on chart data point shows tooltip', async ({ page }) => {
  await page.goto('/analytics');

  const canvas = page.locator('canvas#line-chart');
  await expect(canvas).toBeVisible();

  // Click at a specific coordinate on the canvas
  await canvas.click({ position: { x: 200, y: 100 } });

  // Tooltip appears as an HTML overlay (most chart libraries)
  await expect(page.getByTestId('chart-tooltip')).toContainText('Revenue: $12,400');
});

test('draw a line on the canvas', async ({ page }) => {
  await page.goto('/drawing-tool');

  const canvas = page.locator('canvas#drawing-area');

  // Simulate a drag to draw a line
  await canvas.hover({ position: { x: 50, y: 50 } });
  await page.mouse.down();
  await page.mouse.move(200, 200, { steps: 10 }); // Smooth drag
  await page.mouse.up();

  // Verify via screenshot
  await expect(canvas).toHaveScreenshot('drawn-line.png');
});

test('pinch-to-zoom on a map canvas', async ({ page }) => {
  await page.goto('/map');

  const canvas = page.locator('canvas#map');

  // Scroll to zoom (common in map libraries)
  await canvas.hover({ position: { x: 300, y: 300 } });
  await page.mouse.wheel(0, -500); // Scroll up = zoom in

  // Verify zoom level changed
  await expect(page.getByTestId('zoom-level')).toHaveText('Zoom: 12');
});

JavaScript

const { test, expect } = require('@playwright/test');

test('click on chart data point shows tooltip', async ({ page }) => {
  await page.goto('/analytics');

  const canvas = page.locator('canvas#line-chart');
  await expect(canvas).toBeVisible();

  await canvas.click({ position: { x: 200, y: 100 } });
  await expect(page.getByTestId('chart-tooltip')).toContainText('Revenue: $12,400');
});

test('draw a line on the canvas', async ({ page }) => {
  await page.goto('/drawing-tool');

  const canvas = page.locator('canvas#drawing-area');
  await canvas.hover({ position: { x: 50, y: 50 } });
  await page.mouse.down();
  await page.mouse.move(200, 200, { steps: 10 });
  await page.mouse.up();

  await expect(canvas).toHaveScreenshot('drawn-line.png');
});

Canvas API Testing via page.evaluate()

Use when: You need to inspect canvas pixel data, read the rendering context state, or verify programmatic canvas operations. Avoid when: A screenshot comparison is sufficient. Pixel-level assertions are brittle.

TypeScript

import { test, expect } from '@playwright/test';

test('verify specific pixel color on canvas', async ({ page }) => {
  await page.goto('/color-picker');

  // Click the red swatch
  await page.getByRole('button', { name: 'Red' }).click();

  // Read the pixel color at the canvas center
  const color = await page.evaluate(() => {
    const canvas = document.querySelector('canvas#preview') as HTMLCanvasElement;
    const ctx = canvas.getContext('2d')!;
    const centerX = Math.floor(canvas.width / 2);
    const centerY = Math.floor(canvas.height / 2);
    const pixel = ctx.getImageData(centerX, centerY, 1, 1).data;
    return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };
  });

  expect(color.r).toBeGreaterThan(200); // Red channel high
  expect(color.g).toBeLessThan(50);     // Green channel low
  expect(color.b).toBeLessThan(50);     // Blue channel low
});

test('verify canvas dimensions match expected size', async ({ page }) => {
  await page.goto('/editor');

  const dimensions = await page.evaluate(() => {
    const canvas = document.querySelector('canvas#main') as HTMLCanvasElement;
    return { width: canvas.width, height: canvas.height };
  });

  expect(dimensions).toEqual({ width: 1920, height: 1080 });
});

test('canvas has content (is not blank)', async ({ page }) => {
  await page.goto('/chart');

  // Wait for rendering
  await expect(page.getByTestId('chart-loading')).toBeHidden();

  const isBlank = await page.evaluate(() => {
    const canvas = document.querySelector('canvas') as HTMLCanvasElement;
    const ctx = canvas.getContext('2d')!;
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    // Check if all pixels are transparent (blank canvas)
    return imageData.data.every((value, index) => {
      return index % 4 === 3 ? value === 0 : true; // Check alpha channel
    });
  });

  expect(isBlank).toBe(false);
});

JavaScript

const { test, expect } = require('@playwright/test');

test('verify specific pixel color on canvas', async ({ page }) => {
  await page.goto('/color-picker');
  await page.getByRole('button', { name: 'Red' }).click();

  const color = await page.evaluate(() => {
    const canvas = document.querySelector('canvas#preview');
    const ctx = canvas.getContext('2d');
    const centerX = Math.floor(canvas.width / 2);
    const centerY = Math.floor(canvas.height / 2);
    const pixel = ctx.getImageData(centerX, centerY, 1, 1).data;
    return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };
  });

  expect(color.r).toBeGreaterThan(200);
  expect(color.g).toBeLessThan(50);
  expect(color.b).toBeLessThan(50);
});

test('canvas has content (is not blank)', async ({ page }) => {
  await page.goto('/chart');
  await expect(page.getByTestId('chart-loading')).toBeHidden();

  const isBlank = await page.evaluate(() => {
    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    return imageData.data.every((value, index) => {
      return index % 4 === 3 ? value === 0 : true;
    });
  });

  expect(isBlank).toBe(false);
});

WebGL Rendering Verification

Use when: Your app uses WebGL for 3D visualizations, data plots, or games. Avoid when: The canvas uses 2D context only.

WebGL canvas cannot be read with getImageData from a 2D context. Use toDataURL() or screenshot comparison.

TypeScript

import { test, expect } from '@playwright/test';

test('WebGL scene renders a 3D model', async ({ page }) => {
  await page.goto('/3d-viewer');

  // Wait for WebGL to finish rendering
  await page.waitForFunction(() => {
    const canvas = document.querySelector('canvas#scene') as HTMLCanvasElement;
    const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
    return gl !== null;
  });

  // Give the renderer time to complete the first frame
  await expect(page.getByTestId('render-status')).toHaveText('Ready');

  // Screenshot comparison is the most reliable approach for WebGL
  await expect(page.locator('canvas#scene')).toHaveScreenshot('3d-model.png', {
    maxDiffPixelRatio: 0.02, // WebGL has more rendering variance
  });
});

test('WebGL canvas is not blank', async ({ page }) => {
  await page.goto('/3d-viewer');
  await expect(page.getByTestId('render-status')).toHaveText('Ready');

  // Check that the WebGL canvas has drawn something
  const hasContent = await page.evaluate(() => {
    const canvas = document.querySelector('canvas#scene') as HTMLCanvasElement;
    // Convert canvas to data URL and check it's not a blank image
    const dataUrl = canvas.toDataURL();
    // A blank canvas produces a very short data URL
    return dataUrl.length > 1000;
  });

  expect(hasContent).toBe(true);
});

test('rotate 3D model and verify new angle', async ({ page }) => {
  await page.goto('/3d-viewer');
  await expect(page.getByTestId('render-status')).toHaveText('Ready');

  // Take baseline screenshot
  const canvas = page.locator('canvas#scene');

  // Drag to rotate
  await canvas.hover({ position: { x: 300, y: 300 } });
  await page.mouse.down();
  await page.mouse.move(450, 300, { steps: 20 });
  await page.mouse.up();

  // Screenshot should differ from default angle
  await expect(canvas).toHaveScreenshot('3d-model-rotated.png', {
    maxDiffPixelRatio: 0.02,
  });
});

JavaScript

const { test, expect } = require('@playwright/test');

test('WebGL scene renders a 3D model', async ({ page }) => {
  await page.goto('/3d-viewer');

  await page.waitForFunction(() => {
    const canvas = document.querySelector('canvas#scene');
    const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
    return gl !== null;
  });

  await expect(page.getByTestId('render-status')).toHaveText('Ready');
  await expect(page.locator('canvas#scene')).toHaveScreenshot('3d-model.png', {
    maxDiffPixelRatio: 0.02,
  });
});

test('WebGL canvas is not blank', async ({ page }) => {
  await page.goto('/3d-viewer');
  await expect(page.getByTestId('render-status')).toHaveText('Ready');

  const hasContent = await page.evaluate(() => {
    const canvas = document.querySelector('canvas#scene');
    const dataUrl = canvas.toDataURL();
    return dataUrl.length > 1000;
  });

  expect(hasContent).toBe(true);
});

Chart Library Testing Strategies

Use when: Testing Chart.js, D3, Recharts, Highcharts, or similar chart libraries. Avoid when: Charts have full HTML/SVG DOM output (D3 with SVG). Use standard locators for SVG elements.

TypeScript

import { test, expect } from '@playwright/test';

test('bar chart shows correct number of bars (SVG-based chart)', async ({ page }) => {
  await page.goto('/analytics');

  // SVG-based charts (D3, Recharts) render DOM elements — use locators
  const bars = page.locator('svg.chart rect.bar');
  await expect(bars).toHaveCount(12); // 12 months

  // Check a specific bar's aria-label or tooltip
  await bars.nth(0).hover();
  await expect(page.getByTestId('chart-tooltip')).toContainText('January: $8,200');
});

test('canvas-based chart displays data (Chart.js)', async ({ page }) => {
  await page.goto('/analytics');

  // Canvas charts — no DOM elements to query, use screenshot
  await expect(page.getByTestId('chart-loading')).toBeHidden();
  await expect(page.locator('canvas#monthly-chart')).toHaveScreenshot('monthly-chart.png');
});

test('chart legend toggles data series', async ({ page }) => {
  await page.goto('/analytics');

  // Click legend item (usually HTML, not canvas)
  await page.getByRole('button', { name: 'Revenue' }).click();

  // Revenue series hidden — chart should look different
  await expect(page.locator('canvas#chart')).toHaveScreenshot('chart-no-revenue.png');

  // Click again to re-enable
  await page.getByRole('button', { name: 'Revenue' }).click();
  await expect(page.locator('canvas#chart')).toHaveScreenshot('chart-with-revenue.png');
});

test('export chart as image', async ({ page }) => {
  await page.goto('/analytics');

  // Many chart libraries offer "download as PNG"
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Export as PNG' }).click();
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toMatch(/chart.*\.png$/);
});

JavaScript

const { test, expect } = require('@playwright/test');

test('bar chart shows correct number of bars (SVG-based)', async ({ page }) => {
  await page.goto('/analytics');

  const bars = page.locator('svg.chart rect.bar');
  await expect(bars).toHaveCount(12);

  await bars.nth(0).hover();
  await expect(page.getByTestId('chart-tooltip')).toContainText('January: $8,200');
});

test('canvas-based chart displays data', async ({ page }) => {
  await page.goto('/analytics');
  await expect(page.getByTestId('chart-loading')).toBeHidden();
  await expect(page.locator('canvas#monthly-chart')).toHaveScreenshot('monthly-chart.png');
});

Decision Guide

Scenario Best Approach Why
Verify chart looks correct toHaveScreenshot() on canvas element Canvas pixels are not DOM; screenshot is the source of truth
Click a data point on chart canvas.click({ position: { x, y } }) Canvas does not have clickable child elements
Verify canvas is not blank page.evaluate + getImageData or toDataURL Quick programmatic check without baseline image
Test SVG-based chart (D3) Standard locators (svg rect, svg path) SVG elements are in the DOM; use locator queries
Read specific pixel color page.evaluate + getImageData Direct access to pixel data
Test WebGL rendering toHaveScreenshot() with higher maxDiffPixelRatio WebGL has rendering variance; pixel assertions are unreliable
Test canvas drag/draw mouse.down() + mouse.move() + mouse.up() Simulates real drawing interactions
Chart tooltip after hover canvas.hover({ position }) then assert tooltip DOM Tooltips are usually HTML overlays

Anti-Patterns

Don't Do This Problem Do This Instead
page.getByRole('button') inside a canvas Canvas content has no DOM elements Use canvas.click({ position }) for coordinates
Assert pixel colors with exact RGB values Anti-aliasing and GPU differences cause 1-2 value variance Use ranges (toBeGreaterThan(200)) or toHaveScreenshot
Skip maxDiffPixelRatio in canvas screenshots Different GPUs and OS versions render slightly differently Set maxDiffPixelRatio: 0.01 to 0.02
waitForTimeout to wait for chart render Arbitrary; too slow or too fast Wait for a loading indicator to disappear or use waitForFunction
Read WebGL pixels via 2D context getImageData WebGL and 2D contexts are mutually exclusive on the same canvas Use canvas.toDataURL() or screenshot comparison
Take full-page screenshots for canvas tests Captures unrelated content; more brittle baselines Scope screenshot to page.locator('canvas#specific')

Troubleshooting

Symptom Cause Fix
Screenshot baseline always differs GPU rendering differences across CI and local machine Use Docker with consistent GPU settings, or increase maxDiffPixelRatio
Canvas click at coordinates hits wrong element Coordinates are relative to element, but viewport changed Use position relative to the canvas element, not the page
getImageData returns all transparent pixels Canvas has not finished rendering when evaluated Wait for a render-complete signal or use waitForFunction
toDataURL throws SecurityError Canvas is tainted by cross-origin image Serve images from the same origin or use CORS headers
WebGL context is null Browser does not support WebGL or it is disabled in CI Use --enable-webgl flag or run tests on a GPU-capable CI runner
Screenshot test passes locally, fails in CI Different font rendering, DPI, or OS Pin the Docker image, use fonts config, or increase threshold

Related