@argos-ci/vitest 0.2.3

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.
@@ -0,0 +1,361 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, resolve } from "node:path";
3
+ import { DO_NOT_USE_setMetadataConfig, argosScreenshot } from "@argos-ci/playwright";
4
+ import { resolveViewport } from "@argos-ci/browser";
5
+ import { createDirectory, getMetadataPath, getScreenshotName, readVersionFromPackage, writeMetadata } from "@argos-ci/util";
6
+ import { writeFile } from "node:fs/promises";
7
+ import { getSnapshotMimeType, upload } from "@argos-ci/core";
8
+ //#region src/iframe.ts
9
+ /**
10
+ * Selector of the iframe Vitest renders the test into on the orchestrator page.
11
+ */
12
+ const VITEST_IFRAME_SELECTOR = "iframe[data-vitest=\"true\"]";
13
+ /**
14
+ * ID of the Vitest "tester" element that wraps the iframe with a `scale(...)`
15
+ * transform.
16
+ */
17
+ const VITEST_TESTER_ID = "vitest-tester";
18
+ /**
19
+ * Remove the scale from the Vitest `#vitest-tester` element before taking a
20
+ * screenshot to avoid ending up with small screenshots.
21
+ * @returns A function to restore the scale after the screenshot.
22
+ */
23
+ async function resetTesterScale(ctx) {
24
+ await ctx.page.evaluate((testerId) => {
25
+ const tester = document.getElementById(testerId);
26
+ if (!(tester instanceof HTMLElement)) return;
27
+ if (!tester.getAttribute("data-scale")) throw new Error("Vitest iframe data-scale attribute not found");
28
+ tester.dataset.bckTransform = tester.style.transform;
29
+ tester.style.transform = `scale(1)`;
30
+ }, VITEST_TESTER_ID);
31
+ return async () => {
32
+ await ctx.page.evaluate((testerId) => {
33
+ const tester = document.getElementById(testerId);
34
+ if (!(tester instanceof HTMLElement)) return;
35
+ tester.style.transform = tester.dataset.bckTransform ?? "";
36
+ }, VITEST_TESTER_ID);
37
+ };
38
+ }
39
+ /**
40
+ * Resize the Vitest iframe.
41
+ *
42
+ * The story/test renders inside an `<iframe data-vitest="true">` on the host
43
+ * page and we screenshot the iframe's `<body>`. Anything overflowing the iframe
44
+ * box is not painted, so the iframe must be sized to hold the content.
45
+ *
46
+ * @param size - The viewport size, `"default"` to keep the natural size, or
47
+ * `"initial"` to restore the size backed up on the first resize.
48
+ * @param options.fullPage - When `true`, grow the height to fit the content
49
+ * while keeping the viewport width (Playwright-style full page).
50
+ */
51
+ async function setIframeViewportSize(ctx, size, options = {}) {
52
+ await ctx.page.evaluate(({ size, fullPage, selector }) => {
53
+ const iframe = document.querySelector(selector);
54
+ if (!(iframe instanceof HTMLIFrameElement)) throw new Error("Vitest iframe not found");
55
+ if (!iframe.contentDocument) throw new Error("Vitest iframe contentDocument not found");
56
+ if (size === "initial") {
57
+ if (iframe.dataset.initialWidth && iframe.dataset.initialHeight) {
58
+ iframe.style.width = iframe.dataset.initialWidth;
59
+ iframe.style.height = iframe.dataset.initialHeight;
60
+ }
61
+ return;
62
+ }
63
+ if (!iframe.dataset.initialWidth && !iframe.dataset.initialHeight) {
64
+ iframe.dataset.initialWidth = iframe.style.width;
65
+ iframe.dataset.initialHeight = iframe.style.height;
66
+ }
67
+ if (size !== "default") iframe.style.width = `${size.width}px`;
68
+ if (fullPage) {
69
+ if (!iframe.contentWindow) throw new Error(`Can't access iframe window`);
70
+ const viewportHeight = size === "default" ? iframe.contentWindow.innerHeight : size.height;
71
+ iframe.style.height = "auto";
72
+ iframe.style.height = viewportHeight < iframe.contentDocument.body.offsetHeight ? `${iframe.contentDocument.body.offsetHeight}px` : "100%";
73
+ } else if (size !== "default") {
74
+ iframe.style.height = "auto";
75
+ iframe.style.height = `${size.height}px`;
76
+ }
77
+ }, {
78
+ size,
79
+ fullPage: options.fullPage ?? false,
80
+ selector: VITEST_IFRAME_SELECTOR
81
+ });
82
+ }
83
+ /**
84
+ * Grow the Vitest iframe to fit its content so nothing is clipped.
85
+ *
86
+ * This must run *after* `argosCSS` (which may inject a `zoom`) is applied,
87
+ * because `setIframeViewportSize` sizes the iframe *before* the content's final
88
+ * size is known. It only ever grows the iframe, never shrinks it.
89
+ *
90
+ * @param options.fitWidth - Also grow the iframe horizontally to paint content
91
+ * wider than the viewport. When `false`, only the height grows (to match
92
+ * Playwright's `fullPage` semantics: full height, viewport width).
93
+ */
94
+ async function fitIframeToContent(ctx, options) {
95
+ await ctx.page.evaluate(({ fitWidth, selector }) => {
96
+ const iframe = document.querySelector(selector);
97
+ if (!(iframe instanceof HTMLIFrameElement) || !iframe.contentDocument) return;
98
+ const { body, documentElement } = iframe.contentDocument;
99
+ const contentHeight = Math.max(body.scrollHeight, body.offsetHeight, documentElement.scrollHeight);
100
+ if (contentHeight > iframe.clientHeight) iframe.style.height = `${contentHeight}px`;
101
+ if (fitWidth) {
102
+ const contentWidth = Math.max(body.scrollWidth, body.offsetWidth, documentElement.scrollWidth);
103
+ if (contentWidth > iframe.clientWidth) iframe.style.width = `${contentWidth}px`;
104
+ }
105
+ }, {
106
+ fitWidth: options.fitWidth,
107
+ selector: VITEST_IFRAME_SELECTOR
108
+ });
109
+ }
110
+ //#endregion
111
+ //#region src/screenshot.ts
112
+ /**
113
+ * Take a screenshot of the Vitest iframe body using the Playwright SDK.
114
+ *
115
+ * This is the shared primitive both the standalone command and Storybook build
116
+ * on. It:
117
+ * - strips the Vitest-specific `viewports`/`fullPage` options (they drive the
118
+ * iframe resize, not Playwright);
119
+ * - wraps `beforeScreenshot` so the content is grown to fit *after* `argosCSS`
120
+ * (and any user `beforeScreenshot`) has been applied — otherwise wide/tall
121
+ * content would be clipped;
122
+ * - captures the iframe's `<body>` via `@argos-ci/playwright`.
123
+ *
124
+ * @param config.fitWidth - Grow the iframe horizontally as well as vertically
125
+ * to fit the content (used when not capturing a fixed viewport width).
126
+ */
127
+ async function screenshotFrame(ctx, name, options, config) {
128
+ const { viewports: _viewports, fullPage: _fullPage, ...rest } = options;
129
+ const userBeforeScreenshot = rest.beforeScreenshot;
130
+ const playwrightOptions = {
131
+ ...rest,
132
+ beforeScreenshot: async (api) => {
133
+ await userBeforeScreenshot?.(api);
134
+ await fitIframeToContent(ctx, { fitWidth: config.fitWidth });
135
+ }
136
+ };
137
+ return argosScreenshot(await ctx.frame(), name, playwrightOptions);
138
+ }
139
+ //#endregion
140
+ //#region src/version.ts
141
+ const require = createRequire(import.meta.url);
142
+ /**
143
+ * Get the version of the Argos Vitest SDK.
144
+ */
145
+ async function getArgosVitestVersion() {
146
+ return readVersionFromPackage(require.resolve("@argos-ci/vitest/package.json"));
147
+ }
148
+ /**
149
+ * Get the version of Vitest itself (used as the automation library for
150
+ * `argosSnapshot`, which does not rely on a browser).
151
+ */
152
+ async function getVitestVersion() {
153
+ return readVersionFromPackage(require.resolve("vitest/package.json"));
154
+ }
155
+ //#endregion
156
+ //#region src/command.ts
157
+ /**
158
+ * Create the `argosScreenshot` browser command used to capture Argos
159
+ * screenshots from Vitest browser tests.
160
+ *
161
+ * Non-serializable options (`beforeScreenshot`, `afterScreenshot`, a
162
+ * `Locator`/`ElementHandle` `element`, …) come from `pluginOptions` (node side)
163
+ * and are merged with the serializable per-call options (per-call wins).
164
+ */
165
+ const createArgosScreenshotCommand = (pluginOptions = {}) => {
166
+ return async (ctx, name, options) => {
167
+ if (!name) throw new Error("The `name` argument is required.");
168
+ const merged = {
169
+ ...pluginOptions,
170
+ ...options
171
+ };
172
+ const fullPage = merged.fullPage ?? false;
173
+ const fitWidth = !fullPage;
174
+ const restore = await resetTesterScale(ctx);
175
+ try {
176
+ const version = await getArgosVitestVersion();
177
+ const setMetadata = (viewport) => {
178
+ DO_NOT_USE_setMetadataConfig({
179
+ sdk: {
180
+ name: "@argos-ci/vitest",
181
+ version
182
+ },
183
+ playwrightLibraries: ["@vitest/browser-playwright"],
184
+ viewport
185
+ });
186
+ };
187
+ const attachments = [];
188
+ if (merged.viewports && merged.viewports.length > 0) for (const viewport of merged.viewports) {
189
+ const size = resolveViewport(viewport);
190
+ await setIframeViewportSize(ctx, size, { fullPage });
191
+ setMetadata(size);
192
+ const shot = await screenshotFrame(ctx, getScreenshotName(name, { viewportWidth: size.width }), merged, { fitWidth: false });
193
+ attachments.push(...shot);
194
+ await setIframeViewportSize(ctx, "initial", { fullPage });
195
+ }
196
+ else {
197
+ await setIframeViewportSize(ctx, "default", { fullPage });
198
+ setMetadata();
199
+ const shot = await screenshotFrame(ctx, name, merged, { fitWidth });
200
+ attachments.push(...shot);
201
+ }
202
+ return attachments;
203
+ } finally {
204
+ await restore();
205
+ }
206
+ };
207
+ };
208
+ /**
209
+ * Default extension of a serialized snapshot file.
210
+ */
211
+ const DEFAULT_EXTENSION = ".txt";
212
+ /**
213
+ * Marker inserted before the extension so the reporter can reliably pick up
214
+ * snapshots (`**\/*.snapshot.*`) without matching screenshots or ARIA files.
215
+ */
216
+ const SNAPSHOT_INFIX = ".snapshot";
217
+ /**
218
+ * Normalize a user-provided extension to a leading-dot form (`.txt`).
219
+ */
220
+ function normalizeExtension(extension) {
221
+ return extension.startsWith(".") ? extension : `.${extension}`;
222
+ }
223
+ /**
224
+ * Write a serialized snapshot (and its metadata) to disk and return the
225
+ * corresponding Argos attachments.
226
+ *
227
+ * This is the shared Node-side primitive used by both the browser command (which
228
+ * receives the already-serialized string over RPC) and the Node code path.
229
+ */
230
+ async function writeSnapshotFile(name, content, options = {}) {
231
+ if (!name) throw new Error("The `name` argument is required.");
232
+ const root = options.root ?? "./screenshots";
233
+ const extension = normalizeExtension(options.extension ?? DEFAULT_EXTENSION);
234
+ const snapshotPath = resolve(root, `${getScreenshotName(name)}${SNAPSHOT_INFIX}${extension}`);
235
+ const [vitestVersion, sdkVersion] = await Promise.all([getVitestVersion(), getArgosVitestVersion()]);
236
+ const tags = options.tag ? Array.isArray(options.tag) ? options.tag : [options.tag] : void 0;
237
+ const metadata = {
238
+ automationLibrary: {
239
+ name: "vitest",
240
+ version: vitestVersion
241
+ },
242
+ sdk: {
243
+ name: "@argos-ci/vitest",
244
+ version: sdkVersion
245
+ },
246
+ ...tags ? { tags } : {}
247
+ };
248
+ await createDirectory(dirname(snapshotPath));
249
+ await Promise.all([writeFile(snapshotPath, content, "utf-8"), writeMetadata(snapshotPath, metadata)]);
250
+ return [{
251
+ name: `argos/snapshot___${name}`,
252
+ contentType: getSnapshotMimeType(snapshotPath),
253
+ path: snapshotPath
254
+ }, {
255
+ name: `argos/snapshot/metadata___${name}`,
256
+ contentType: "application/json",
257
+ path: getMetadataPath(snapshotPath)
258
+ }];
259
+ }
260
+ //#endregion
261
+ //#region src/snapshot-command.ts
262
+ /**
263
+ * Create the `argosSnapshot` browser command used to write serialized snapshots
264
+ * from Vitest browser tests. The serialized string is produced on the browser
265
+ * side and this command writes it (and its metadata) to disk on the Node side.
266
+ */
267
+ const createArgosSnapshotCommand = (pluginOptions = {}) => {
268
+ return async (_ctx, name, content, options) => {
269
+ if (!name) throw new Error("The `name` argument is required.");
270
+ return writeSnapshotFile(name, content, {
271
+ root: pluginOptions.root,
272
+ ...options
273
+ });
274
+ };
275
+ };
276
+ //#endregion
277
+ //#region src/reporter.ts
278
+ /**
279
+ * Vitest reporter that uploads the screenshots captured during the run to Argos.
280
+ */
281
+ var ArgosReporter = class {
282
+ vitest;
283
+ config;
284
+ constructor(config) {
285
+ this.config = config;
286
+ }
287
+ onInit(vitest) {
288
+ this.vitest = vitest;
289
+ }
290
+ async onFinished() {
291
+ await this.onTestRunEnd();
292
+ }
293
+ async onTestRunEnd() {
294
+ if (this.vitest.config.watch) return;
295
+ const res = await upload({
296
+ files: [
297
+ "**/*.png",
298
+ "**/*.aria.yml",
299
+ "**/*.snapshot.*"
300
+ ],
301
+ ignore: ["**/*.argos.json"],
302
+ ...this.config
303
+ });
304
+ console.log(`✅ Argos build created: ${res.build.url}`);
305
+ }
306
+ };
307
+ //#endregion
308
+ //#region src/plugin.ts
309
+ const cwd = process.cwd();
310
+ /**
311
+ * Vitest plugin that registers the `argosScreenshot` browser command and,
312
+ * optionally, the reporter that uploads the captured screenshots to Argos.
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * import { defineConfig } from "vitest/config";
317
+ * import { playwright } from "@vitest/browser-playwright";
318
+ * import { argosVitestPlugin } from "@argos-ci/vitest/plugin";
319
+ *
320
+ * export default defineConfig({
321
+ * plugins: [argosVitestPlugin({ uploadToArgos: true })],
322
+ * test: {
323
+ * browser: {
324
+ * enabled: true,
325
+ * provider: playwright(),
326
+ * instances: [{ browser: "chromium" }],
327
+ * },
328
+ * },
329
+ * });
330
+ * ```
331
+ */
332
+ function argosVitestPlugin(options) {
333
+ const { root: unresolvedRoot = "./screenshots", uploadToArgos, ...otherOptions } = options ?? {};
334
+ const root = resolve(cwd, unresolvedRoot);
335
+ return {
336
+ name: "@argos-ci/vitest",
337
+ configureVitest({ vitest }) {
338
+ if (uploadToArgos) vitest.config.reporters.push(new ArgosReporter({
339
+ ...otherOptions,
340
+ root
341
+ }));
342
+ },
343
+ config() {
344
+ return {
345
+ optimizeDeps: { include: ["@argos-ci/vitest"] },
346
+ test: { browser: { commands: {
347
+ argosScreenshot: createArgosScreenshotCommand({
348
+ ...otherOptions,
349
+ root
350
+ }),
351
+ argosSnapshot: createArgosSnapshotCommand({
352
+ ...otherOptions,
353
+ root
354
+ })
355
+ } } }
356
+ };
357
+ }
358
+ };
359
+ }
360
+ //#endregion
361
+ export { ArgosReporter, argosVitestPlugin, createArgosScreenshotCommand, createArgosSnapshotCommand };
@@ -0,0 +1,74 @@
1
+ import { createRequire } from "node:module";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { getSnapshotMimeType } from "@argos-ci/core";
5
+ import { createDirectory, getMetadataPath, getScreenshotName, readVersionFromPackage, writeMetadata } from "@argos-ci/util";
6
+ //#region src/version.ts
7
+ const require = createRequire(import.meta.url);
8
+ /**
9
+ * Get the version of the Argos Vitest SDK.
10
+ */
11
+ async function getArgosVitestVersion() {
12
+ return readVersionFromPackage(require.resolve("@argos-ci/vitest/package.json"));
13
+ }
14
+ /**
15
+ * Get the version of Vitest itself (used as the automation library for
16
+ * `argosSnapshot`, which does not rely on a browser).
17
+ */
18
+ async function getVitestVersion() {
19
+ return readVersionFromPackage(require.resolve("vitest/package.json"));
20
+ }
21
+ /**
22
+ * Default extension of a serialized snapshot file.
23
+ */
24
+ const DEFAULT_EXTENSION = ".txt";
25
+ /**
26
+ * Marker inserted before the extension so the reporter can reliably pick up
27
+ * snapshots (`**\/*.snapshot.*`) without matching screenshots or ARIA files.
28
+ */
29
+ const SNAPSHOT_INFIX = ".snapshot";
30
+ /**
31
+ * Normalize a user-provided extension to a leading-dot form (`.txt`).
32
+ */
33
+ function normalizeExtension(extension) {
34
+ return extension.startsWith(".") ? extension : `.${extension}`;
35
+ }
36
+ /**
37
+ * Write a serialized snapshot (and its metadata) to disk and return the
38
+ * corresponding Argos attachments.
39
+ *
40
+ * This is the shared Node-side primitive used by both the browser command (which
41
+ * receives the already-serialized string over RPC) and the Node code path.
42
+ */
43
+ async function writeSnapshotFile(name, content, options = {}) {
44
+ if (!name) throw new Error("The `name` argument is required.");
45
+ const root = options.root ?? "./screenshots";
46
+ const extension = normalizeExtension(options.extension ?? DEFAULT_EXTENSION);
47
+ const snapshotPath = resolve(root, `${getScreenshotName(name)}${SNAPSHOT_INFIX}${extension}`);
48
+ const [vitestVersion, sdkVersion] = await Promise.all([getVitestVersion(), getArgosVitestVersion()]);
49
+ const tags = options.tag ? Array.isArray(options.tag) ? options.tag : [options.tag] : void 0;
50
+ const metadata = {
51
+ automationLibrary: {
52
+ name: "vitest",
53
+ version: vitestVersion
54
+ },
55
+ sdk: {
56
+ name: "@argos-ci/vitest",
57
+ version: sdkVersion
58
+ },
59
+ ...tags ? { tags } : {}
60
+ };
61
+ await createDirectory(dirname(snapshotPath));
62
+ await Promise.all([writeFile(snapshotPath, content, "utf-8"), writeMetadata(snapshotPath, metadata)]);
63
+ return [{
64
+ name: `argos/snapshot___${name}`,
65
+ contentType: getSnapshotMimeType(snapshotPath),
66
+ path: snapshotPath
67
+ }, {
68
+ name: `argos/snapshot/metadata___${name}`,
69
+ contentType: "application/json",
70
+ path: getMetadataPath(snapshotPath)
71
+ }];
72
+ }
73
+ //#endregion
74
+ export { writeSnapshotFile };
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@argos-ci/vitest",
3
+ "description": "Vitest SDK for visual testing with Argos.",
4
+ "version": "0.2.3",
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/vitest"
11
+ },
12
+ "homepage": "https://argos-ci.com/docs/sdks-reference/vitest",
13
+ "bugs": {
14
+ "url": "https://github.com/argos-ci/argos-javascript/issues"
15
+ },
16
+ "keywords": [
17
+ "vitest",
18
+ "vitest-plugin",
19
+ "vitest-browser",
20
+ "screenshot",
21
+ "capture",
22
+ "testing",
23
+ "visual testing",
24
+ "argos",
25
+ "regression",
26
+ "visual regression"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.mts",
35
+ "import": "./dist/index.mjs",
36
+ "default": "./dist/index.mjs"
37
+ },
38
+ "./plugin": {
39
+ "types": "./dist/plugin.d.mts",
40
+ "import": "./dist/plugin.mjs",
41
+ "default": "./dist/plugin.mjs"
42
+ },
43
+ "./internal": {
44
+ "types": "./dist/internal.d.mts",
45
+ "import": "./dist/internal.mjs",
46
+ "default": "./dist/internal.mjs"
47
+ },
48
+ "./package.json": "./package.json"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.0.0"
52
+ },
53
+ "dependencies": {
54
+ "@argos-ci/browser": "6.4.4",
55
+ "@argos-ci/core": "6.5.3",
56
+ "@argos-ci/playwright": "7.3.4",
57
+ "@argos-ci/util": "4.0.4"
58
+ },
59
+ "peerDependencies": {
60
+ "@vitest/browser": ">=4",
61
+ "@vitest/browser-playwright": ">=4",
62
+ "playwright": ">=1",
63
+ "vitest": ">=4"
64
+ },
65
+ "devDependencies": {
66
+ "@types/node": "catalog:",
67
+ "@vitest/browser": "^4.1.9",
68
+ "@vitest/browser-playwright": "^4.1.9",
69
+ "@vitest/pretty-format": "^4.1.9",
70
+ "playwright": "^1.61.0",
71
+ "vitest": "catalog:"
72
+ },
73
+ "scripts": {
74
+ "build": "tsdown",
75
+ "test": "vitest run --project unit",
76
+ "test-e2e": "vitest run --project e2e",
77
+ "install-playwright": "playwright install chromium --with-deps",
78
+ "build-e2e": "pnpm run install-playwright",
79
+ "e2e": "cross-env BUILD_NAME=\"argos-vitest-e2e-node-$NODE_VERSION-$OS\" UPLOAD_TO_ARGOS=true pnpm run test-e2e",
80
+ "check-types": "tsc",
81
+ "check-format": "prettier --check --ignore-unknown --ignore-path=./.gitignore --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .",
82
+ "lint": "eslint ."
83
+ },
84
+ "gitHead": "1b25b7c1109a3d032d627c5f9d2f2c3937731aea"
85
+ }