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 `` 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( + `
` + + ``, + ); + 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. diff --git a/packages/vitest/e2e/slow-image-plugin.ts b/packages/vitest/e2e/slow-image-plugin.ts new file mode 100644 index 00000000..a111cf16 --- /dev/null +++ b/packages/vitest/e2e/slow-image-plugin.ts @@ -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 `` 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); + }); + }, + }; +} diff --git a/packages/vitest/e2e/slow-image.ts b/packages/vitest/e2e/slow-image.ts new file mode 100644 index 00000000..aeb07492 --- /dev/null +++ b/packages/vitest/e2e/slow-image.ts @@ -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"; diff --git a/packages/vitest/src/command.test.ts b/packages/vitest/src/command.test.ts index 10ccf0d8..f4c3a7fa 100644 --- a/packages/vitest/src/command.test.ts +++ b/packages/vitest/src/command.test.ts @@ -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(); diff --git a/packages/vitest/src/command.ts b/packages/vitest/src/command.ts index 4c4b276c..df286613 100644 --- a/packages/vitest/src/command.ts +++ b/packages/vitest/src/command.ts @@ -99,6 +99,10 @@ export const createArgosScreenshotCommand = ( return attachments; } finally { + // The iframe was grown to fit the content, so restore it: Vitest reuses + // the same iframe for every test in the file, and a leftover size would + // pad the next screenshots with blank space. + await setIframeViewportSize(ctx, "initial"); await restore(); } }; diff --git a/packages/vitest/src/iframe.ts b/packages/vitest/src/iframe.ts index 1da66018..4f47851b 100644 --- a/packages/vitest/src/iframe.ts +++ b/packages/vitest/src/iframe.ts @@ -12,6 +12,16 @@ export const VITEST_IFRAME_SELECTOR = 'iframe[data-vitest="true"]'; */ export const VITEST_TESTER_ID = "vitest-tester"; +/** + * Attribute holding the iframe's inline size from before Argos resized it, as + * JSON. + * + * The presence of the attribute — not the values it holds — is what marks the + * size as backed up: the original `style.width`/`style.height` are usually + * empty strings, which are indistinguishable from "nothing was saved yet". + */ +const SIZE_BACKUP_ATTRIBUTE = "data-argos-size-backup"; + /** * Remove the scale from the Vitest `#vitest-tester` element before taking a * screenshot to avoid ending up with small screenshots. @@ -58,7 +68,7 @@ export async function resetTesterScale( * box is not painted, so the iframe must be sized to hold the content. * * @param size - The viewport size, `"default"` to keep the natural size, or - * `"initial"` to restore the size backed up on the first resize. + * `"initial"` to restore the size the iframe had before Argos resized it. * @param options.fullPage - When `true`, grow the height to fit the content * while keeping the viewport width (Playwright-style full page). */ @@ -68,7 +78,7 @@ export async function setIframeViewportSize( options: { fullPage?: boolean } = {}, ): Promise { await ctx.page.evaluate( - ({ size, fullPage, selector }) => { + ({ size, fullPage, selector, backupAttribute }) => { const iframe = document.querySelector(selector); if (!(iframe instanceof HTMLIFrameElement)) { @@ -80,17 +90,26 @@ export async function setIframeViewportSize( } if (size === "initial") { - if (iframe.dataset.initialWidth && iframe.dataset.initialHeight) { - iframe.style.width = iframe.dataset.initialWidth; - iframe.style.height = iframe.dataset.initialHeight; + const backup = iframe.getAttribute(backupAttribute); + if (backup !== null) { + const { width, height } = JSON.parse(backup); + iframe.style.width = width; + iframe.style.height = height; + // Drop the backup so the next screenshot saves the size the iframe + // actually has then, rather than restoring a stale one. + iframe.removeAttribute(backupAttribute); } return; } - // Backup default width/height if not set - if (!iframe.dataset.initialWidth && !iframe.dataset.initialHeight) { - iframe.dataset.initialWidth = iframe.style.width; - iframe.dataset.initialHeight = iframe.style.height; + if (!iframe.hasAttribute(backupAttribute)) { + iframe.setAttribute( + backupAttribute, + JSON.stringify({ + width: iframe.style.width, + height: iframe.style.height, + }), + ); } if (size !== "default") { @@ -118,6 +137,7 @@ export async function setIframeViewportSize( size, fullPage: options.fullPage ?? false, selector: VITEST_IFRAME_SELECTOR, + backupAttribute: SIZE_BACKUP_ATTRIBUTE, }, ); } @@ -125,9 +145,12 @@ export async function setIframeViewportSize( /** * Grow the Vitest iframe to fit its content so nothing is clipped. * - * This must run *after* `argosCSS` (which may inject a `zoom`) is applied, - * because `setIframeViewportSize` sizes the iframe *before* the content's final - * size is known. It only ever grows the iframe, never shrinks it. + * This must run once the content has reached its final size — after `argosCSS` + * (which may inject a `zoom`) is applied *and* after stabilization has waited + * for images and fonts. `setIframeViewportSize` sizes the iframe before any of + * that, so it can't account for the final content size. It only ever grows the + * iframe, never shrinks it; use `setIframeViewportSize(ctx, "initial")` to + * restore the original size afterwards. * * @param options.fitWidth - Also grow the iframe horizontally to paint content * wider than the viewport. When `false`, only the height grows (to match @@ -138,13 +161,23 @@ export async function fitIframeToContent( options: { fitWidth: boolean }, ): Promise { await ctx.page.evaluate( - ({ fitWidth, selector }) => { + ({ fitWidth, selector, backupAttribute }) => { const iframe = document.querySelector(selector); if (!(iframe instanceof HTMLIFrameElement) || !iframe.contentDocument) { return; } + if (!iframe.hasAttribute(backupAttribute)) { + iframe.setAttribute( + backupAttribute, + JSON.stringify({ + width: iframe.style.width, + height: iframe.style.height, + }), + ); + } + const { body, documentElement } = iframe.contentDocument; const contentHeight = Math.max( body.scrollHeight, @@ -170,6 +203,10 @@ export async function fitIframeToContent( } } }, - { fitWidth: options.fitWidth, selector: VITEST_IFRAME_SELECTOR }, + { + fitWidth: options.fitWidth, + selector: VITEST_IFRAME_SELECTOR, + backupAttribute: SIZE_BACKUP_ATTRIBUTE, + }, ); } diff --git a/packages/vitest/src/screenshot.ts b/packages/vitest/src/screenshot.ts index 015213c8..c44d0567 100644 --- a/packages/vitest/src/screenshot.ts +++ b/packages/vitest/src/screenshot.ts @@ -13,9 +13,9 @@ import { fitIframeToContent } from "./iframe"; * on. It: * - strips the Vitest-specific `viewports`/`fullPage` options (they drive the * iframe resize, not Playwright); - * - wraps `beforeScreenshot` so the content is grown to fit *after* `argosCSS` - * (and any user `beforeScreenshot`) has been applied — otherwise wide/tall - * content would be clipped; + * - wraps `beforeScreenshot` so the iframe is grown to fit once the content has + * settled (see {@link fitIframeToContent}) — otherwise wide/tall content + * would be clipped; * - captures the iframe's `` via `@argos-ci/playwright`. * * @param config.fitWidth - Grow the iframe horizontally as well as vertically @@ -34,8 +34,13 @@ export async function screenshotFrame( ...rest, beforeScreenshot: async (api) => { await userBeforeScreenshot?.(api); - // Re-fit the iframe here, after stabilization has injected `argosCSS`, so - // the whole content is painted and nothing is clipped. + // 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 short for + // the final content, and everything below is never painted. + await api.runStabilization(); + // Re-fit the iframe here, after stabilization has injected `argosCSS` and + // the content reached its final size, so nothing is clipped. await fitIframeToContent(ctx, { fitWidth: config.fitWidth }); }, }; diff --git a/packages/vitest/vitest.config.ts b/packages/vitest/vitest.config.ts index f8ed7967..35777147 100644 --- a/packages/vitest/vitest.config.ts +++ b/packages/vitest/vitest.config.ts @@ -2,6 +2,7 @@ import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; import { argosVitestPlugin } from "./dist/plugin.mjs"; +import { slowImagePlugin } from "./e2e/slow-image-plugin"; export default defineConfig({ test: { @@ -21,6 +22,7 @@ export default defineConfig({ uploadToArgos: process.env.UPLOAD_TO_ARGOS === "true", buildName: process.env.BUILD_NAME, }), + slowImagePlugin(), ], test: { name: "e2e",