Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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\"",
Expand Down
31 changes: 31 additions & 0 deletions packages/storybook/src/utils/channel.ts
Original file line number Diff line number Diff line change
@@ -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({}));
}
28 changes: 28 additions & 0 deletions packages/storybook/src/vitest-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
61 changes: 40 additions & 21 deletions packages/storybook/src/vitest-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,18 @@ export const createArgosScreenshotCommand = (
// and we screenshot the iframe's `<body>`. 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
Expand All @@ -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();
}
};
};

Expand Down Expand Up @@ -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) {
Expand All @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions packages/storybook/src/vitest-setup-channel-file.ts
Original file line number Diff line number Diff line change
@@ -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();
2 changes: 1 addition & 1 deletion packages/storybook/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
11 changes: 11 additions & 0 deletions packages/storybook/vitest.unit.config.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
88 changes: 80 additions & 8 deletions packages/vitest/e2e/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(`<div style="width:120px;height:60px;background:#0ea5e9"></div>`);
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 `<img>` takes no space until it loads, so the page grows taller while
// the screenshot flow waits for it. The iframe must be sized from the final
// layout: sizing it earlier leaves the bottom of the page unpainted.
mount(
`<div style="height:900px;background:#111"></div>` +
`<img src="${SLOW_IMAGE_URL}" style="display:block">`,
);
const attachments = await argosScreenshot("slow-image");
const screenshot = attachments.find((a) => a.path.endsWith("slow-image.png"));
expect(screenshot).toBeDefined();

const image = await readPngImageData(screenshot!);
// 900px of content plus the 300px image once it has loaded.
expect(image.height).toBeGreaterThanOrEqual(1200);
// The image is the last thing on the page, so the bottom of the capture must
// not be blank.
expect(checkIsBandBlank(image, image.height - 50, image.height)).toBe(false);
});

test("writes a value snapshot that the reporter can upload", async () => {
// `argosSnapshot` works without a browser, but here we exercise the browser
// RPC path: the value is serialized in the browser, written on the node side.
Expand Down
35 changes: 35 additions & 0 deletions packages/vitest/e2e/slow-image-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Plugin } from "vitest/config";
import { SLOW_IMAGE_URL } from "./slow-image";

/**
* Delay before the image bytes are sent, long enough for the screenshot flow to
* reach the point where it measures the content.
*/
const DELAY = 1500;

/** Solid magenta 300x300 PNG. */
const IMAGE_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAMAAABOo35HAAAAA1BMVEX/AP804Oa6AAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAbUlEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8G5gywABEMbFvQAAAABJRU5ErkJggg==";

/**
* Serve an image that only responds after {@link DELAY}.
*
* Used to reproduce a layout that grows *after* the screenshot flow has
* started: the `<img>` takes no space until it loads, so any content
* measurement done before then underestimates the page height.
*/
export function slowImagePlugin(): Plugin {
return {
name: "argos-e2e:slow-image",
configureServer(server) {
const image = Buffer.from(IMAGE_BASE64, "base64");
server.middlewares.use(SLOW_IMAGE_URL, (_req, res) => {
setTimeout(() => {
res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "no-store");
res.end(image);
}, DELAY);
});
},
};
}
7 changes: 7 additions & 0 deletions packages/vitest/e2e/slow-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* URL of the intentionally slow image served by `slowImagePlugin`.
*
* Kept apart from the plugin so the browser tests can import it without pulling
* in the Node-only plugin code.
*/
export const SLOW_IMAGE_URL = "/__argos_slow_image.png";
33 changes: 33 additions & 0 deletions packages/vitest/src/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ describe("createArgosScreenshotCommand", () => {
expect(metadata.playwrightLibraries).toContain("vitest");
});

it("waits for stabilization before sizing the iframe to the content", async () => {
const command = createArgosScreenshotCommand();
const { ctx, evaluate } = createCtx();

await command(ctx, "shot");

const [, , opts] = argosScreenshot.mock.calls[0]!;
const evaluateCallsBefore = evaluate.mock.calls.length;
const runStabilization = vi.fn(async () => {
// Nothing has been measured yet: content that is still loading (images,
// fonts) would make the page look shorter than it ends up being.
expect(evaluate).toHaveBeenCalledTimes(evaluateCallsBefore);
});

await opts.beforeScreenshot({ runStabilization });

expect(runStabilization).toHaveBeenCalledTimes(1);
// The iframe is only grown once the content has settled.
expect(evaluate.mock.calls.length).toBeGreaterThan(evaluateCallsBefore);
});

it("restores the iframe size once the screenshot is taken", async () => {
const command = createArgosScreenshotCommand();
const { ctx, evaluate } = createCtx();

await command(ctx, "shot");

// Vitest reuses the same iframe for every test in the file, so the size it
// was grown to must not leak into the next screenshot.
const sizes = evaluate.mock.calls.map((call) => call[1]?.size);
expect(sizes).toContain("initial");
});

it("takes one screenshot per viewport with viewport-suffixed names", async () => {
const command = createArgosScreenshotCommand();
const { ctx } = createCtx();
Expand Down
Loading
Loading