name: screenshot-test description: Add a screenshot (visual regression) browser test for a canvas feature. Use when asked to "add a screenshot test for X", "visual test for X", "screenshot test X", "add a browser test with screenshots", or when the user wants to verify how something renders on the canvas.
Screenshot Test Generator
Generate visual regression browser tests that verify canvas rendering by simulating real user interactions and comparing screenshots against baselines.
Core Principles
- Test like a user — every test simulates what a real user would do: select a tool via keyboard shortcut, drag on the canvas to draw, click to select. Never manipulate internal state directly.
- Grid coordinates — use the
CanvasGridcell system for readable, maintainable positions instead of raw pixel values. The default grid is 16x9 on a 1280x720 canvas (80x80px cells). - Deterministic rendering — RoughJS uses
Math.random()for hand-drawn stroke variations.CanvasPage.create()seeds it deterministically so screenshots are pixel-identical across runs. - Wait for paint — the canvas render pipeline is async (ResizeObserver -> watch -> RAF -> paint).
CanvasPage.create()waits for canvas ready. UsewaitForPaint()after drawing before screenshotting.
File Placement
Place the test file next to the feature code it tests:
app/features/{feature}/
{feature}.browser.test.ts # <- the test file
__screenshots__/
{feature}.browser.test.ts/ # <- auto-created by vitest
{name}-chromium-darwin.png # <- baseline screenshots
Test Template
import { page as vitestPage } from "vitest/browser";
import { CanvasPage } from "~/__test-utils__/browser";
import { waitForPaint } from "~/__test-utils__/browser/waiters";
describe("{feature name} rendering", () => {
it("{describes what the user sees}", async () => {
const page = await CanvasPage.create();
// Simulate user actions using grid cells
await page.canvas.createElementAtCells("rectangle", [2, 2], [6, 5]);
await waitForPaint();
await expect(vitestPage.getByTestId("canvas-container")).toMatchScreenshot(
"{descriptive-name}",
);
});
});
CanvasPage.create() handles everything: reseed() → render(CanvasContainer) → waitForCanvasReady() → cleanup via onTestFinished. No beforeEach/afterEach needed.
Available User Actions
Drawing elements (via CanvasPage)
const page = await CanvasPage.create();
// Draw using grid cells (preferred — readable and maintainable)
await page.canvas.createElementAtCells("rectangle", [1, 1], [4, 3]);
await page.canvas.createElementAtCells("diamond", [5, 1], [8, 3]);
await page.canvas.createElementAtCells("ellipse", [1, 4], [4, 6]);
await page.canvas.createElementAtCells("arrow", [5, 5], [8, 5]);
// Draw and get a live accessor to the created element
const { id, get } = await page.canvas.createElement("rectangle", [2, 2], [5, 5]);
// Draw using raw pixel coords (only when precise positioning matters for screenshots)
await page.canvas.pointer.drag(100, 100, 300, 250);
Tool selection
await page.toolbar.select("rectangle"); // shortcut '2'
await page.toolbar.select("diamond"); // shortcut '3'
await page.toolbar.select("ellipse"); // shortcut '4'
await page.toolbar.select("arrow"); // shortcut 'a'
await page.toolbar.select("line"); // shortcut 'l'
await page.toolbar.select("text"); // shortcut 't'
await page.toolbar.select("code"); // shortcut 'c'
await page.toolbar.select("selection"); // shortcut '1'
await page.toolbar.select("hand"); // shortcut 'h'
Clicking and dragging on canvas
// Grid-based (preferred)
await page.canvas.click([3, 3]);
await page.canvas.click([3, 3], { shiftKey: true }); // with modifier
await page.canvas.draw([1, 1], [4, 4]); // raw drag between cells
await page.canvas.clickCenter([2, 2], [5, 5]); // click center of region
await page.canvas.dblClick([3, 3]);
// Raw pixel access (when needed for precise screenshot positions)
await page.canvas.pointer.clickAt(200, 150);
await page.canvas.pointer.drag(100, 100, 300, 250);
Selection
await page.selection.clickElement(element);
await page.selection.shiftClickElement(element);
await page.selection.boxSelect([0, 0], [8, 4]);
page.selection.clear();
page.selection.expectSelected(id1, id2);
page.selection.expectNoneSelected();
Scene (programmatic setup)
const el = page.scene.addElement({ x: 50, width: 80 });
await page.scene.flush(); // markStaticDirty + waitForPaint
page.scene.expectElementCount(2);
page.scene.expectElementType(0, "rectangle");
Keyboard input
await page.keyboard.press("{Delete}");
await page.keyboard.withModifierKeys({ ctrlKey: true }, async () => {
await page.keyboard.press("g"); // Ctrl+G (group)
});
await page.keyboard.undo(); // Ctrl+Z
await page.keyboard.redo(); // Ctrl+Shift+Z
Tool state assertions
await page.toolbar.expectActive("selection"); // checks aria-pressed="true"
Grid Coordinate System
The CanvasGrid divides a 1280x720 canvas into a 16x9 grid of 80x80px cells.
Cell [0,0] = top-left → pixel center (40, 40)
Cell [15,8] = bottom-right → pixel center (1240, 680)
Cell [col, row] → pixel center ((col+0.5)*80, (row+0.5)*80)
Use grid cells for element placement to make tests self-documenting:
// Clear layout: two shapes side by side
await page.canvas.createElementAtCells("rectangle", [1, 1], [4, 3]); // left shape
await page.canvas.createElementAtCells("diamond", [5, 1], [8, 3]); // right shape
Debug overlay
When debugging, inject a visible grid:
await page.canvas.grid.showOverlay(10000); // shows red grid lines for 10 seconds
Critical Rules
- Use
CanvasPage.create()— it handles seeding, rendering, and cleanup. No manualreseed()/restoreSeed()orbeforeEach/afterEachneeded. - Always
waitForPaint()(one RAF) after drawing before taking a screenshot — the render pipeline is async. - Never use
page.mouse— iframe coordinate translation causes silent mismatches. Usepage.canvas.pointerorpage.canvas.gridwhich dispatch PointerEvents directly inside the iframe. - Screenshot the container (
vitestPage.getByTestId('canvas-container')), not the canvas element — this includes the toolbar for full context. - Import
pagefrom vitest asvitestPageto avoid naming conflict with theCanvasPagevariable. - Use descriptive kebab-case names for
toMatchScreenshot('name')— the name becomes part of the baseline filename. - Test names describe what the user sees — "renders grouped elements with selection outline", not "tests group rendering function".
Running Screenshot Tests
pnpm test:browser # run all browser tests
pnpm test:browser -- {feature}.browser # run specific test file
On first run, baseline screenshots are created in __screenshots__/. On subsequent runs, new screenshots are compared against baselines. To update baselines after intentional visual changes:
pnpm test:browser -- --update # update all baselines
Example: Complete Test for a New Feature
If asked to "add a screenshot test for grouping", produce something like:
import { page as vitestPage } from "vitest/browser";
import { CanvasPage } from "~/__test-utils__/browser";
import { waitForPaint } from "~/__test-utils__/browser/waiters";
describe("grouping rendering", () => {
it("renders two grouped elements with shared selection outline", async () => {
const page = await CanvasPage.create();
// User draws two shapes
await page.canvas.createElementAtCells("rectangle", [2, 2], [5, 4]);
await page.canvas.createElementAtCells("ellipse", [6, 2], [9, 4]);
// User selects both (click first, shift-click second)
await page.canvas.clickCenter([2, 2], [5, 4]);
await page.canvas.clickCenter([6, 2], [9, 4], { shiftKey: true });
// User groups them with Ctrl+G
await page.keyboard.withModifierKeys({ ctrlKey: true }, async () => {
await page.keyboard.press("g");
});
await waitForPaint();
await expect(vitestPage.getByTestId("canvas-container")).toMatchScreenshot(
"grouped-elements-selected",
);
});
});
Notice how the test reads like a user story: draw two shapes, select both, group them, verify the visual result.