diff --git a/packages/storybook/package.json b/packages/storybook/package.json index c179982c..f4d017b5 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -43,6 +43,7 @@ "default": "./dist/vitest-plugin.mjs" }, "./internal/vitest-setup-file": "./dist/vitest-setup-file.mjs", + "./internal/vitest-setup-channel-file": "./dist/vitest-setup-channel-file.mjs", "./package.json": "./package.json" }, "engines": { @@ -84,6 +85,7 @@ "build": "tsdown && cp ./src/test-runner.cjs ./dist", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "test": "vitest run --config vitest.unit.config.ts", "test-storybook": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-vm-modules test-storybook", "install-playwright": "playwright install chromium --with-deps", "argos-upload-runner": "argos upload screenshots --build-name \"argos-storybook-test-runner-e2e-node-$NODE_VERSION-$OS\"", diff --git a/packages/storybook/src/utils/channel.ts b/packages/storybook/src/utils/channel.ts new file mode 100644 index 00000000..f88bd6ee --- /dev/null +++ b/packages/storybook/src/utils/channel.ts @@ -0,0 +1,31 @@ +import { addons } from "storybook/preview-api"; +import { Channel } from "storybook/internal/channels"; + +/** + * Make sure a single, shared Storybook channel exists in the preview. + * + * Portable stories (Vitest) run without the Storybook preview runtime, so + * nothing installs a channel. Addons still grab one at import time with + * `addons.getChannel()`, and Argos emits `storyRendered` on it so addons that + * only react to channel events — `storybook-addon-pseudo-states`, which + * rewrites `:hover` rules into `.pseudo-hover` ones — do their work before the + * screenshot is taken. + * + * Up to Storybook 10.4, `getChannel()` lazily created a mock channel and cached + * it, so every caller shared the same object and the emit was received. Since + * 10.5 it returns a *throwaway* mock channel on each call unless a channel has + * been installed, so listeners and emitters end up on different objects and the + * event goes nowhere. Installing a real channel restores a single instance on + * both versions. + * + * This has to run before any addon captures its channel, hence its own setup + * file, registered ahead of the user's. + */ +export function setupArgosChannel() { + if (addons.hasChannel()) { + return; + } + // A transport-less channel: nothing is sent anywhere, it only dispatches + // events between the preview-side listeners. + addons.setChannel(new Channel({})); +} diff --git a/packages/storybook/src/vitest-plugin.test.ts b/packages/storybook/src/vitest-plugin.test.ts new file mode 100644 index 00000000..18931b19 --- /dev/null +++ b/packages/storybook/src/vitest-plugin.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { argosVitestPlugin } from "./vitest-plugin"; + +function configure(setupFiles: string[]) { + const plugin = argosVitestPlugin({ uploadToArgos: false }) as any; + const project = { config: { setupFiles } }; + plugin.configureVitest({ vitest: { config: { reporters: [] } }, project }); + return project.config.setupFiles as string[]; +} + +describe("argosVitestPlugin", () => { + it("registers the channel setup ahead of the user's setup files", () => { + const setupFiles = configure(["/project/.storybook/vitest.setup.ts"]); + + // Addon preview modules capture a channel when the user's setup file + // imports them, so ours has to install one first. + expect(setupFiles[0]).toMatch(/vitest-setup-channel-file\.mjs$/); + expect(setupFiles).toContain("/project/.storybook/vitest.setup.ts"); + }); + + it("registers the screenshot setup after the user's setup files", () => { + const setupFiles = configure(["/project/.storybook/vitest.setup.ts"]); + + // `afterEach` hooks run in reverse registration order, so registering last + // is what makes the screenshot happen before Storybook unmounts the story. + expect(setupFiles.at(-1)).toMatch(/vitest-setup-file\.mjs$/); + }); +}); diff --git a/packages/storybook/src/vitest-plugin.ts b/packages/storybook/src/vitest-plugin.ts index 15549700..bdac0055 100644 --- a/packages/storybook/src/vitest-plugin.ts +++ b/packages/storybook/src/vitest-plugin.ts @@ -57,13 +57,18 @@ export const createArgosScreenshotCommand = ( // and we screenshot the iframe's `
`. Anything overflowing the iframe box // is not painted, so the screenshot gets cut. `setViewportSize` grows the iframe // *before* `argosCSS` (which injects `fitToContent`'s `zoom`) is applied, so it - // can't account for the final content size. Re-fit the iframe here, after - // stabilization has injected `argosCSS`, so the whole content is painted. + // can't account for the final content size. Re-fit the iframe here, once the + // content has settled, so the whole story is painted. const userBeforeScreenshot = options?.beforeScreenshot; const optionsWithFit: ArgosScreenshotOptions = { ...options, beforeScreenshot: async (api) => { await userBeforeScreenshot?.(api); + // Stabilize before measuring: `beforeScreenshot` runs *before* the SDK + // waits for images and fonts, and an image that has not loaded yet + // takes no space. Sizing the iframe from that layout leaves it too + // small for the final story, and the rest is never painted. + await api.runStabilization(); // `fitToContent` fits the content in both dimensions, so the iframe must // also grow horizontally to paint content wider than the viewport. // Without `fitToContent` we keep the viewport width to match Playwright's @@ -72,21 +77,27 @@ export const createArgosScreenshotCommand = ( }, }; - const attachments = await storybookArgosScreenshot( - frame, - { - ...testContext, - playwrightLibraries: ["@storybook/addon-vitest"], - setViewportSize: async (size) => { - await setIframeViewportSize(ctx, size, { - fullPage: screenshotOptions.fullPage ?? !fitToContent, - }); + try { + return await storybookArgosScreenshot( + frame, + { + ...testContext, + playwrightLibraries: ["@storybook/addon-vitest"], + setViewportSize: async (size) => { + await setIframeViewportSize(ctx, size, { + fullPage: screenshotOptions.fullPage ?? !fitToContent, + }); + }, }, - }, - optionsWithFit, - ); - await after(); - return attachments; + optionsWithFit, + ); + } finally { + // The iframe was grown to fit the story, so restore it: Vitest reuses the + // same iframe for every story in the file, and a leftover size would pad + // the next screenshots with blank space. + await setIframeViewportSize(ctx, "initial"); + await after(); + } }; }; @@ -120,13 +131,18 @@ export function argosVitestPlugin(options?: ArgosVitestPluginOptions): Plugin { ...otherOptions } = options ?? {}; const root = resolve(cwd, unresolvedRoot); - const setupFile = resolve( - dirname(fileURLToPath(import.meta.url)), - "./vitest-setup-file.mjs", - ); + const distDir = dirname(fileURLToPath(import.meta.url)); + const setupFile = resolve(distDir, "./vitest-setup-file.mjs"); + const channelSetupFile = resolve(distDir, "./vitest-setup-channel-file.mjs"); return { name: "@argos-ci/storybook/vitest-plugin", configureVitest({ vitest, project }) { + // Ahead of the user's setup files: it installs the Storybook channel that + // addon preview modules capture when they are imported. + project.config.setupFiles.unshift(channelSetupFile); + // After them: `afterEach` hooks run in reverse registration order, so + // registering last is what makes the screenshot happen before Storybook + // unmounts the story. project.config.setupFiles.push(setupFile); if (uploadToArgos) { @@ -138,7 +154,10 @@ export function argosVitestPlugin(options?: ArgosVitestPluginOptions): Plugin { config() { return { optimizeDeps: { - include: ["@argos-ci/storybook/internal/vitest-setup-file"], + include: [ + "@argos-ci/storybook/internal/vitest-setup-file", + "@argos-ci/storybook/internal/vitest-setup-channel-file", + ], }, test: { browser: { diff --git a/packages/storybook/src/vitest-setup-channel-file.ts b/packages/storybook/src/vitest-setup-channel-file.ts new file mode 100644 index 00000000..628ad8cf --- /dev/null +++ b/packages/storybook/src/vitest-setup-channel-file.ts @@ -0,0 +1,5 @@ +import { setupArgosChannel } from "./utils/channel"; + +// Registered ahead of the user's setup files so the channel exists before any +// addon preview module captures one. See `setupArgosChannel`. +setupArgosChannel(); diff --git a/packages/storybook/tsdown.config.ts b/packages/storybook/tsdown.config.ts index 096f2c05..4743c47a 100644 --- a/packages/storybook/tsdown.config.ts +++ b/packages/storybook/tsdown.config.ts @@ -23,7 +23,7 @@ export default defineConfig([ }, }, { - entry: ["src/vitest-setup-file.ts"], + entry: ["src/vitest-setup-file.ts", "src/vitest-setup-channel-file.ts"], dts: false, format: ["esm"], deps: { diff --git a/packages/storybook/vitest.unit.config.ts b/packages/storybook/vitest.unit.config.ts new file mode 100644 index 00000000..3a952d54 --- /dev/null +++ b/packages/storybook/vitest.unit.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +// Node unit tests. The browser tests that run the stories live in +// `vitest.config.ts`, which is driven by `@storybook/addon-vitest`. +export default defineConfig({ + test: { + name: "unit", + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/packages/vitest/e2e/screenshot.test.ts b/packages/vitest/e2e/screenshot.test.ts index 20f1484a..290e61b3 100644 --- a/packages/vitest/e2e/screenshot.test.ts +++ b/packages/vitest/e2e/screenshot.test.ts @@ -2,6 +2,7 @@ import { beforeEach, expect, test } from "vitest"; import { server } from "vitest/browser"; import { argosScreenshot, argosSnapshot } from "@argos-ci/vitest"; import type { ArgosAttachment } from "@argos-ci/playwright"; +import { SLOW_IMAGE_URL } from "./slow-image"; /** * These tests run in a real browser (Vitest browser mode + Playwright) and @@ -29,15 +30,51 @@ async function readMetadata(attachments: ArgosAttachment[]) { return JSON.parse(await server.commands.readFile(metadata.path)); } -/** Decode a PNG's pixel width from its IHDR chunk (big-endian uint32 @ byte 16). */ -async function readPngWidth(attachment: ArgosAttachment) { +/** Decode a PNG's pixel size from its IHDR chunk (big-endian uint32s @ byte 16). */ +async function readPngSize(attachment: ArgosAttachment) { const bin = await server.commands.readFile(attachment.path, "latin1"); - return ( - (bin.charCodeAt(16) << 24) | - (bin.charCodeAt(17) << 16) | - (bin.charCodeAt(18) << 8) | - bin.charCodeAt(19) + const readUint32 = (offset: number) => + (bin.charCodeAt(offset) << 24) | + (bin.charCodeAt(offset + 1) << 16) | + (bin.charCodeAt(offset + 2) << 8) | + bin.charCodeAt(offset + 3); + return { width: readUint32(16), height: readUint32(20) }; +} + +/** Decode a captured PNG so its pixels can be asserted on. */ +async function readPngImageData(attachment: ArgosAttachment) { + const binary = await server.commands.readFile(attachment.path, "latin1"); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap( + new Blob([bytes], { type: "image/png" }), ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("No 2d context available"); + } + context.drawImage(bitmap, 0, 0); + return context.getImageData(0, 0, bitmap.width, bitmap.height); +} + +/** + * Check that a horizontal band of the capture is fully white, which is what + * areas of the page the browser never painted look like. + */ +function checkIsBandBlank(image: ImageData, top: number, bottom: number) { + for (let y = top; y < bottom; y++) { + for (let x = 0; x < image.width; x++) { + const index = (y * image.width + x) * 4; + if ( + image.data[index] !== 255 || + image.data[index + 1] !== 255 || + image.data[index + 2] !== 255 + ) { + return false; + } + } + } + return true; } beforeEach(() => { @@ -154,10 +191,45 @@ test("grows the iframe to capture content wider than the viewport", async () => const attachments = await argosScreenshot("wide"); const screenshot = attachments.find((a) => a.path.endsWith("wide.png")); expect(screenshot).toBeDefined(); - const width = await readPngWidth(screenshot!); + const { width } = await readPngSize(screenshot!); expect(width).toBeGreaterThan(1500); }); +test("does not inherit the size of a previous, larger capture", async () => { + // The `tall` and `wide` tests above grow the Vitest iframe, which is shared by + // every test in the file. It has to be restored afterwards, otherwise later + // captures are padded with blank space. + mount(``); + const attachments = await argosScreenshot("small-after-big"); + const screenshot = attachments.find((a) => + a.path.endsWith("small-after-big.png"), + ); + expect(screenshot).toBeDefined(); + const { width, height } = await readPngSize(screenshot!); + expect(width).toBeLessThan(1000); + expect(height).toBeLessThan(1000); +}); + +test("paints content that only appears once slow images have loaded", async () => { + // An `