diff --git a/packages/vitest/src/browser/public/takeSnapshot.browser.test.ts b/packages/vitest/src/browser/public/takeSnapshot.browser.test.ts
new file mode 100644
index 00000000..cf4e49a2
--- /dev/null
+++ b/packages/vitest/src/browser/public/takeSnapshot.browser.test.ts
@@ -0,0 +1,98 @@
+import { beforeEach, expect, test } from 'vitest';
+import { commands } from 'vitest/browser';
+import { takeSnapshot } from './takeSnapshot';
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+});
+
+test('saves snapshot on server', async ({ task }) => {
+ const h1 = document.createElement('h1');
+ h1.textContent = 'Example heading';
+ document.body.appendChild(h1);
+
+ await takeSnapshot('example');
+
+ const snapshots = await commands.__chromatic_getSnapshots(task.id);
+ expect(snapshots).toHaveProperty('example');
+
+ expect(snapshots.example).toMatchInlineSnapshot(`
+ {
+ "attributes": {},
+ "childNodes": [
+ {
+ "id": "number",
+ "textContent": "Example heading",
+ "type": 3,
+ },
+ ],
+ "id": "number",
+ "tagName": "h1",
+ "type": 2,
+ }
+ `);
+});
+
+test('saves multiple snapshots', async ({ task }) => {
+ await takeSnapshot('example-1');
+ await takeSnapshot('example-2');
+
+ const snapshots = await commands.__chromatic_getSnapshots(task.id);
+
+ expect(snapshots).toHaveProperty('example-1');
+ expect(snapshots).toHaveProperty('example-2');
+});
+
+test('implicit snapshot names increment', async ({ task }) => {
+ await takeSnapshot();
+
+ {
+ const snapshots = await commands.__chromatic_getSnapshots(task.id);
+
+ expect(snapshots).toHaveProperty('Snapshot #1');
+ expect(Object.keys(snapshots)).toHaveLength(1);
+ }
+
+ await takeSnapshot();
+
+ {
+ const snapshots = await commands.__chromatic_getSnapshots(task.id);
+
+ expect.soft(snapshots).toHaveProperty('Snapshot #1');
+ expect.soft(snapshots).toHaveProperty('Snapshot #2');
+ expect.soft(Object.keys(snapshots)).toHaveLength(2);
+ }
+});
+
+test('blob URLs are replaced with data URLs', async ({ task }) => {
+ const blob = await fetch(
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAASCAQAAADIvofAAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfhBhAPKSstM+EuAAAAvUlEQVQY05WQIW4CYRgF599gEZgeoAKBWIfCNSmVvQMe3wv0ChhIViKwtTQEAYJwhgpISBA0JSxNIdlB7LIGTJ/8kpeZ7wW5TcT9o/QNBtvOrrWMrtg0sSGOFeELbHlCDsQ+ukeYiHNFJPHBDRKlQKVEbFkLUT3AiAxI6VGCXsWXAoQLBUl5E7HjUFwiyI4zf/wWoB3CFnxX5IeGdY8IGU/iwE9jcZrLy4pnEat+FL4hf/cbqREKo/Cf6W5zASVMeh234UtGAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE3LTA2LTE2VDE1OjQxOjQzLTA3OjAwd1xNIQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNy0wNi0xNlQxNTo0MTo0My0wNzowMAYB9Z0AAAAASUVORK5CYII='
+ ).then((res) => res.blob());
+
+ const img = document.createElement('img');
+ const loaded = new Promise((resolve) => (img.onload = resolve));
+ img.src = window.URL.createObjectURL(blob);
+ document.body.appendChild(img);
+
+ await loaded;
+ await takeSnapshot('example');
+
+ const snapshots = await commands.__chromatic_getSnapshots(task.id);
+ expect(snapshots).toHaveProperty('example');
+
+ const snapshot = snapshots.example;
+ expect(snapshot).toMatchInlineSnapshot(`
+ {
+ "attributes": {
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAASCAQAAADIvofAAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfhBhAPKSstM+EuAAAAvUlEQVQY05WQIW4CYRgF599gEZgeoAKBWIfCNSmVvQMe3wv0ChhIViKwtTQEAYJwhgpISBA0JSxNIdlB7LIGTJ/8kpeZ7wW5TcT9o/QNBtvOrrWMrtg0sSGOFeELbHlCDsQ+ukeYiHNFJPHBDRKlQKVEbFkLUT3AiAxI6VGCXsWXAoQLBUl5E7HjUFwiyI4zf/wWoB3CFnxX5IeGdY8IGU/iwE9jcZrLy4pnEat+FL4hf/cbqREKo/Cf6W5zASVMeh234UtGAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE3LTA2LTE2VDE1OjQxOjQzLTA3OjAwd1xNIQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNy0wNi0xNlQxNTo0MTo0My0wNzowMAYB9Z0AAAAASUVORK5CYII=",
+ },
+ "childNodes": [],
+ "id": "number",
+ "rootId": 301,
+ "tagName": "img",
+ "type": 2,
+ }
+ `);
+
+ expect(img.src).toMatch(/^blob:/);
+});
diff --git a/packages/vitest/src/browser/public/takeSnapshot.test.ts b/packages/vitest/src/browser/public/takeSnapshot.test.ts
new file mode 100644
index 00000000..725a1211
--- /dev/null
+++ b/packages/vitest/src/browser/public/takeSnapshot.test.ts
@@ -0,0 +1,87 @@
+import { expect, test } from 'vitest';
+import { runFixture } from '../../../test/utils/node';
+
+/** See {@link file://./../../../test/fixtures/take-snapshot.test.ts} */
+const include = ['take-snapshot.test.ts'];
+
+test('provides descriptive error when called in non-registered test', async () => {
+ const { stderr } = await runFixture(
+ {
+ include,
+ provide: { testName: 'one' },
+ },
+ { disabled: true }
+ );
+
+ expect(stderr).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL chromium take-snapshot.test.ts > test #1
+ TypeError: takeSnapshot() cannot be called in a test that is not registered for Chromatic plugin.
+ Make sure chromium project has chromaticPlugin() enabled.
+ ❯ take-snapshot.test.ts:7:8
+ 5| document.body.innerHTML = '
Example heading
';
+ 6|
+ 7| await takeSnapshot();
+ | ^
+ 8|
+ 9| expect.fail('Should not reach this point');
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯"
+ `);
+});
+
+test('provides descriptive error when called outside of a test()', async () => {
+ const { stderr } = await runFixture({ include, provide: { testName: 'two' } });
+
+ expect(stderr).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL chromium take-snapshot.test.ts > suite
+ TypeError: takeSnapshot() must be called within a test()
+ ❯ take-snapshot.test.ts:14:10
+ 12| describe.runIf(inject('testName') === 'two')('suite', async () => {
+ 13| beforeAll(async () => {
+ 14| await takeSnapshot();
+ | ^
+ 15| });
+ 16|
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯"
+ `);
+});
+
+test('provides descriptive error when not awaited', async () => {
+ const { stderr } = await runFixture({ include, provide: { testName: 'three' } });
+
+ expect(stderr).toMatchInlineSnapshot(`
+ "
+ ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL chromium take-snapshot.test.ts > test #3
+ Error: takeSnapshot() call was not awaited!
+ ❯ take-snapshot.test.ts:23:2
+ 21| document.body.innerHTML = 'Example heading
';
+ 22|
+ 23| takeSnapshot(); // Leave the promise floating, no await
+ | ^
+ 24|
+ 25| // another
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
+
+ FAIL chromium take-snapshot.test.ts > test #3
+ Error: takeSnapshot() call was not awaited!
+ ❯ take-snapshot.test.ts:26:2
+ 24|
+ 25| // another
+ 26| takeSnapshot();
+ | ^
+ 27| });
+ 28|
+
+ ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯"
+ `);
+});
diff --git a/packages/vitest/src/browser/public/takeSnapshot.ts b/packages/vitest/src/browser/public/takeSnapshot.ts
index 6fec4347..5855cb47 100644
--- a/packages/vitest/src/browser/public/takeSnapshot.ts
+++ b/packages/vitest/src/browser/public/takeSnapshot.ts
@@ -5,20 +5,65 @@ import { serializedNodeWithId } from '@rrweb/types';
import { getCurrentTest } from '../getCurrentTest';
import type {} from '../../node/commands';
+interface Options {
+ ignoreUnawaited?: boolean;
+}
+
/**
* Take visual regression snapshot of the current state of the DOM.
*/
-export async function takeSnapshot(name?: string) {
+async function takeSnapshot(name?: string): Promise;
+
+/** @internal Pass options when used by automatic snapshots */
+async function takeSnapshot(name: string | undefined, options: Options): Promise;
+
+async function takeSnapshot(name?: string, options?: Options): Promise {
const test = getCurrentTest();
- assert(test, 'takeSnapshot() must be called within a test()');
- const domSnapshot = snapshot(document, { recordCanvas: true });
+ if (!test) {
+ throw new TypeError('takeSnapshot() must be called within a test()');
+ }
+
+ if (!test.meta.__chromatic_isRegistered) {
+ throw new TypeError(
+ 'takeSnapshot() cannot be called in a test that is not registered for Chromatic plugin.' +
+ `\nMake sure ${test.file.projectName || 'root'} project has chromaticPlugin() enabled.`
+ );
+ }
+
+ test.meta.__chromatic_isTakeSnapshotCalled = true;
+ const domSnapshot = snapshot(document, { recordCanvas: true });
assert(domSnapshot, 'Failed to capture DOM snapshot');
- await replaceBlobUrls(domSnapshot);
+ const save = async () => {
+ await replaceBlobUrls(domSnapshot);
+ await commands.__chromatic_uploadDOMSnapshot(test.id, domSnapshot, name);
+ };
+
+ // Ignore is set when called by automatic snapshots
+ if (options?.ignoreUnawaited) {
+ return await save();
+ }
+
+ /**
+ * Provide descriptive error if the user forgets to await the takeSnapshot() call.
+ * See {@link file://./takeSnapshot.test.ts} for examples.
+ */
+ const error = new Error('takeSnapshot() call was not awaited!');
+ Error.captureStackTrace?.(error, takeSnapshot);
- await commands.__chromatic_uploadDOMSnapshot(test.id, domSnapshot, name);
+ const pendingCall = { promise: save(), error };
+ test.meta.__chromatic_pendingTakeSnapshots ||= [];
+ test.meta.__chromatic_pendingTakeSnapshots.push(pendingCall);
+
+ await pendingCall.promise.finally(() => {
+ const index = test.meta.__chromatic_pendingTakeSnapshots?.indexOf(pendingCall);
+
+ if (index !== undefined && index !== -1) {
+ test.meta.__chromatic_pendingTakeSnapshots?.splice(index, 1);
+ }
+ });
}
async function replaceBlobUrls(node: serializedNodeWithId) {
@@ -57,3 +102,5 @@ async function toDataURL(url: string): Promise {
reader.readAsDataURL(blob);
});
}
+
+export { takeSnapshot };
diff --git a/packages/vitest/src/browser/setupFile.ts b/packages/vitest/src/browser/setupFile.ts
index caf2842c..8bbe34dc 100644
--- a/packages/vitest/src/browser/setupFile.ts
+++ b/packages/vitest/src/browser/setupFile.ts
@@ -7,14 +7,24 @@ import type {} from '../node/commands';
// Destructuring the context is important, so that possible user-provided fixtures work too
beforeEach(async ({ task }) => {
task.meta.__chromatic_isRegistered = true;
+ task.meta.__chromatic_isTakeSnapshotCalled = false;
await commands.__chromatic_interceptFetch(task.id);
return async function afterEach() {
+ const { __chromatic_pendingTakeSnapshots: pendingTakeSnapshots } = task.meta;
+
+ if (pendingTakeSnapshots?.length) {
+ throw new AggregateError(
+ pendingTakeSnapshots.map((call) => call.error),
+ `${pendingTakeSnapshots.length} unawaited takeSnapshot() call(s)`
+ );
+ }
+
// TODO: Replace with waitForIdleNetwork API in Milestone #4
await new Promise((resolve) => setTimeout(resolve, 500));
- await takeSnapshot();
+ await takeSnapshot(undefined, { ignoreUnawaited: true });
await commands.__chromatic_writeTestResult(task.id);
@@ -25,6 +35,8 @@ beforeEach(async ({ task }) => {
* Clean internal task meta so that it doesn't show up on Vitest's reporters
*/
function cleanup() {
+ task.meta.__chromatic_isTakeSnapshotCalled = undefined;
task.meta.__chromatic_isRegistered = undefined;
+ task.meta.__chromatic_pendingTakeSnapshots = undefined;
}
});
diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts
index 53391379..a8b9436e 100644
--- a/packages/vitest/src/index.ts
+++ b/packages/vitest/src/index.ts
@@ -1,3 +1,3 @@
/* Entrypoint for browser context */
-export {};
+export { takeSnapshot } from './browser/public/takeSnapshot';
diff --git a/packages/vitest/src/node/commands.browser.test.ts b/packages/vitest/src/node/commands.browser.test.ts
index 78aec4c6..95198a78 100644
--- a/packages/vitest/src/node/commands.browser.test.ts
+++ b/packages/vitest/src/node/commands.browser.test.ts
@@ -4,6 +4,7 @@ import { InternalTestContext } from '../types';
test('setupFile is registered', async (context) => {
expect(context.task.meta.__chromatic_isRegistered).toBe(true);
+ expect(context.task.meta.__chromatic_isTakeSnapshotCalled).toBe(false);
});
test('browser commands are available', () => {
@@ -13,6 +14,7 @@ test('browser commands are available', () => {
expect(Object.fromEntries(chromaticCommands)).toMatchInlineSnapshot(`
{
+ "__chromatic_getSnapshots": [Function],
"__chromatic_interceptFetch": [Function],
"__chromatic_reset": [Function],
"__chromatic_uploadDOMSnapshot": [Function],
diff --git a/packages/vitest/src/node/commands.ts b/packages/vitest/src/node/commands.ts
index 41775b9a..1d666cc6 100644
--- a/packages/vitest/src/node/commands.ts
+++ b/packages/vitest/src/node/commands.ts
@@ -105,6 +105,14 @@ export function createCommands(options: ResolvedOptions) {
resourceArchivers.clear();
snapshots.clear();
},
+
+ /**
+ * Get currently save snapshots. Used only during testing.
+ * @internal
+ */
+ async __chromatic_getSnapshots(_, id: string) {
+ return Object.fromEntries(snapshots.get(id) || []);
+ },
} satisfies Record>;
async function onTestCleanup(id: TestID) {
diff --git a/packages/vitest/src/node/plugin.test.ts b/packages/vitest/src/node/plugin.test.ts
index 78daa3bd..05f0e3c4 100644
--- a/packages/vitest/src/node/plugin.test.ts
+++ b/packages/vitest/src/node/plugin.test.ts
@@ -22,6 +22,7 @@ test('adds browser commands', async () => {
expect(config.browser.commands).toMatchInlineSnapshot(`
{
+ "__chromatic_getSnapshots": [Function],
"__chromatic_interceptFetch": [Function],
"__chromatic_reset": [Function],
"__chromatic_uploadDOMSnapshot": [Function],
diff --git a/packages/vitest/src/types.ts b/packages/vitest/src/types.ts
index b03ad1ef..dcd08c58 100644
--- a/packages/vitest/src/types.ts
+++ b/packages/vitest/src/types.ts
@@ -36,6 +36,12 @@ export interface ResolvedOptions
type InternalMeta = Record & {
/** Indicates whether Visual Regression tracking is registered */
__chromatic_isRegistered?: boolean;
+
+ /** Indicates whether `takeSnapshot()` has been called */
+ __chromatic_isTakeSnapshotCalled?: boolean;
+
+ /** Pending `takeSnapshot()` promises */
+ __chromatic_pendingTakeSnapshots?: { promise: Promise; error: Error }[];
};
/** @internal */
diff --git a/packages/vitest/test/fixtures/take-snapshot.test.ts b/packages/vitest/test/fixtures/take-snapshot.test.ts
new file mode 100644
index 00000000..17a61e03
--- /dev/null
+++ b/packages/vitest/test/fixtures/take-snapshot.test.ts
@@ -0,0 +1,27 @@
+import { beforeAll, describe, expect, test, inject } from 'vitest';
+import { takeSnapshot } from '../../src';
+
+test.runIf(inject('testName') === 'one')('test #1', async () => {
+ document.body.innerHTML = 'Example heading
';
+
+ await takeSnapshot();
+
+ expect.fail('Should not reach this point');
+});
+
+describe.runIf(inject('testName') === 'two')('suite', async () => {
+ beforeAll(async () => {
+ await takeSnapshot();
+ });
+
+ test('test #2', async () => {});
+});
+
+test.runIf(inject('testName') === 'three')('test #3', async () => {
+ document.body.innerHTML = 'Example heading
';
+
+ takeSnapshot(); // Leave the promise floating, no await
+
+ // another
+ takeSnapshot();
+});
diff --git a/packages/vitest/test/manual-snapshots.test.ts b/packages/vitest/test/manual-snapshots.test.ts
new file mode 100644
index 00000000..88b437da
--- /dev/null
+++ b/packages/vitest/test/manual-snapshots.test.ts
@@ -0,0 +1,19 @@
+import { expect } from 'vitest';
+import { page } from 'vitest/browser';
+import { test } from './utils/browser';
+import { takeSnapshot } from '../dist';
+
+test.override({ url: '/manual-snapshots' });
+
+test('snapshot names increment for both manual and automatic', async () => {
+ await takeSnapshot();
+ await page.getByText('Click me').click();
+ expect(page.getByText('I am hiding inside!')).toBeVisible();
+
+ await takeSnapshot('another test');
+ await takeSnapshot();
+});
+
+test.todo('manual snapshot is taken even when automatic snapshots are turned off');
+
+test.todo('manual snapshot name autogenerated when not passed');
diff --git a/packages/vitest/tsconfig.json b/packages/vitest/tsconfig.json
index cbc3f37a..a43d5958 100644
--- a/packages/vitest/tsconfig.json
+++ b/packages/vitest/tsconfig.json
@@ -1,7 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
- "target": "esnext"
+ "rootDir": ".",
+ "module": "preserve",
+ "moduleResolution": "bundler",
+ "target": "esnext",
+ "lib": ["es2021", "dom"],
+ "stripInternal": true
},
"include": ["src", "test", "vitest.config.unit.ts", "vitest.config.browser.ts"]
}
diff --git a/packages/vitest/vitest.config.browser.ts b/packages/vitest/vitest.config.browser.ts
index b7004a9e..2ad1af6b 100644
--- a/packages/vitest/vitest.config.browser.ts
+++ b/packages/vitest/vitest.config.browser.ts
@@ -35,5 +35,6 @@ export default defineProject({
declare module 'vitest' {
export interface ProvidedContext {
processCwd: string;
+ testName?: string;
}
}