diff --git a/.gitignore b/.gitignore index b21ea997..a0e17646 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,8 @@ yarn-error.log # testing /coverage .harness/ +# Images a failed match saves next to the playground's committed image snapshots +/apps/playground/**/__image_snapshots__/**/__diff_output__/ # Prebuilt XCTest agent artifacts, packed by the platform-apple `prepack` script /packages/platform-ios/xctest-agent-prebuilds/ vite.config.*.timestamp* diff --git a/.nx/version-plans/version-plan-1792417138768.md b/.nx/version-plans/version-plan-1792417138768.md new file mode 100644 index 00000000..4e38be20 --- /dev/null +++ b/.nx/version-plans/version-plan-1792417138768.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +In Jest's CI mode (on by default in CI), `toMatchImageSnapshot` fails when its snapshot does not exist instead of writing it and passing, and saves the received image beside it as `-actual.png`. A project whose CI relied on missing snapshots being written commits those images as its snapshots, or runs with `--ci=false` on a machine with the comparing device. Like Jest's own snapshot matchers, `toMatchImageSnapshot` cannot be used with `.not`. diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png new file mode 100644 index 00000000..98f46435 Binary files /dev/null and b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/orange-square-element-only.png differ diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/out-of-bounds.png new file mode 100644 index 00000000..7d7311cb Binary files /dev/null and b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/android/out-of-bounds.png differ diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png new file mode 100644 index 00000000..64c61141 Binary files /dev/null and b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/orange-square-element-only.png differ diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png new file mode 100644 index 00000000..f2af63da Binary files /dev/null and b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/chromium/out-of-bounds.png differ diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png new file mode 100644 index 00000000..3cb20809 Binary files /dev/null and b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/orange-square-element-only.png differ diff --git a/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png new file mode 100644 index 00000000..33126c31 Binary files /dev/null and b/apps/playground/src/__tests__/normal/ui/__image_snapshots__/ios/out-of-bounds.png differ diff --git a/packages/bridge/src/__tests__/image-snapshot.test.ts b/packages/bridge/src/__tests__/image-snapshot.test.ts index 1ba8282d..62e732d3 100644 --- a/packages/bridge/src/__tests__/image-snapshot.test.ts +++ b/packages/bridge/src/__tests__/image-snapshot.test.ts @@ -1,10 +1,166 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import fs from 'node:fs/promises'; +import fs, { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PNG } from 'pngjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { matchImageSnapshot } from '../image-snapshot.js'; +const createPng = ({ width = 2, height = 2, fill = 255 } = {}): Buffer => { + const png = new PNG({ width, height }); + png.data.fill(fill); + return PNG.sync.write(png); +}; + +describe('matchImageSnapshot', () => { + let directory: string; + let testFilePath: string; + let receivedPath: string; + let snapshotPath: string; + + beforeEach(async () => { + directory = await mkdtemp( + path.join(os.tmpdir(), 'harness-image-snapshot-') + ); + testFilePath = path.join(directory, 'screen.harness.tsx'); + receivedPath = path.join(directory, 'received.png'); + snapshotPath = path.join( + directory, + '__image_snapshots__', + 'android', + 'square.png' + ); + await writeFile(receivedPath, createPng()); + }); + + afterEach(async () => { + await rm(directory, { force: true, recursive: true }); + }); + + const diffOutputPath = (file: string): string => + path.join(path.dirname(snapshotPath), '__diff_output__', file); + + it('writes a missing snapshot and passes when new snapshots may be written', async () => { + const result = await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'square' }, + 'android', + 'new' + ); + + expect(result.pass).toBe(true); + expect(await readFile(snapshotPath)).toEqual(await readFile(receivedPath)); + expect(existsSync(diffOutputPath('square-actual.png'))).toBe(false); + }); + + it('writes a missing snapshot and passes when snapshots are updated', async () => { + const result = await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'square' }, + 'android', + 'all' + ); + + expect(result.pass).toBe(true); + expect(await readFile(snapshotPath)).toEqual(await readFile(receivedPath)); + expect(existsSync(diffOutputPath('square-actual.png'))).toBe(false); + }); + + it('fails a missing snapshot without writing it in CI mode, and saves the received image to __diff_output__', async () => { + const actualPath = diffOutputPath('square-actual.png'); + + const result = await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'square' }, + 'android', + 'none' + ); + + expect(result.pass).toBe(false); + expect(result.message).toContain( + `New snapshot was not written: ${snapshotPath} does not exist` + ); + expect(result.message).toContain(`Received image saved at ${actualPath}`); + expect(existsSync(snapshotPath)).toBe(false); + expect(await readFile(actualPath)).toEqual(await readFile(receivedPath)); + }); + + it('compares an existing snapshot in CI mode', async () => { + await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'square' }, + 'android', + 'new' + ); + + const result = await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'square' }, + 'android', + 'none' + ); + + expect(result).toEqual({ pass: true, message: 'Images match' }); + expect(existsSync(diffOutputPath('square-actual.png'))).toBe(false); + expect(existsSync(diffOutputPath('square-diff.png'))).toBe(false); + }); + + it('saves the diff and received images of a mismatch to __diff_output__', async () => { + await mkdir(path.dirname(snapshotPath), { recursive: true }); + await writeFile(snapshotPath, createPng({ fill: 0 })); + + const result = await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'square' }, + 'android', + 'none' + ); + + expect(result.pass).toBe(false); + expect(await readFile(diffOutputPath('square-actual.png'))).toEqual( + await readFile(receivedPath) + ); + expect(existsSync(diffOutputPath('square-diff.png'))).toBe(true); + }); + + it('names the received image of a missing snapshot after the whole snapshot name', async () => { + await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'logo.png-dark' }, + 'android', + 'none' + ); + + expect(existsSync(diffOutputPath('logo.png-dark-actual.png'))).toBe(true); + }); + + it('names the images of a mismatch after the whole snapshot name', async () => { + await mkdir(path.dirname(snapshotPath), { recursive: true }); + await writeFile( + path.join(path.dirname(snapshotPath), 'logo.png-dark.png'), + createPng({ fill: 0 }) + ); + + await matchImageSnapshot( + { path: receivedPath }, + testFilePath, + { name: 'logo.png-dark' }, + 'android', + 'none' + ); + + expect(existsSync(diffOutputPath('logo.png-dark-actual.png'))).toBe(true); + expect(existsSync(diffOutputPath('logo.png-dark-diff.png'))).toBe(true); + }); +}); + const solid = (r: number, g: number, b: number) => { const png = new PNG({ width: 4, height: 4 }); for (let i = 0; i < png.data.length; i += 4) { @@ -52,7 +208,8 @@ describe('matchImageSnapshot failure output', () => { { path: received }, testFile, { name: 'code' }, - 'ios' + 'ios', + 'new' ); const outDir = path.join(snapshotsDir, '__diff_output__'); @@ -90,7 +247,8 @@ describe('matchImageSnapshot failure output', () => { { path: received }, testFile, { name: 'box' }, - 'ios' + 'ios', + 'new' ); const actualPath = path.join( diff --git a/packages/bridge/src/image-snapshot.ts b/packages/bridge/src/image-snapshot.ts index ce8c44de..6df68748 100644 --- a/packages/bridge/src/image-snapshot.ts +++ b/packages/bridge/src/image-snapshot.ts @@ -3,7 +3,11 @@ import { ssim } from 'ssim.js'; import fs from 'node:fs/promises'; import path from 'node:path'; import { PNG } from 'pngjs'; -import type { FileReference, ImageSnapshotOptions } from './shared.js'; +import type { + FileReference, + ImageSnapshotOptions, + SnapshotUpdateState, +} from './shared.js'; type PixelmatchOptions = Parameters[5]; @@ -50,7 +54,8 @@ export const matchImageSnapshot = async ( screenshot: FileReference, testFilePath: string, options: ImageSnapshotOptions, - platformName: string + platformName: string, + updateSnapshot: SnapshotUpdateState ) => { const pixelmatchOptions = { ...DEFAULT_OPTIONS_FOR_PIXELMATCH, @@ -70,14 +75,28 @@ export const matchImageSnapshot = async ( const testDir = path.dirname(testFilePath); const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName); - const snapshotName = `${options.name}.png`; - const snapshotPath = path.join(snapshotsDir, snapshotName); + const snapshotPath = path.join(snapshotsDir, `${options.name}.png`); + // Failure artifacts live in their own folder so they can never overwrite + // a baseline of another snapshot (e.g. one named `-diff`). + const diffOutputDir = path.join(snapshotsDir, DIFF_OUTPUT_DIR_NAME); + const failureImagePath = (image: 'actual' | 'diff'): string => + path.join(diffOutputDir, `${options.name}-${image}.png`); + const actualPath = failureImagePath('actual'); await fs.mkdir(snapshotsDir, { recursive: true }); try { await fs.access(snapshotPath); } catch { + if (updateSnapshot === 'none') { + await fs.mkdir(diffOutputDir, { recursive: true }); + await fs.writeFile(actualPath, receivedBuffer); + return { + pass: false, + message: `New snapshot was not written: ${snapshotPath} does not exist, and missing snapshots are not written in CI mode. Received image saved at ${actualPath}; commit it as ${snapshotPath}, or run without CI mode (--ci=false) on a machine with this device and commit the snapshot it writes.`, + }; + } + // First time - create snapshot await fs.writeFile(snapshotPath, receivedBuffer); return { @@ -94,13 +113,6 @@ export const matchImageSnapshot = async ( const img2 = PNG.sync.read(snapshotBuffer); const { width, height } = img1; const diff = new PNG({ width, height }); - // Failure artifacts live in their own folder so they can never overwrite - // a baseline of another snapshot (e.g. one named `-diff`). - const diffOutputDir = path.join(snapshotsDir, DIFF_OUTPUT_DIR_NAME); - const actualPath = path.join( - diffOutputDir, - `${snapshotName.replace('.png', '')}-actual.png` - ); if (img1.width !== img2.width || img1.height !== img2.height) { await fs.mkdir(diffOutputDir, { recursive: true }); @@ -165,8 +177,7 @@ export const matchImageSnapshot = async ( if (!pass) { await fs.mkdir(diffOutputDir, { recursive: true }); - const diffFileName = `${snapshotName.replace('.png', '')}-diff.png`; - const diffPath = path.join(diffOutputDir, diffFileName); + const diffPath = failureImagePath('diff'); await fs.writeFile(diffPath, PNG.sync.write(diff)); await fs.writeFile(actualPath, receivedBuffer); diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index 2fcf408d..5b927d22 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -180,6 +180,7 @@ export const createHarnessBridge = async ( testPath, opts, context.platform.name, + context.updateSnapshot ?? 'new' ); } finally { // Only delete files this bridge created, never a client-supplied path. diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index 3cfa22d5..bbd28e40 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -169,6 +169,10 @@ export type BridgeServerFunctions = { ) => Promise<{ pass: boolean; message: string }>; }; +export type SnapshotUpdateState = 'all' | 'new' | 'none'; + export type HarnessContext = { platform: HarnessPlatform; + /** Jest's snapshot update state; when absent, a missing image snapshot is written. */ + updateSnapshot?: SnapshotUpdateState; }; diff --git a/packages/cli/skills/ui.md b/packages/cli/skills/ui.md index 34d9bf97..b5e1e5d2 100644 --- a/packages/cli/skills/ui.md +++ b/packages/cli/skills/ui.md @@ -37,7 +37,8 @@ Use this skill when the task requires: - Keep imports split correctly: core APIs from `react-native-harness`, UI APIs from `@react-native-harness/ui`. - Mention that `@react-native-harness/ui` requires installation, and native apps must be rebuilt after adding it. -- `toMatchImageSnapshot(...)` needs a unique snapshot `name`. +- `toMatchImageSnapshot(...)` needs a unique snapshot `name`, and cannot be used with `.not`. +- In Jest's CI mode, on by default in CI, a missing snapshot fails and its received image is saved as `-actual.png`: commit that image as `.png`, or run with `--ci=false` on the device that compares it and commit the snapshot it writes. - If screenshotting elements that extend beyond screen bounds, call out `disableViewFlattening: true` in `rn-harness.config.mjs`. - On web, UI interactions and screenshots run through the web runner's Playwright-backed browser environment. diff --git a/packages/jest/src/__tests__/bridge.test.ts b/packages/jest/src/__tests__/bridge.test.ts index b609ae7c..38abab6d 100644 --- a/packages/jest/src/__tests__/bridge.test.ts +++ b/packages/jest/src/__tests__/bridge.test.ts @@ -3,7 +3,10 @@ * connectToHarness (app side). Tests the full connection lifecycle and * RPC round-trip without coupling tests to the transport internals. */ +import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { HarnessBridge } from '@react-native-harness/bridge/server'; import { createHarnessBridge } from '@react-native-harness/bridge/server'; @@ -11,13 +14,16 @@ import { connectToHarness } from '@react-native-harness/bridge/client'; import type { HarnessContext } from '@react-native-harness/bridge'; import type { TestSuiteResult } from '@react-native-harness/bridge'; -const makeContext = (): HarnessContext => ({ +const makeContext = ( + updateSnapshot: HarnessContext['updateSnapshot'] = 'new', +): HarnessContext => ({ platform: { name: 'ios', platformId: 'ios', runner: '/dev/null', config: {}, }, + updateSnapshot, }); // --------------------------------------------------------------------------- @@ -236,6 +242,64 @@ describe('bridge: createHarnessBridge + connectToHarness', () => { }); }); + describe('image snapshots', () => { + it.each([ + [ + 'writes a missing snapshot and passes when the run passes updateSnapshot new', + makeContext('new'), + true, + ], + [ + 'fails a missing snapshot without writing it in CI mode when the run passes updateSnapshot none', + makeContext('none'), + false, + ], + [ + 'writes a missing snapshot and passes when the context names no snapshot state', + { platform: makeContext().platform }, + true, + ], + ] as const)( + '%s', + async (_, context, written) => { + bridge.dispose(); + bridge = await createHarnessBridge({ port: 0, context }); + bridgePort = (bridge.ws.address() as { port: number }).port; + + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'harness-bridge-snapshot-'), + ); + + try { + const receivedPath = path.join(directory, 'received.png'); + await fs.writeFile(receivedPath, 'received image'); + + const connectionPromise = bridge.nextConnection(); + const handle = await connect(); + handle.reportReady(device); + await connectionPromise; + + const result = await handle.matchImageSnapshot( + { path: receivedPath }, + path.join(directory, 'example.harness.tsx'), + { name: 'square' }, + 'ios', + ); + + expect(result.pass).toBe(written); + expect( + existsSync( + path.join(directory, '__image_snapshots__', 'ios', 'square.png'), + ), + ).toBe(written); + handle.disconnect(); + } finally { + await fs.rm(directory, { force: true, recursive: true }); + } + }, + ); + }); + describe('dispose', () => { it('rejects pending nextConnection() waiters', async () => { const pending = bridge.nextConnection(); diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index c23ee613..0e211c55 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -622,7 +622,10 @@ export const createHarnessSession = async ( const getCurrentRunId = () => currentRun?.runId; const clientLogCollector = createClientLogCollector(); - const context: HarnessContext = { platform }; + const context = { + platform, + updateSnapshot: globalConfig.updateSnapshot, + } satisfies Required; const crashArtifactWriter = createCrashArtifactWriter({ runnerName: platform.name, platformId: platform.platformId, diff --git a/packages/runtime/src/__tests__/to-match-image-snapshot.test.ts b/packages/runtime/src/__tests__/to-match-image-snapshot.test.ts new file mode 100644 index 00000000..337b2511 --- /dev/null +++ b/packages/runtime/src/__tests__/to-match-image-snapshot.test.ts @@ -0,0 +1,62 @@ +import { GLOBAL_EXPECT } from '@vitest/expect'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import '../globals.js'; + +const transferScreenshot = vi.fn(async () => ({ + path: '/project/received.png', +})); +const matchImageSnapshot = vi.fn(); + +vi.mock('../client/store.js', () => ({ + getHandle: () => ({ transferScreenshot, matchImageSnapshot }), +})); + +vi.mock('../runner/index.js', () => ({ + getHarnessContext: () => ({ + testFilePath: '/project/screen.harness.tsx', + runner: 'android', + }), +})); + +// The runtime's expect module installs itself as the global expect; Vitest's +// own expect is put back once the tests are done. +const vitestGlobalExpect = Object.getOwnPropertyDescriptor( + globalThis, + GLOBAL_EXPECT +); +const { expect: harnessExpect } = await import('../expect/expect.js'); + +afterAll(() => { + if (vitestGlobalExpect) { + Object.defineProperty(globalThis, GLOBAL_EXPECT, vitestGlobalExpect); + } +}); + +const screenshot = { data: new Uint8Array(4), width: 1, height: 1 }; + +describe('toMatchImageSnapshot', () => { + beforeEach(() => { + transferScreenshot.mockClear(); + matchImageSnapshot.mockReset(); + }); + + it('fails with the message of a comparison that did not pass', async () => { + matchImageSnapshot.mockResolvedValueOnce({ + pass: false, + message: 'New snapshot was not written', + }); + + await expect( + harnessExpect(screenshot).toMatchImageSnapshot({ name: 'square' }) + ).rejects.toThrow('New snapshot was not written'); + }); + + it('refuses .not before a snapshot is compared or written', async () => { + await expect( + harnessExpect(screenshot).not.toMatchImageSnapshot({ name: 'square' }) + ).rejects.toThrow('`.not` cannot be used with `.toMatchImageSnapshot()`.'); + + expect(transferScreenshot).not.toHaveBeenCalled(); + expect(matchImageSnapshot).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts index 81ca010a..48890485 100644 --- a/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts +++ b/packages/runtime/src/expect/matchers/toMatchImageSnapshot.ts @@ -20,6 +20,10 @@ export async function toMatchImageSnapshot( ); } + if (this.isNot) { + throw new Error('`.not` cannot be used with `.toMatchImageSnapshot()`.'); + } + const handle = getHandle(); const context = getHarnessContext(); diff --git a/packages/runtime/src/globals.ts b/packages/runtime/src/globals.ts index d3a63971..7ddaf1a3 100644 --- a/packages/runtime/src/globals.ts +++ b/packages/runtime/src/globals.ts @@ -13,7 +13,8 @@ declare module '@vitest/expect' { interface Matchers { /** * Match the received screenshot against a stored snapshot. - * Creates a new snapshot if one doesn't exist. + * Creates a new snapshot if one doesn't exist, except in Jest's CI mode, + * where a missing snapshot fails the test. Cannot be used with `.not`. */ toMatchImageSnapshot(options: ImageSnapshotOptions): Promise; } diff --git a/website/src/docs/api/cli.mdx b/website/src/docs/api/cli.mdx index 940e2d4d..93f66d17 100644 --- a/website/src/docs/api/cli.mdx +++ b/website/src/docs/api/cli.mdx @@ -76,6 +76,7 @@ React Native Harness supports the most commonly used Jest flags: - `--testNamePattern, -t`: Run only tests with a name that matches the regex. - `--testPathPattern`: A regexp string that is matched against all tests paths before executing the test. - `--verbose`: Display individual test results with the test suite hierarchy. +- `--ci`: Run in CI mode, where a missing image snapshot fails the test instead of being written. Jest turns it on in a CI environment it detects; `--ci=false` turns it off. ## Restrictions and Limitations @@ -107,4 +108,4 @@ The following Jest configuration flags are **disabled**: ### Snapshot Management -Snapshot testing for UI is handled via the separate `@react-native-harness/ui` package and the `toMatchImageSnapshot` matcher. Standard Jest snapshot flags like `--updateSnapshot` (`-u`) are currently not supported for native image snapshots. +Snapshot testing for UI is handled via the separate `@react-native-harness/ui` package and the `toMatchImageSnapshot` matcher. Standard Jest snapshot flags like `--updateSnapshot` (`-u`) are currently not supported for native image snapshots. A missing snapshot is written, except in CI mode (`--ci`), where the test fails instead; see [Creating snapshots](/docs/guides/ui-testing#creating-snapshots). diff --git a/website/src/docs/guides/ui-testing.mdx b/website/src/docs/guides/ui-testing.mdx index 784b7cb3..75e9293c 100644 --- a/website/src/docs/guides/ui-testing.mdx +++ b/website/src/docs/guides/ui-testing.mdx @@ -152,6 +152,14 @@ test('screenshot with custom options', async () => { | `diffColor` | `[number, number, number]` | `[255, 0, 0]` | RGB color for highlighting differences | | `ignoreRegions` | `Array<{x,y,width,height}>` | `[]` | Regions to exclude from comparison | +### Creating snapshots + +`toMatchImageSnapshot` stores each snapshot as `__image_snapshots__//.png` next to the test file. When that file does not exist, the test writes it and passes, except in Jest's [CI mode](https://jestjs.io/docs/cli#--ci), where the test fails without writing it and saves the received image as `__diff_output__/-actual.png`. Jest turns CI mode on when it detects a CI environment, such as one with the `CI` environment variable set; `--ci` and `--ci=false` override that. + +Snapshots are compared pixel by pixel, so create them on the device, simulator or browser that compares them: commit the `-actual.png` a CI run saved for a missing snapshot as `.png`, or run with `--ci=false` on a machine with that device and commit what it writes. The [GitHub Action](/docs/guides/ci-cd) uploads the `-actual.png` images of a run as its `visual-test-diffs-` artifact; in other CI, upload `**/__image_snapshots__/**/*-actual.png` from the job. + +Like Jest's own snapshot matchers, `toMatchImageSnapshot` cannot be negated: `expect(screenshot).not.toMatchImageSnapshot(...)` throws. + ### Element-specific screenshots You can capture screenshots of specific elements instead of the entire screen: