mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
494 lines
19 KiB
Markdown
Executable file
494 lines
19 KiB
Markdown
Executable file
# 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](assertions-and-waiting.md), [core/locators.md](locators.md)
|
|
|
|
## Quick Reference
|
|
|
|
```typescript
|
|
// 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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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**
|
|
```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
|
|
|
|
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- auto-retrying assertions and visual comparison options
|
|
- [core/configuration.md](configuration.md) -- configure screenshot thresholds and update baselines
|
|
- [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md) -- canvas elements inside iframes
|
|
- [core/debugging.md](debugging.md) -- debugging visual regression failures with trace viewer
|