@snapvisor/storybook 6.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +66 -0
- package/dist/test-runner.cjs +5 -0
- package/dist/test-runner.d.mts +60 -0
- package/dist/test-runner.mjs +311 -0
- package/dist/vitest-plugin.d.mts +54 -0
- package/dist/vitest-plugin.mjs +312 -0
- package/dist/vitest-setup-file.mjs +57 -0
- package/dist/vitest.d.mts +62 -0
- package/dist/vitest.mjs +82 -0
- package/package.json +104 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright 2026 Devino Solutions Inc. (Snapvisor modifications)
|
|
2
|
+
Copyright 2022 Smooth Code
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://argos-ci.com/?utm_source=github&utm_medium=logo" target="_blank">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/argos-ci/argos/main/resources/logos/github-readme-logo-dark.png">
|
|
5
|
+
<img alt="Argos" src="https://raw.githubusercontent.com/argos-ci/argos/main/resources/logos/github-readme-logo-light.png" width="360" height="70">
|
|
6
|
+
</picture>
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center"><strong>The open source visual testing platform for AI-native engineering teams.</strong></p>
|
|
11
|
+
|
|
12
|
+
# Official Argos Storybook integration
|
|
13
|
+
|
|
14
|
+
[](https://www.npmjs.com/package/@snapvisor/storybook)
|
|
15
|
+
[](https://www.npmjs.com/package/@snapvisor/storybook)
|
|
16
|
+
[](https://www.npmjs.com/package/@snapvisor/storybook)
|
|
17
|
+
|
|
18
|
+
Capture and review visual changes of your [Storybook](https://storybook.js.org/) stories with Argos. It runs your stories in a real browser and uploads a screenshot of each one to Argos in your CI.
|
|
19
|
+
|
|
20
|
+
Visit the [Storybook SDK documentation](https://argos-ci.com/docs/reference/storybook) for guides, the API reference, and more.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install --save-dev @snapvisor/storybook
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
The recommended way to run visual tests is with the [Storybook Vitest addon](https://storybook.js.org/docs/writing-tests/integrations/vitest-addon). Register the Argos plugin in your Vitest config:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// vitest.config.ts
|
|
34
|
+
import { defineConfig } from "vitest/config";
|
|
35
|
+
import { argosVitestPlugin } from "@snapvisor/storybook/vitest-plugin";
|
|
36
|
+
|
|
37
|
+
export default defineConfig({
|
|
38
|
+
plugins: [
|
|
39
|
+
argosVitestPlugin({
|
|
40
|
+
// Upload the screenshots to Argos only on CI.
|
|
41
|
+
uploadToArgos: process.env.CI === "true",
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A screenshot is captured for every story automatically. To capture additional screenshots (for example, at different steps of an interaction), call `argosScreenshot` inside a story's `play` function:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// Button.stories.tsx
|
|
51
|
+
import { argosScreenshot } from "@snapvisor/storybook/vitest";
|
|
52
|
+
|
|
53
|
+
export const Example: Story = {
|
|
54
|
+
play: async (ctx) => {
|
|
55
|
+
await argosScreenshot(ctx, "example");
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
> Using the [Storybook Test Runner](https://storybook.js.org/docs/writing-tests/integrations/test-runner) instead? Import `argosScreenshot` from `@snapvisor/storybook/test-runner` and call it from the `postVisit` hook. See the [Test Runner quickstart](https://argos-ci.com/docs/quickstart/storybook-quickstart/storybook-test-runner-quickstart).
|
|
61
|
+
|
|
62
|
+
## Links
|
|
63
|
+
|
|
64
|
+
- [Official SDK Docs](https://argos-ci.com/docs/reference/storybook)
|
|
65
|
+
- [Quickstart](https://argos-ci.com/docs/quickstart/storybook-quickstart)
|
|
66
|
+
- [Discord](https://argos-ci.com/discord)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { TestContext } from "@storybook/test-runner";
|
|
2
|
+
import { ArgosScreenshotOptions as ArgosScreenshotOptions$1 } from "@snapvisor/playwright";
|
|
3
|
+
import { Page } from "playwright";
|
|
4
|
+
|
|
5
|
+
//#region src/utils/parameters.d.ts
|
|
6
|
+
type StorybookGlobals = Record<string, any>;
|
|
7
|
+
type FitToContent = {
|
|
8
|
+
/**
|
|
9
|
+
* Padding around the content in pixels.
|
|
10
|
+
* @default 16
|
|
11
|
+
*/
|
|
12
|
+
padding: number;
|
|
13
|
+
/**
|
|
14
|
+
* Zoom level for the content.
|
|
15
|
+
* @default 2
|
|
16
|
+
*/
|
|
17
|
+
zoom: number;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Argos parameters in Storybook.
|
|
21
|
+
*/
|
|
22
|
+
interface ArgosStorybookParameters {
|
|
23
|
+
/**
|
|
24
|
+
* Modes for the story.
|
|
25
|
+
*/
|
|
26
|
+
modes?: Record<string, StorybookGlobals>;
|
|
27
|
+
/**
|
|
28
|
+
* Fit to content option for the story.
|
|
29
|
+
*/
|
|
30
|
+
fitToContent?: boolean | Partial<FitToContent>;
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/utils/screenshot.d.ts
|
|
34
|
+
type ArgosScreenshotOptions = Omit<ArgosScreenshotOptions$1, "viewports">;
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/test-runner.d.ts
|
|
37
|
+
/**
|
|
38
|
+
* Stabilize the UI and takes a screenshot of the application under test.
|
|
39
|
+
*
|
|
40
|
+
* @example argosScreenshot(page, context, options)
|
|
41
|
+
* @see https://argos-ci.com/docs/reference/playwright
|
|
42
|
+
*/
|
|
43
|
+
declare function argosScreenshot(
|
|
44
|
+
/**
|
|
45
|
+
* Playwright `page` object.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
page: Page,
|
|
49
|
+
/**
|
|
50
|
+
* Context of the test.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
context: TestContext,
|
|
54
|
+
/**
|
|
55
|
+
* Options for the screenshot.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
options?: ArgosScreenshotOptions): Promise<void>;
|
|
59
|
+
//#endregion
|
|
60
|
+
export { type ArgosScreenshotOptions, type ArgosStorybookParameters, argosScreenshot };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { getStoryContext, waitForPageReady } from "@storybook/test-runner";
|
|
3
|
+
import { DO_NOT_USE_setMetadataConfig, argosScreenshot as argosScreenshot$1 } from "@snapvisor/playwright";
|
|
4
|
+
import { readVersionFromPackage } from "@snapvisor/util";
|
|
5
|
+
//#region src/utils/metadata.ts
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
/**
|
|
8
|
+
* Get the version of the Argos Playwright SDK.
|
|
9
|
+
*/
|
|
10
|
+
async function getArgosStorybookVersion() {
|
|
11
|
+
return readVersionFromPackage(require.resolve("@snapvisor/storybook/package.json"));
|
|
12
|
+
}
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/utils/storyMetadata.ts
|
|
15
|
+
function toArray(v) {
|
|
16
|
+
return Array.isArray(v) ? v : v ? [v] : [];
|
|
17
|
+
}
|
|
18
|
+
function mergeTags(...tags) {
|
|
19
|
+
const merged = tags.flatMap((tags) => toArray(tags));
|
|
20
|
+
return Array.from(new Set(merged)).filter((tag) => typeof tag === "string" && tag !== "");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Return whether a story context has a play function.
|
|
24
|
+
* Handles both the `play` field (plain story function) and the legacy
|
|
25
|
+
* `playFunction` field present in older Storybook test-runner contexts.
|
|
26
|
+
*/
|
|
27
|
+
function hasPlay(storyContext) {
|
|
28
|
+
if (!storyContext || typeof storyContext !== "object") return false;
|
|
29
|
+
return "play" in storyContext && typeof storyContext.play === "function" || "playFunction" in storyContext && typeof storyContext.playFunction === "function";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Build the story metadata object that is attached to a screenshot.
|
|
33
|
+
*
|
|
34
|
+
* @param story - The story shape (id, tags, play flag).
|
|
35
|
+
* @param storyMode - The Storybook mode name (e.g. "dark", "mobile") as defined in `parameters.argos.modes`
|
|
36
|
+
*/
|
|
37
|
+
function getStoryMetadata(story, storyMode) {
|
|
38
|
+
return {
|
|
39
|
+
id: story.id,
|
|
40
|
+
tags: story.tags ?? [],
|
|
41
|
+
mode: storyMode ?? void 0,
|
|
42
|
+
play: Boolean(story.play)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/utils/parameters.ts
|
|
47
|
+
/**
|
|
48
|
+
* Get the default viewport size from the Storybook parameters.
|
|
49
|
+
*/
|
|
50
|
+
function getDefaultViewport(parameters) {
|
|
51
|
+
const defaultViewport = parameters?.viewport?.defaultViewport;
|
|
52
|
+
if (defaultViewport) return getViewport(parameters, defaultViewport);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get the viewport size from the Storybook parameters.
|
|
57
|
+
*/
|
|
58
|
+
function getViewport(parameters, viewportName) {
|
|
59
|
+
if (typeof viewportName === "number") return {
|
|
60
|
+
width: viewportName,
|
|
61
|
+
height: 720
|
|
62
|
+
};
|
|
63
|
+
const viewports = parameters?.viewport?.viewports;
|
|
64
|
+
if (viewports && viewportName in viewports) {
|
|
65
|
+
if ("styles" in viewports[viewportName] && viewports[viewportName].styles) {
|
|
66
|
+
const width = parseInt(viewports[viewportName].styles.width, 10);
|
|
67
|
+
const height = parseInt(viewports[viewportName].styles.height, 10);
|
|
68
|
+
if (!isNaN(width) && !isNaN(height)) return {
|
|
69
|
+
width,
|
|
70
|
+
height
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get the Argos parameters from the Storybook context.
|
|
78
|
+
*/
|
|
79
|
+
function getArgosParameters(parameters) {
|
|
80
|
+
if ("argos" in parameters && parameters.argos && typeof parameters.argos === "object") return parameters.argos;
|
|
81
|
+
if ("chromatic" in parameters && parameters.chromatic && typeof parameters.chromatic === "object") return parameters.chromatic;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const DEFAULT_FIT_TO_CONTENT = {
|
|
85
|
+
padding: 16,
|
|
86
|
+
zoom: 2
|
|
87
|
+
};
|
|
88
|
+
function getFitToContentFromParameters(parameters) {
|
|
89
|
+
const argosParameters = getArgosParameters(parameters);
|
|
90
|
+
if (argosParameters && "fitToContent" in argosParameters) {
|
|
91
|
+
if (typeof argosParameters.fitToContent === "boolean") return argosParameters.fitToContent ? DEFAULT_FIT_TO_CONTENT : null;
|
|
92
|
+
if (typeof argosParameters.fitToContent === "object" && argosParameters.fitToContent) return {
|
|
93
|
+
...DEFAULT_FIT_TO_CONTENT,
|
|
94
|
+
...argosParameters.fitToContent
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return DEFAULT_FIT_TO_CONTENT;
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/utils/screenshot.ts
|
|
101
|
+
/**
|
|
102
|
+
* Take a screenshot in the context of a Storybook story.
|
|
103
|
+
*/
|
|
104
|
+
async function storybookArgosScreenshot(handler, context, options) {
|
|
105
|
+
const argosOptions = {
|
|
106
|
+
...options,
|
|
107
|
+
stabilize: options?.stabilize ?? {
|
|
108
|
+
waitForAriaBusy: false,
|
|
109
|
+
...typeof options?.stabilize === "object" ? options.stabilize : {}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const version = await getArgosStorybookVersion();
|
|
113
|
+
const storyUrl = `http://localhost:6006/?path=/story/${context.story.id}`;
|
|
114
|
+
const metadata = {
|
|
115
|
+
sdk: {
|
|
116
|
+
name: "@snapvisor/storybook",
|
|
117
|
+
version
|
|
118
|
+
},
|
|
119
|
+
playwrightLibraries: context.playwrightLibraries,
|
|
120
|
+
url: storyUrl,
|
|
121
|
+
test: context.test,
|
|
122
|
+
story: getStoryMetadata(context.story)
|
|
123
|
+
};
|
|
124
|
+
const modes = getArgosParameters(context.story.parameters)?.modes;
|
|
125
|
+
const allAttachments = [];
|
|
126
|
+
if (context.mode === "automatic") if (modes) for (const [name, globals] of Object.entries(modes)) {
|
|
127
|
+
if (globals.disabled) continue;
|
|
128
|
+
await handler.evaluate((name) => {
|
|
129
|
+
globalThis.__ARGOS_CURRENT_MODE = name;
|
|
130
|
+
}, name);
|
|
131
|
+
const attachments = await runHooksAndScreenshot({
|
|
132
|
+
handler,
|
|
133
|
+
context,
|
|
134
|
+
metadata,
|
|
135
|
+
options: argosOptions,
|
|
136
|
+
suffix: getModeSuffix(name),
|
|
137
|
+
mode: name,
|
|
138
|
+
globals: {
|
|
139
|
+
...context.story.globals,
|
|
140
|
+
...globals
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
allAttachments.push(...attachments);
|
|
144
|
+
await context.setViewportSize("initial");
|
|
145
|
+
await handler.evaluate(() => {
|
|
146
|
+
delete globalThis.__ARGOS_CURRENT_MODE;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const attachments = await runHooksAndScreenshot({
|
|
151
|
+
handler,
|
|
152
|
+
context,
|
|
153
|
+
metadata,
|
|
154
|
+
options: argosOptions,
|
|
155
|
+
mode: null,
|
|
156
|
+
globals: context.story.globals ?? {}
|
|
157
|
+
});
|
|
158
|
+
await context.setViewportSize("initial");
|
|
159
|
+
allAttachments.push(...attachments);
|
|
160
|
+
}
|
|
161
|
+
else if (context.mode === "manual") {
|
|
162
|
+
const currentMode = await handler.evaluate(() => {
|
|
163
|
+
return globalThis.__ARGOS_CURRENT_MODE || null;
|
|
164
|
+
});
|
|
165
|
+
if (modes && !currentMode) return;
|
|
166
|
+
await markStoryAsRendered(handler, context.story.id);
|
|
167
|
+
DO_NOT_USE_setMetadataConfig({
|
|
168
|
+
...metadata,
|
|
169
|
+
story: getStoryMetadata(context.story, currentMode)
|
|
170
|
+
});
|
|
171
|
+
await argosScreenshot$1(handler, composeName(context.name, getModeSuffix(currentMode)), options);
|
|
172
|
+
}
|
|
173
|
+
await context.afterScreenshot?.({
|
|
174
|
+
handler,
|
|
175
|
+
globals: {}
|
|
176
|
+
});
|
|
177
|
+
return allAttachments;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Run the story with the given globals.
|
|
181
|
+
*/
|
|
182
|
+
async function runStory(args) {
|
|
183
|
+
const { handler, globals, storyId } = args;
|
|
184
|
+
await handler.evaluate(async (globals) => {
|
|
185
|
+
if ("__ARGOS_STORYBOOK_STORY" in globalThis) {
|
|
186
|
+
const storyFn = globalThis.__ARGOS_STORYBOOK_STORY;
|
|
187
|
+
if (!storyFn) throw new Error("@snapvisor/storybook: Unable to find `__ARGOS_STORYBOOK_STORY`.");
|
|
188
|
+
const canvasElement = (() => {
|
|
189
|
+
document.getElementById("storybook-root")?.remove();
|
|
190
|
+
const canvasElement = document.createElement("div");
|
|
191
|
+
canvasElement.id = "storybook-root";
|
|
192
|
+
document.body.appendChild(canvasElement);
|
|
193
|
+
return canvasElement;
|
|
194
|
+
})();
|
|
195
|
+
await storyFn.run({
|
|
196
|
+
globals,
|
|
197
|
+
canvasElement
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const channel = (() => {
|
|
202
|
+
if ("__STORYBOOK_PREVIEW__" in globalThis) return globalThis.__STORYBOOK_PREVIEW__.channel;
|
|
203
|
+
return null;
|
|
204
|
+
})();
|
|
205
|
+
if (!channel) throw new Error("@snapvisor/storybook: Unable to find `__STORYBOOK_PREVIEW__`.");
|
|
206
|
+
const initialGlobals = channel.last("globalsUpdated")?.[0].initialGlobals;
|
|
207
|
+
channel.emit("updateGlobals", { globals: {
|
|
208
|
+
...initialGlobals,
|
|
209
|
+
...globals
|
|
210
|
+
} });
|
|
211
|
+
}, globals);
|
|
212
|
+
await markStoryAsRendered(handler, storyId);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Notify Storybook that the story has been rendered.
|
|
216
|
+
* It will run all the necessary hooks (like pseudo-states).
|
|
217
|
+
*/
|
|
218
|
+
async function markStoryAsRendered(handler, storyId) {
|
|
219
|
+
await handler.evaluate((storyId) => {
|
|
220
|
+
if ("__ARGOS_STORYBOOK_STORY" in globalThis) {
|
|
221
|
+
const addons = globalThis.__STORYBOOK_ADDONS_PREVIEW;
|
|
222
|
+
if (!addons) throw new Error("@snapvisor/storybook: Unable to find `__STORYBOOK_ADDONS_PREVIEW`.");
|
|
223
|
+
addons.getChannel().emit("storyRendered", storyId);
|
|
224
|
+
}
|
|
225
|
+
}, storyId);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Wait for the page to be ready and take a screenshot.
|
|
229
|
+
*/
|
|
230
|
+
async function runHooksAndScreenshot(args) {
|
|
231
|
+
const { handler, context, options, globals, metadata, mode } = args;
|
|
232
|
+
await runStory({
|
|
233
|
+
handler,
|
|
234
|
+
globals,
|
|
235
|
+
storyId: context.story.id
|
|
236
|
+
});
|
|
237
|
+
const viewport = (globals.viewport ? getViewport(context.story.parameters, globals.viewport) : null) ?? getDefaultViewport(context.story.parameters) ?? "default";
|
|
238
|
+
await context.setViewportSize(viewport);
|
|
239
|
+
DO_NOT_USE_setMetadataConfig({
|
|
240
|
+
...metadata,
|
|
241
|
+
story: getStoryMetadata(context.story, mode),
|
|
242
|
+
viewport: viewport && viewport !== "default" ? viewport : void 0
|
|
243
|
+
});
|
|
244
|
+
await context.beforeScreenshot?.({
|
|
245
|
+
handler,
|
|
246
|
+
globals
|
|
247
|
+
});
|
|
248
|
+
return argosScreenshot$1(handler, composeName(context.name, args.suffix), options);
|
|
249
|
+
}
|
|
250
|
+
function getModeSuffix(mode) {
|
|
251
|
+
return mode ? ` mode-[${mode}]` : "";
|
|
252
|
+
}
|
|
253
|
+
function composeName(name, suffix) {
|
|
254
|
+
return name + (suffix ?? "");
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/test-runner.ts
|
|
258
|
+
const DEFAULT_PLAYWRIGHT_VIEWPORT_SIZE = {
|
|
259
|
+
width: 1280,
|
|
260
|
+
height: 720
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Stabilize the UI and takes a screenshot of the application under test.
|
|
264
|
+
*
|
|
265
|
+
* @example argosScreenshot(page, context, options)
|
|
266
|
+
* @see https://argos-ci.com/docs/reference/playwright
|
|
267
|
+
*/
|
|
268
|
+
async function argosScreenshot(page, context, options) {
|
|
269
|
+
const storyContext = await getStoryContext(page, context);
|
|
270
|
+
const fitToContent = getFitToContentFromParameters(storyContext.parameters);
|
|
271
|
+
await storybookArgosScreenshot(page, {
|
|
272
|
+
mode: "automatic",
|
|
273
|
+
name: storyContext.id,
|
|
274
|
+
playwrightLibraries: ["@storybook/test-runner"],
|
|
275
|
+
beforeScreenshot: async ({ handler }) => {
|
|
276
|
+
await waitForPageReady(handler);
|
|
277
|
+
},
|
|
278
|
+
story: {
|
|
279
|
+
id: storyContext.id,
|
|
280
|
+
tags: mergeTags(storyContext.tags, storyContext.parameters.tags),
|
|
281
|
+
play: hasPlay(storyContext),
|
|
282
|
+
parameters: storyContext.parameters,
|
|
283
|
+
globals: null
|
|
284
|
+
},
|
|
285
|
+
setViewportSize: async (size) => {
|
|
286
|
+
const actualSize = await page.viewportSize();
|
|
287
|
+
const absoluteSize = size === "default" || size === "initial" ? DEFAULT_PLAYWRIGHT_VIEWPORT_SIZE : size;
|
|
288
|
+
if (!actualSize || actualSize.height !== absoluteSize.height || actualSize.width !== absoluteSize.width) {
|
|
289
|
+
await page.setViewportSize(absoluteSize);
|
|
290
|
+
await page.waitForFunction(({ width, height }) => window.innerWidth === width && window.innerHeight === height, {
|
|
291
|
+
width: absoluteSize.width,
|
|
292
|
+
height: absoluteSize.height
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}, applyFitToContent(options, fitToContent));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Apply fitToContent options to the screenshot options.
|
|
300
|
+
*/
|
|
301
|
+
function applyFitToContent(options, fitToContent) {
|
|
302
|
+
if (!fitToContent) return options;
|
|
303
|
+
const { padding, zoom } = fitToContent;
|
|
304
|
+
return {
|
|
305
|
+
...options,
|
|
306
|
+
element: "#storybook-root",
|
|
307
|
+
argosCSS: `#storybook-root { padding: ${padding}px; width: fit-content; height: fit-content; zoom: ${zoom}; }` + (options?.argosCSS ?? "")
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
export { argosScreenshot };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ArgosScreenshotOptions as ArgosScreenshotOptions$1, MetadataConfig } from "@snapvisor/playwright";
|
|
2
|
+
import { ArgosReporterConfig } from "@snapvisor/vitest/plugin";
|
|
3
|
+
import { Plugin } from "vitest/config";
|
|
4
|
+
import { BrowserCommand } from "vitest/node";
|
|
5
|
+
import { Frame, Page, ViewportSize } from "playwright";
|
|
6
|
+
|
|
7
|
+
//#region src/utils/parameters.d.ts
|
|
8
|
+
type StorybookGlobals = Record<string, any>;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/utils/screenshot.d.ts
|
|
11
|
+
type StorybookScreenshotContext<Handler extends Page | Frame> = {
|
|
12
|
+
/**
|
|
13
|
+
* - "manual" means the `argosScreenshot` has been called in the story play function.
|
|
14
|
+
* - "automatic" means the screenshot is taken automatically after each test.
|
|
15
|
+
*/
|
|
16
|
+
mode: "manual" | "automatic";
|
|
17
|
+
name: string;
|
|
18
|
+
playwrightLibraries: string[];
|
|
19
|
+
test?: MetadataConfig["test"];
|
|
20
|
+
setViewportSize: (size: ViewportSize | "default" | "initial") => Promise<void>;
|
|
21
|
+
beforeScreenshot?: (input: {
|
|
22
|
+
handler: Handler;
|
|
23
|
+
globals: StorybookGlobals;
|
|
24
|
+
}) => Promise<void>;
|
|
25
|
+
afterScreenshot?: (input: {
|
|
26
|
+
handler: Handler;
|
|
27
|
+
globals: StorybookGlobals;
|
|
28
|
+
}) => Promise<void>;
|
|
29
|
+
story: {
|
|
30
|
+
id: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
play?: boolean;
|
|
33
|
+
parameters: Record<string, any>;
|
|
34
|
+
globals: StorybookGlobals | null;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
type ArgosScreenshotOptions = Omit<ArgosScreenshotOptions$1, "viewports">;
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/vitest-plugin.d.ts
|
|
40
|
+
type ArgosScreenshotCommandArgs = [Pick<StorybookScreenshotContext<Frame>, "name" | "story" | "test" | "mode">];
|
|
41
|
+
interface ArgosVitestPluginOptions extends ArgosReporterConfig, ArgosScreenshotOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Upload the report to Argos.
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
uploadToArgos?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create a command for taking Argos screenshots in Vitest.
|
|
50
|
+
*/
|
|
51
|
+
declare const createArgosScreenshotCommand: (pluginOptions?: ArgosVitestPluginOptions) => BrowserCommand<ArgosScreenshotCommandArgs>;
|
|
52
|
+
declare function argosVitestPlugin(options?: ArgosVitestPluginOptions): Plugin;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { ArgosScreenshotCommandArgs, type ArgosScreenshotOptions, ArgosVitestPluginOptions, argosVitestPlugin, createArgosScreenshotCommand };
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { DO_NOT_USE_setMetadataConfig, argosScreenshot } from "@snapvisor/playwright";
|
|
3
|
+
import { readVersionFromPackage } from "@snapvisor/util";
|
|
4
|
+
import { ArgosReporter } from "@snapvisor/vitest/plugin";
|
|
5
|
+
import { fitIframeToContent, resetTesterScale, setIframeViewportSize } from "@snapvisor/vitest/internal";
|
|
6
|
+
import { dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
//#region src/utils/metadata.ts
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
/**
|
|
11
|
+
* Get the version of the Argos Playwright SDK.
|
|
12
|
+
*/
|
|
13
|
+
async function getArgosStorybookVersion() {
|
|
14
|
+
return readVersionFromPackage(require.resolve("@snapvisor/storybook/package.json"));
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/utils/storyMetadata.ts
|
|
18
|
+
/**
|
|
19
|
+
* Build the story metadata object that is attached to a screenshot.
|
|
20
|
+
*
|
|
21
|
+
* @param story - The story shape (id, tags, play flag).
|
|
22
|
+
* @param storyMode - The Storybook mode name (e.g. "dark", "mobile") as defined in `parameters.argos.modes`
|
|
23
|
+
*/
|
|
24
|
+
function getStoryMetadata(story, storyMode) {
|
|
25
|
+
return {
|
|
26
|
+
id: story.id,
|
|
27
|
+
tags: story.tags ?? [],
|
|
28
|
+
mode: storyMode ?? void 0,
|
|
29
|
+
play: Boolean(story.play)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/utils/parameters.ts
|
|
34
|
+
/**
|
|
35
|
+
* Get the default viewport size from the Storybook parameters.
|
|
36
|
+
*/
|
|
37
|
+
function getDefaultViewport(parameters) {
|
|
38
|
+
const defaultViewport = parameters?.viewport?.defaultViewport;
|
|
39
|
+
if (defaultViewport) return getViewport(parameters, defaultViewport);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get the viewport size from the Storybook parameters.
|
|
44
|
+
*/
|
|
45
|
+
function getViewport(parameters, viewportName) {
|
|
46
|
+
if (typeof viewportName === "number") return {
|
|
47
|
+
width: viewportName,
|
|
48
|
+
height: 720
|
|
49
|
+
};
|
|
50
|
+
const viewports = parameters?.viewport?.viewports;
|
|
51
|
+
if (viewports && viewportName in viewports) {
|
|
52
|
+
if ("styles" in viewports[viewportName] && viewports[viewportName].styles) {
|
|
53
|
+
const width = parseInt(viewports[viewportName].styles.width, 10);
|
|
54
|
+
const height = parseInt(viewports[viewportName].styles.height, 10);
|
|
55
|
+
if (!isNaN(width) && !isNaN(height)) return {
|
|
56
|
+
width,
|
|
57
|
+
height
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get the Argos parameters from the Storybook context.
|
|
65
|
+
*/
|
|
66
|
+
function getArgosParameters(parameters) {
|
|
67
|
+
if ("argos" in parameters && parameters.argos && typeof parameters.argos === "object") return parameters.argos;
|
|
68
|
+
if ("chromatic" in parameters && parameters.chromatic && typeof parameters.chromatic === "object") return parameters.chromatic;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const DEFAULT_FIT_TO_CONTENT = {
|
|
72
|
+
padding: 16,
|
|
73
|
+
zoom: 2
|
|
74
|
+
};
|
|
75
|
+
function getFitToContentFromParameters(parameters) {
|
|
76
|
+
const argosParameters = getArgosParameters(parameters);
|
|
77
|
+
if (argosParameters && "fitToContent" in argosParameters) {
|
|
78
|
+
if (typeof argosParameters.fitToContent === "boolean") return argosParameters.fitToContent ? DEFAULT_FIT_TO_CONTENT : null;
|
|
79
|
+
if (typeof argosParameters.fitToContent === "object" && argosParameters.fitToContent) return {
|
|
80
|
+
...DEFAULT_FIT_TO_CONTENT,
|
|
81
|
+
...argosParameters.fitToContent
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return DEFAULT_FIT_TO_CONTENT;
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/utils/screenshot.ts
|
|
88
|
+
/**
|
|
89
|
+
* Take a screenshot in the context of a Storybook story.
|
|
90
|
+
*/
|
|
91
|
+
async function storybookArgosScreenshot(handler, context, options) {
|
|
92
|
+
const argosOptions = {
|
|
93
|
+
...options,
|
|
94
|
+
stabilize: options?.stabilize ?? {
|
|
95
|
+
waitForAriaBusy: false,
|
|
96
|
+
...typeof options?.stabilize === "object" ? options.stabilize : {}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const version = await getArgosStorybookVersion();
|
|
100
|
+
const storyUrl = `http://localhost:6006/?path=/story/${context.story.id}`;
|
|
101
|
+
const metadata = {
|
|
102
|
+
sdk: {
|
|
103
|
+
name: "@snapvisor/storybook",
|
|
104
|
+
version
|
|
105
|
+
},
|
|
106
|
+
playwrightLibraries: context.playwrightLibraries,
|
|
107
|
+
url: storyUrl,
|
|
108
|
+
test: context.test,
|
|
109
|
+
story: getStoryMetadata(context.story)
|
|
110
|
+
};
|
|
111
|
+
const modes = getArgosParameters(context.story.parameters)?.modes;
|
|
112
|
+
const allAttachments = [];
|
|
113
|
+
if (context.mode === "automatic") if (modes) for (const [name, globals] of Object.entries(modes)) {
|
|
114
|
+
if (globals.disabled) continue;
|
|
115
|
+
await handler.evaluate((name) => {
|
|
116
|
+
globalThis.__ARGOS_CURRENT_MODE = name;
|
|
117
|
+
}, name);
|
|
118
|
+
const attachments = await runHooksAndScreenshot({
|
|
119
|
+
handler,
|
|
120
|
+
context,
|
|
121
|
+
metadata,
|
|
122
|
+
options: argosOptions,
|
|
123
|
+
suffix: getModeSuffix(name),
|
|
124
|
+
mode: name,
|
|
125
|
+
globals: {
|
|
126
|
+
...context.story.globals,
|
|
127
|
+
...globals
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
allAttachments.push(...attachments);
|
|
131
|
+
await context.setViewportSize("initial");
|
|
132
|
+
await handler.evaluate(() => {
|
|
133
|
+
delete globalThis.__ARGOS_CURRENT_MODE;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const attachments = await runHooksAndScreenshot({
|
|
138
|
+
handler,
|
|
139
|
+
context,
|
|
140
|
+
metadata,
|
|
141
|
+
options: argosOptions,
|
|
142
|
+
mode: null,
|
|
143
|
+
globals: context.story.globals ?? {}
|
|
144
|
+
});
|
|
145
|
+
await context.setViewportSize("initial");
|
|
146
|
+
allAttachments.push(...attachments);
|
|
147
|
+
}
|
|
148
|
+
else if (context.mode === "manual") {
|
|
149
|
+
const currentMode = await handler.evaluate(() => {
|
|
150
|
+
return globalThis.__ARGOS_CURRENT_MODE || null;
|
|
151
|
+
});
|
|
152
|
+
if (modes && !currentMode) return;
|
|
153
|
+
await markStoryAsRendered(handler, context.story.id);
|
|
154
|
+
DO_NOT_USE_setMetadataConfig({
|
|
155
|
+
...metadata,
|
|
156
|
+
story: getStoryMetadata(context.story, currentMode)
|
|
157
|
+
});
|
|
158
|
+
await argosScreenshot(handler, composeName(context.name, getModeSuffix(currentMode)), options);
|
|
159
|
+
}
|
|
160
|
+
await context.afterScreenshot?.({
|
|
161
|
+
handler,
|
|
162
|
+
globals: {}
|
|
163
|
+
});
|
|
164
|
+
return allAttachments;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Run the story with the given globals.
|
|
168
|
+
*/
|
|
169
|
+
async function runStory(args) {
|
|
170
|
+
const { handler, globals, storyId } = args;
|
|
171
|
+
await handler.evaluate(async (globals) => {
|
|
172
|
+
if ("__ARGOS_STORYBOOK_STORY" in globalThis) {
|
|
173
|
+
const storyFn = globalThis.__ARGOS_STORYBOOK_STORY;
|
|
174
|
+
if (!storyFn) throw new Error("@snapvisor/storybook: Unable to find `__ARGOS_STORYBOOK_STORY`.");
|
|
175
|
+
const canvasElement = (() => {
|
|
176
|
+
document.getElementById("storybook-root")?.remove();
|
|
177
|
+
const canvasElement = document.createElement("div");
|
|
178
|
+
canvasElement.id = "storybook-root";
|
|
179
|
+
document.body.appendChild(canvasElement);
|
|
180
|
+
return canvasElement;
|
|
181
|
+
})();
|
|
182
|
+
await storyFn.run({
|
|
183
|
+
globals,
|
|
184
|
+
canvasElement
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const channel = (() => {
|
|
189
|
+
if ("__STORYBOOK_PREVIEW__" in globalThis) return globalThis.__STORYBOOK_PREVIEW__.channel;
|
|
190
|
+
return null;
|
|
191
|
+
})();
|
|
192
|
+
if (!channel) throw new Error("@snapvisor/storybook: Unable to find `__STORYBOOK_PREVIEW__`.");
|
|
193
|
+
const initialGlobals = channel.last("globalsUpdated")?.[0].initialGlobals;
|
|
194
|
+
channel.emit("updateGlobals", { globals: {
|
|
195
|
+
...initialGlobals,
|
|
196
|
+
...globals
|
|
197
|
+
} });
|
|
198
|
+
}, globals);
|
|
199
|
+
await markStoryAsRendered(handler, storyId);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Notify Storybook that the story has been rendered.
|
|
203
|
+
* It will run all the necessary hooks (like pseudo-states).
|
|
204
|
+
*/
|
|
205
|
+
async function markStoryAsRendered(handler, storyId) {
|
|
206
|
+
await handler.evaluate((storyId) => {
|
|
207
|
+
if ("__ARGOS_STORYBOOK_STORY" in globalThis) {
|
|
208
|
+
const addons = globalThis.__STORYBOOK_ADDONS_PREVIEW;
|
|
209
|
+
if (!addons) throw new Error("@snapvisor/storybook: Unable to find `__STORYBOOK_ADDONS_PREVIEW`.");
|
|
210
|
+
addons.getChannel().emit("storyRendered", storyId);
|
|
211
|
+
}
|
|
212
|
+
}, storyId);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Wait for the page to be ready and take a screenshot.
|
|
216
|
+
*/
|
|
217
|
+
async function runHooksAndScreenshot(args) {
|
|
218
|
+
const { handler, context, options, globals, metadata, mode } = args;
|
|
219
|
+
await runStory({
|
|
220
|
+
handler,
|
|
221
|
+
globals,
|
|
222
|
+
storyId: context.story.id
|
|
223
|
+
});
|
|
224
|
+
const viewport = (globals.viewport ? getViewport(context.story.parameters, globals.viewport) : null) ?? getDefaultViewport(context.story.parameters) ?? "default";
|
|
225
|
+
await context.setViewportSize(viewport);
|
|
226
|
+
DO_NOT_USE_setMetadataConfig({
|
|
227
|
+
...metadata,
|
|
228
|
+
story: getStoryMetadata(context.story, mode),
|
|
229
|
+
viewport: viewport && viewport !== "default" ? viewport : void 0
|
|
230
|
+
});
|
|
231
|
+
await context.beforeScreenshot?.({
|
|
232
|
+
handler,
|
|
233
|
+
globals
|
|
234
|
+
});
|
|
235
|
+
return argosScreenshot(handler, composeName(context.name, args.suffix), options);
|
|
236
|
+
}
|
|
237
|
+
function getModeSuffix(mode) {
|
|
238
|
+
return mode ? ` mode-[${mode}]` : "";
|
|
239
|
+
}
|
|
240
|
+
function composeName(name, suffix) {
|
|
241
|
+
return name + (suffix ?? "");
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/vitest-plugin.ts
|
|
245
|
+
/**
|
|
246
|
+
* Create a command for taking Argos screenshots in Vitest.
|
|
247
|
+
*/
|
|
248
|
+
const createArgosScreenshotCommand = (pluginOptions) => {
|
|
249
|
+
const screenshotOptions = pluginOptions ?? {};
|
|
250
|
+
return async (ctx, testContext) => {
|
|
251
|
+
const frame = await ctx.frame();
|
|
252
|
+
const fitToContent = getFitToContentFromParameters(testContext.story.parameters);
|
|
253
|
+
const after = await resetTesterScale(ctx);
|
|
254
|
+
const options = applyFitToContent(screenshotOptions, fitToContent);
|
|
255
|
+
const userBeforeScreenshot = options?.beforeScreenshot;
|
|
256
|
+
const optionsWithFit = {
|
|
257
|
+
...options,
|
|
258
|
+
beforeScreenshot: async (api) => {
|
|
259
|
+
await userBeforeScreenshot?.(api);
|
|
260
|
+
await fitIframeToContent(ctx, { fitWidth: Boolean(fitToContent) });
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const attachments = await storybookArgosScreenshot(frame, {
|
|
264
|
+
...testContext,
|
|
265
|
+
playwrightLibraries: ["@storybook/addon-vitest"],
|
|
266
|
+
setViewportSize: async (size) => {
|
|
267
|
+
await setIframeViewportSize(ctx, size, { fullPage: screenshotOptions.fullPage ?? !fitToContent });
|
|
268
|
+
}
|
|
269
|
+
}, optionsWithFit);
|
|
270
|
+
await after();
|
|
271
|
+
return attachments;
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Apply fitToContent options to the screenshot options.
|
|
276
|
+
*/
|
|
277
|
+
function applyFitToContent(options, fitToContent) {
|
|
278
|
+
if (!fitToContent) return options;
|
|
279
|
+
const { padding, zoom } = fitToContent;
|
|
280
|
+
return {
|
|
281
|
+
...options,
|
|
282
|
+
element: "body",
|
|
283
|
+
argosCSS: `body { padding: ${padding}px; width: fit-content; height: fit-content; min-height: initial; zoom: ${zoom}; }` + (options?.argosCSS ?? "")
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const cwd = process.cwd();
|
|
287
|
+
function argosVitestPlugin(options) {
|
|
288
|
+
const { root: unresolvedRoot = "./screenshots", uploadToArgos, ...otherOptions } = options ?? {};
|
|
289
|
+
const root = resolve(cwd, unresolvedRoot);
|
|
290
|
+
const setupFile = resolve(dirname(fileURLToPath(import.meta.url)), "./vitest-setup-file.mjs");
|
|
291
|
+
return {
|
|
292
|
+
name: "@snapvisor/storybook/vitest-plugin",
|
|
293
|
+
configureVitest({ vitest, project }) {
|
|
294
|
+
project.config.setupFiles.push(setupFile);
|
|
295
|
+
if (uploadToArgos) vitest.config.reporters.push(new ArgosReporter({
|
|
296
|
+
...otherOptions,
|
|
297
|
+
root
|
|
298
|
+
}));
|
|
299
|
+
},
|
|
300
|
+
config() {
|
|
301
|
+
return {
|
|
302
|
+
optimizeDeps: { include: ["@snapvisor/storybook/internal/vitest-setup-file"] },
|
|
303
|
+
test: { browser: { commands: { argosStorybookScreenshot: createArgosScreenshotCommand({
|
|
304
|
+
...otherOptions,
|
|
305
|
+
root
|
|
306
|
+
}) } } }
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
export { argosVitestPlugin, createArgosScreenshotCommand };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterEach } from "vitest";
|
|
2
|
+
//#region src/utils/storyMetadata.ts
|
|
3
|
+
function toArray(v) {
|
|
4
|
+
return Array.isArray(v) ? v : v ? [v] : [];
|
|
5
|
+
}
|
|
6
|
+
function mergeTags(...tags) {
|
|
7
|
+
const merged = tags.flatMap((tags) => toArray(tags));
|
|
8
|
+
return Array.from(new Set(merged)).filter((tag) => typeof tag === "string" && tag !== "");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Return whether a story context has a play function.
|
|
12
|
+
* Handles both the `play` field (plain story function) and the legacy
|
|
13
|
+
* `playFunction` field present in older Storybook test-runner contexts.
|
|
14
|
+
*/
|
|
15
|
+
function hasPlay(storyContext) {
|
|
16
|
+
if (!storyContext || typeof storyContext !== "object") return false;
|
|
17
|
+
return "play" in storyContext && typeof storyContext.play === "function" || "playFunction" in storyContext && typeof storyContext.playFunction === "function";
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/vitest.ts
|
|
21
|
+
/**
|
|
22
|
+
* Setup Argos hooks for Vitest.
|
|
23
|
+
*/
|
|
24
|
+
function setupArgos(api) {
|
|
25
|
+
api.afterEach(async (ctx) => {
|
|
26
|
+
const story = "story" in ctx ? ctx.story : null;
|
|
27
|
+
if (!story) throw new Error(`@snapvisor/storybook/vitest-plugin should be used with @storybook/addon-vitest/vitest-plugin`);
|
|
28
|
+
const { server } = await import("vitest/browser");
|
|
29
|
+
globalThis.__ARGOS_STORYBOOK_STORY = story;
|
|
30
|
+
await server.commands.argosStorybookScreenshot({
|
|
31
|
+
mode: "automatic",
|
|
32
|
+
name: story.id,
|
|
33
|
+
story: {
|
|
34
|
+
id: story.id,
|
|
35
|
+
tags: mergeTags(story.tags, story.parameters.tags),
|
|
36
|
+
play: hasPlay(story),
|
|
37
|
+
parameters: story.parameters,
|
|
38
|
+
globals: story.globals
|
|
39
|
+
},
|
|
40
|
+
test: {
|
|
41
|
+
id: ctx.task.id,
|
|
42
|
+
title: ctx.task.name,
|
|
43
|
+
titlePath: [ctx.task.file.name, ctx.task.name],
|
|
44
|
+
location: {
|
|
45
|
+
line: ctx.task.location?.line ?? 1,
|
|
46
|
+
column: ctx.task.location?.column ?? 1,
|
|
47
|
+
file: ctx.task.file.filepath
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/vitest-setup-file.ts
|
|
55
|
+
setupArgos({ afterEach });
|
|
56
|
+
//#endregion
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as vitest from "vitest";
|
|
2
|
+
import { ArgosAttachment, ArgosScreenshotOptions as ArgosScreenshotOptions$1, MetadataConfig } from "@snapvisor/playwright";
|
|
3
|
+
import { Frame, Page, ViewportSize } from "playwright";
|
|
4
|
+
//#region src/utils/parameters.d.ts
|
|
5
|
+
type StorybookGlobals = Record<string, any>;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/utils/screenshot.d.ts
|
|
8
|
+
type StorybookScreenshotContext<Handler extends Page | Frame> = {
|
|
9
|
+
/**
|
|
10
|
+
* - "manual" means the `argosScreenshot` has been called in the story play function.
|
|
11
|
+
* - "automatic" means the screenshot is taken automatically after each test.
|
|
12
|
+
*/
|
|
13
|
+
mode: "manual" | "automatic";
|
|
14
|
+
name: string;
|
|
15
|
+
playwrightLibraries: string[];
|
|
16
|
+
test?: MetadataConfig["test"];
|
|
17
|
+
setViewportSize: (size: ViewportSize | "default" | "initial") => Promise<void>;
|
|
18
|
+
beforeScreenshot?: (input: {
|
|
19
|
+
handler: Handler;
|
|
20
|
+
globals: StorybookGlobals;
|
|
21
|
+
}) => Promise<void>;
|
|
22
|
+
afterScreenshot?: (input: {
|
|
23
|
+
handler: Handler;
|
|
24
|
+
globals: StorybookGlobals;
|
|
25
|
+
}) => Promise<void>;
|
|
26
|
+
story: {
|
|
27
|
+
id: string;
|
|
28
|
+
tags?: string[];
|
|
29
|
+
play?: boolean;
|
|
30
|
+
parameters: Record<string, any>;
|
|
31
|
+
globals: StorybookGlobals | null;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
type ArgosScreenshotOptions = Omit<ArgosScreenshotOptions$1, "viewports">;
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/vitest-plugin.d.ts
|
|
37
|
+
type ArgosScreenshotCommandArgs = [Pick<StorybookScreenshotContext<Frame>, "name" | "story" | "test" | "mode">];
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/vitest.d.ts
|
|
40
|
+
declare module "vitest/browser" {
|
|
41
|
+
interface BrowserCommands {
|
|
42
|
+
argosStorybookScreenshot: (...args: ArgosScreenshotCommandArgs) => Promise<ArgosAttachment[]>;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Setup Argos hooks for Vitest.
|
|
47
|
+
*/
|
|
48
|
+
declare function setupArgos(api: {
|
|
49
|
+
afterEach: typeof vitest.afterEach;
|
|
50
|
+
}): void;
|
|
51
|
+
/**
|
|
52
|
+
* Take an Argos screenshot of a story in Vitest.
|
|
53
|
+
*/
|
|
54
|
+
declare function argosScreenshot(story: {
|
|
55
|
+
parameters: Record<string, any>;
|
|
56
|
+
globals: StorybookGlobals | null;
|
|
57
|
+
id: string;
|
|
58
|
+
tags?: string[];
|
|
59
|
+
play?: unknown;
|
|
60
|
+
}, name: string): Promise<void>;
|
|
61
|
+
//#endregion
|
|
62
|
+
export { type ArgosScreenshotOptions, argosScreenshot, setupArgos };
|
package/dist/vitest.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/utils/storyMetadata.ts
|
|
2
|
+
function toArray(v) {
|
|
3
|
+
return Array.isArray(v) ? v : v ? [v] : [];
|
|
4
|
+
}
|
|
5
|
+
function mergeTags(...tags) {
|
|
6
|
+
const merged = tags.flatMap((tags) => toArray(tags));
|
|
7
|
+
return Array.from(new Set(merged)).filter((tag) => typeof tag === "string" && tag !== "");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Return whether a story context has a play function.
|
|
11
|
+
* Handles both the `play` field (plain story function) and the legacy
|
|
12
|
+
* `playFunction` field present in older Storybook test-runner contexts.
|
|
13
|
+
*/
|
|
14
|
+
function hasPlay(storyContext) {
|
|
15
|
+
if (!storyContext || typeof storyContext !== "object") return false;
|
|
16
|
+
return "play" in storyContext && typeof storyContext.play === "function" || "playFunction" in storyContext && typeof storyContext.playFunction === "function";
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/vitest.ts
|
|
20
|
+
/**
|
|
21
|
+
* Setup Argos hooks for Vitest.
|
|
22
|
+
*/
|
|
23
|
+
function setupArgos(api) {
|
|
24
|
+
api.afterEach(async (ctx) => {
|
|
25
|
+
const story = "story" in ctx ? ctx.story : null;
|
|
26
|
+
if (!story) throw new Error(`@snapvisor/storybook/vitest-plugin should be used with @storybook/addon-vitest/vitest-plugin`);
|
|
27
|
+
const { server } = await import("vitest/browser");
|
|
28
|
+
globalThis.__ARGOS_STORYBOOK_STORY = story;
|
|
29
|
+
await server.commands.argosStorybookScreenshot({
|
|
30
|
+
mode: "automatic",
|
|
31
|
+
name: story.id,
|
|
32
|
+
story: {
|
|
33
|
+
id: story.id,
|
|
34
|
+
tags: mergeTags(story.tags, story.parameters.tags),
|
|
35
|
+
play: hasPlay(story),
|
|
36
|
+
parameters: story.parameters,
|
|
37
|
+
globals: story.globals
|
|
38
|
+
},
|
|
39
|
+
test: {
|
|
40
|
+
id: ctx.task.id,
|
|
41
|
+
title: ctx.task.name,
|
|
42
|
+
titlePath: [ctx.task.file.name, ctx.task.name],
|
|
43
|
+
location: {
|
|
44
|
+
line: ctx.task.location?.line ?? 1,
|
|
45
|
+
column: ctx.task.location?.column ?? 1,
|
|
46
|
+
file: ctx.task.file.filepath
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Take an Argos screenshot of a story in Vitest.
|
|
54
|
+
*/
|
|
55
|
+
async function argosScreenshot(story, name) {
|
|
56
|
+
if (!await checkIsVitestEnv()) return;
|
|
57
|
+
const { server } = await import("vitest/browser");
|
|
58
|
+
await server.commands.argosStorybookScreenshot({
|
|
59
|
+
mode: "manual",
|
|
60
|
+
name: `${story.id}/${name}`,
|
|
61
|
+
story: {
|
|
62
|
+
id: story.id,
|
|
63
|
+
tags: mergeTags(story.tags, story.parameters.tags),
|
|
64
|
+
play: hasPlay(story),
|
|
65
|
+
parameters: story.parameters,
|
|
66
|
+
globals: story.globals
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check if we are running in a Vitest environment.
|
|
72
|
+
*/
|
|
73
|
+
async function checkIsVitestEnv() {
|
|
74
|
+
try {
|
|
75
|
+
await import("vitest");
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { argosScreenshot, setupArgos };
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@snapvisor/storybook",
|
|
3
|
+
"description": "Visual testing for Storybook test runner.",
|
|
4
|
+
"version": "6.1.8",
|
|
5
|
+
"author": "Smooth Code",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/argos-ci/argos-javascript.git",
|
|
10
|
+
"directory": "packages/storybook"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://argos-ci.com/docs/reference/storybook",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"storybook",
|
|
15
|
+
"storybook-addon",
|
|
16
|
+
"screenshot",
|
|
17
|
+
"capture",
|
|
18
|
+
"testing",
|
|
19
|
+
"visual testing",
|
|
20
|
+
"argos",
|
|
21
|
+
"regression",
|
|
22
|
+
"visual regression"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
"./test-runner": {
|
|
30
|
+
"types": "./dist/test-runner.d.mts",
|
|
31
|
+
"import": "./dist/test-runner.mjs",
|
|
32
|
+
"require": "./dist/test-runner.cjs",
|
|
33
|
+
"default": "./dist/test-runner.cjs"
|
|
34
|
+
},
|
|
35
|
+
"./vitest": {
|
|
36
|
+
"types": "./dist/vitest.d.mts",
|
|
37
|
+
"import": "./dist/vitest.mjs",
|
|
38
|
+
"default": "./dist/vitest.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./vitest-plugin": {
|
|
41
|
+
"types": "./dist/vitest-plugin.d.mts",
|
|
42
|
+
"import": "./dist/vitest-plugin.mjs",
|
|
43
|
+
"default": "./dist/vitest-plugin.mjs"
|
|
44
|
+
},
|
|
45
|
+
"./internal/vitest-setup-file": "./dist/vitest-setup-file.mjs",
|
|
46
|
+
"./package.json": "./package.json"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.0.0"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@snapvisor/core": "6.6.2",
|
|
53
|
+
"@snapvisor/playwright": "7.3.7",
|
|
54
|
+
"@snapvisor/util": "4.0.4",
|
|
55
|
+
"@snapvisor/vitest": "0.3.2"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@snapvisor/cli": "6.4.1",
|
|
59
|
+
"@snapvisor/util": "workspace:*",
|
|
60
|
+
"@storybook/addon-docs": "^10.4.6",
|
|
61
|
+
"@storybook/addon-links": "^10.4.6",
|
|
62
|
+
"@storybook/addon-themes": "^10.4.6",
|
|
63
|
+
"@storybook/addon-vitest": "^10.4.6",
|
|
64
|
+
"@storybook/react-vite": "^10.4.6",
|
|
65
|
+
"@storybook/test-runner": "^0.24.4",
|
|
66
|
+
"@types/node": "catalog:",
|
|
67
|
+
"@types/react": "^19.2.17",
|
|
68
|
+
"@types/react-dom": "^19.2.3",
|
|
69
|
+
"@vitest/browser": "^4.1.9",
|
|
70
|
+
"@vitest/browser-playwright": "^4.1.9",
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
72
|
+
"@vitest/ui": "^4.1.9",
|
|
73
|
+
"http-server": "^14.1.1",
|
|
74
|
+
"playwright": "^1.61.0",
|
|
75
|
+
"prop-types": "^15.8.1",
|
|
76
|
+
"react": "^19.2.7",
|
|
77
|
+
"react-dom": "^19.2.7",
|
|
78
|
+
"storybook": "^10.4.6",
|
|
79
|
+
"storybook-addon-pseudo-states": "^10.4.6",
|
|
80
|
+
"vitest": "catalog:",
|
|
81
|
+
"wait-on": "^9.0.10"
|
|
82
|
+
},
|
|
83
|
+
"scripts": {
|
|
84
|
+
"build": "tsdown && cp ./src/test-runner.cjs ./dist",
|
|
85
|
+
"storybook": "storybook dev -p 6006",
|
|
86
|
+
"build-storybook": "storybook build",
|
|
87
|
+
"test-storybook": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-vm-modules test-storybook",
|
|
88
|
+
"install-playwright": "playwright install chromium --with-deps",
|
|
89
|
+
"argos-upload-runner": "argos upload screenshots --build-name \"argos-storybook-test-runner-e2e-node-$NODE_VERSION-$OS\"",
|
|
90
|
+
"serve-storybook": "http-server storybook-static --port 6006 --silent",
|
|
91
|
+
"wait-storybook": "wait-on tcp:127.0.0.1:6006",
|
|
92
|
+
"build-e2e-runner": "pnpm run build-storybook --quiet && pnpm run install-playwright",
|
|
93
|
+
"build-e2e": "pnpm run build-e2e-runner",
|
|
94
|
+
"test-e2e-runner": "pnpm run test-storybook && pnpm run argos-upload-runner",
|
|
95
|
+
"test-e2e-vitest": "vitest run",
|
|
96
|
+
"e2e-runner": "pnpm dlx concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm run serve-storybook\" \"pnpm run wait-storybook && pnpm run test-e2e-runner\"",
|
|
97
|
+
"e2e-vitest": "cross-env BUILD_NAME=\"argos-storybook-vitest-e2e-node-$NODE_VERSION-$OS\" UPLOAD_TO_ARGOS=true pnpm run test-e2e-vitest",
|
|
98
|
+
"e2e": "pnpm run e2e-runner && rm -rf ./screenshots && pnpm run e2e-vitest",
|
|
99
|
+
"check-types": "tsc",
|
|
100
|
+
"check-format": "prettier --check --ignore-unknown --ignore-path=./.gitignore --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .",
|
|
101
|
+
"lint": "eslint ."
|
|
102
|
+
},
|
|
103
|
+
"gitHead": "7c74326b53f6f513ac4087385b88e8fbd7e92eac"
|
|
104
|
+
}
|