UNPKG

creevey

Version:

Cross-browser screenshot testing tool for Storybook with fancy UI Runner

362 lines (314 loc) 13.3 kB
import path from 'path'; import assert from 'assert'; import { readFileSync } from 'fs'; import type { PixelmatchOptions } from 'pixelmatch'; import type { ODiffOptions } from 'odiff-bin'; import { test, Page, BrowserContext } from '@playwright/test'; import isEqual from 'lodash/isEqual.js'; import { CreeveyStoryParams, isObject, StoriesRaw, StorybookEvents, StorybookGlobals } from '../types'; import { getOdiffAssert, getPixelmatchAssert, ImageContext } from '../server/compare'; import { appendIframePath } from '../server/webdriver'; import { waitForStorybookReady } from './helpers'; export interface TestsConfig { /** * Define pixelmatch diff options * @default { threshold: 0.1, includeAA: false } */ diffOptions: PixelmatchOptions; /** * Define odiff diff options * @default { threshold: 0.1, antialiasing: true } */ odiffOptions: ODiffOptions; /** * Define matcher for visual regression assertion * @default 'pixelmatch' */ comparisonLibrary: 'pixelmatch' | 'odiff'; /** * Enables page context reuse across tests for faster execution, though this breaks test isolation. * @default true */ reusePageContext: boolean; /** * Enables trace recording for each test. * @default false */ trace: boolean | { screenshots?: boolean; snapshots?: boolean; sources?: boolean }; } const cacheDir = process.env.CREEVEY_CACHE_DIR; const defaultConfig: TestsConfig = { diffOptions: { threshold: 0.1, includeAA: false }, odiffOptions: { threshold: 0.1, antialiasing: true }, comparisonLibrary: 'pixelmatch', reusePageContext: true, trace: false, }; // TODO: Use this Storybook function for building args for query params // export const buildArgsParam = (initialArgs: Args | undefined, args: Args): string => { // TODO: Pass globals to story function appendStoryQueryParams(url: string, storyId: string): string { return `${url}?args=&globals=&id=${storyId}`; } function assertWrapper( assert: (actual: Buffer, imageName?: string) => Promise<string | undefined>, ): (actual: Buffer, imageName?: string) => Promise<void> { return async function assertImage(actual, imageName) { try { const errorMessage = await assert(actual, imageName); if (errorMessage) { throw new Error(errorMessage); } } catch (error) { if (error instanceof Error) { error.stack = error.stack ?.split('\n') .filter((line) => !line.includes('at assertImage')) .join('\n'); } throw error; } }; } async function takeScreenshot( page: Page, storyId: string, captureElement?: string | null, ignoreElements?: string | string[] | null, ): Promise<Buffer> { const ignore = ignoreElements ? (Array.isArray(ignoreElements) ? ignoreElements : [ignoreElements]) : []; const mask = ignore.map((selector) => page.locator(selector)); if (captureElement) { // TODO Use page.locator(captureElement) instead of page.$(captureElement) // TODO Test `#storybook-root > *` selector, probably we don't need `> *` and use `#storybook-root >*:first-child` instead const element = await page.$(captureElement); if (!element) throw new Error(`Capture element '${captureElement}' not found for story '${storyId}'`); return element.screenshot({ style: ':root { overflow: hidden !important; }', animations: 'disabled', mask, }); } else { return page.screenshot({ animations: 'disabled', mask, }); } } // TODO: To support parallel tests, we need to define each test suite in separate file // TODO: How to support custom interactions for different tests // Main function to define tests using Playwright's API export function definePlaywrightTests(config?: Partial<TestsConfig>): void { assert(cacheDir, 'Cache directory not found'); const stories = JSON.parse(readFileSync(path.join(cacheDir, 'stories.json'), 'utf-8')) as StoriesRaw; let globals: StorybookGlobals = {}; let reusedContext: BrowserContext; let reusedPage: Page; const { diffOptions, odiffOptions, comparisonLibrary, reusePageContext, trace } = { ...defaultConfig, ...config, }; async function updateGlobals(page: Page, storybookGlobals: unknown): Promise<void> { if (storybookGlobals && typeof storybookGlobals === 'object' && !isEqual(globals, storybookGlobals)) { globals = storybookGlobals as StorybookGlobals; await page.evaluate((globals) => { window.__STORYBOOK_ADDONS_CHANNEL__.emit(StorybookEvents.UPDATE_GLOBALS, { globals }); }, globals); } } test.describe('Creevey Tests', () => { const imagesContext: ImageContext = { attachments: [], testFullPath: [], images: {}, }; let assertImage: (actual: Buffer, imageName?: string) => Promise<void>; test.beforeAll('Setup images context', async ({ browser }, { project }) => { const { snapshotDir, outputDir, use: { viewport }, } = project; if (reusePageContext) { const storybookUrl = project.use.baseURL; assert(storybookUrl, 'Storybook URL not found'); // TODO Record video reusedContext = await browser.newContext({ viewport, screen: viewport ?? undefined, // recordVideo: trace ? { dir: path.join(cacheDir, `${process.pid}`), size: viewport ?? undefined } : undefined, }); reusedPage = await reusedContext.newPage(); if (trace) { // TODO: Add logger for tracing await reusedContext.tracing.start( typeof trace === 'object' ? trace : { screenshots: true, snapshots: true, sources: true }, ); } await reusedPage.goto(appendIframePath(storybookUrl), { timeout: 60000 }); await reusedPage.waitForLoadState('networkidle'); await waitForStorybookReady(reusedPage); } if (comparisonLibrary === 'pixelmatch') { const { default: pixelmatch } = await import('pixelmatch'); assertImage = assertWrapper( getPixelmatchAssert(pixelmatch, imagesContext, { screenDir: snapshotDir, reportDir: outputDir, diffOptions }), ); } else { const { compare } = await import('odiff-bin'); assertImage = assertWrapper( getOdiffAssert(compare, imagesContext, { screenDir: snapshotDir, reportDir: outputDir, odiffOptions }), ); } }); test.beforeEach('Switch story', async ({ page }, { annotations, project }) => { const { description: storyId } = annotations.find((annotation) => annotation.type === 'storyId') ?? {}; assert(storyId, 'Cannot get storyId. It seems like inner test annotation is missing'); const story = stories[storyId]; assert(story, `Story '${storyId}' not found in stories cache`); const { title, name } = story; const storybookGlobals: unknown = project.metadata.storybookGlobals; imagesContext.attachments = []; imagesContext.testFullPath = [...title.split('/').map((x) => x.trim()), name, project.name]; imagesContext.images = {}; if (!reusePageContext) { const storybookUrl = project.use.baseURL; assert(storybookUrl, 'Storybook URL not found'); await page.goto(appendStoryQueryParams(appendIframePath(storybookUrl), storyId), { timeout: 60000 }); await page.waitForLoadState('networkidle'); await waitForStorybookReady(page); // TODO: Pass globals to story await updateGlobals(page, storybookGlobals); return; } // 1. Update Storybook Globals await updateGlobals(reusedPage, storybookGlobals); // 2. Reset Mouse Position await reusedPage.mouse.move(0, 0); // 3. Select Story const errorMessage = await reusedPage.evaluate< string | null, { storyId: string; StorybookEvents: typeof StorybookEvents } >( async ({ storyId, StorybookEvents }) => { // TODO: DRY with withCreevey.ts // NOTE: Copy-pasted from withCreevey.ts const channel = window.__STORYBOOK_ADDONS_CHANNEL__; async function sequence(fns: (() => Promise<unknown>)[]): Promise<void> { for (const fn of fns) { await fn(); } } let rejectCallback: (reason?: unknown) => void; const renderErrorPromise = new Promise<void>((_resolve, reject) => (rejectCallback = reject)); function errorHandler({ title, description }: { title: string; description: string }): void { rejectCallback({ message: title, stack: description, }); } function exceptionHandler(exception: Error): void { rejectCallback(exception); } function removeErrorHandlers(): void { channel.off(StorybookEvents.STORY_ERRORED, errorHandler); channel.off(StorybookEvents.STORY_THREW_EXCEPTION, errorHandler); } channel.once(StorybookEvents.STORY_ERRORED, errorHandler); channel.once(StorybookEvents.STORY_THREW_EXCEPTION, exceptionHandler); let resolveCallback: () => void; const storyRenderedPromise = new Promise<void>((resolve) => (resolveCallback = resolve)); function renderHandler(): void { resolveCallback(); } function removeRenderHandlers(): void { channel.off(StorybookEvents.STORY_RENDERED, renderHandler); } channel.once(StorybookEvents.STORY_RENDERED, renderHandler); setTimeout(() => { channel.emit(StorybookEvents.SET_CURRENT_STORY, { storyId }); }, 0); try { await Promise.race([ renderErrorPromise, sequence([() => storyRenderedPromise, () => document.fonts.ready]), ]); } catch (reason) { // NOTE Event `STORY_THREW_EXCEPTION` triggered only in react and vue frameworks and return Error instance // NOTE Event `STORY_ERRORED` return error-like object without `name` field const errorMessage = reason instanceof Error ? (reason.stack ?? reason.message) : isObject(reason) ? `${reason.message as string}\n ${reason.stack as string}` : (reason as string); return errorMessage; } finally { removeErrorHandlers(); removeRenderHandlers(); } return null; }, { storyId: story.id, StorybookEvents }, ); if (errorMessage) { throw new Error(`Failed to select story '${story.id}': ${errorMessage}`); } }); test.afterEach('Save screenshot', () => { const { name: projectName } = test.info().project; // TODO: Use another way to handle attachments // NOTE: Don't need to copy files for assertImage, because it's done internally const { actual, diff, expect } = imagesContext.images[projectName] ?? {}; for (const image of imagesContext.attachments) { switch (true) { case image.includes('actual') && !!actual: { test.info().attachments.push({ name: actual, path: image, contentType: 'image/png' }); // await test.info().attach(actual, { path: image }); break; } case image.includes('expect') && !!expect: { test.info().attachments.push({ name: expect, path: image, contentType: 'image/png' }); // await test.info().attach(expect, { path: image }); break; } case image.includes('diff') && !!diff: { test.info().attachments.push({ name: diff, path: image, contentType: 'image/png' }); // await test.info().attach(diff, { path: image }); break; } } } }); if (trace && reusePageContext) { test.afterAll('Save trace', async ({ browser: _ }, { project }) => { const { outputDir, name: projectName } = project; await reusedContext.tracing.stop({ path: `${outputDir}/traces/${projectName}-${process.pid}.zip`, }); // await reusedPage.video()?.saveAs(`${outputDir}/traces/${projectName}-${process.pid}.webm`); }); } for (const story of Object.values(stories)) { const { name, title, parameters } = story; const { captureElement, ignoreElements } = (parameters.creevey ?? {}) as CreeveyStoryParams; test.describe(title, () => { // TODO: Support creevey.skip test(name, { annotation: [{ type: 'storyId', description: story.id }] }, async ({ page }) => { // 4. Take Screenshot const screenshot = await takeScreenshot( reusePageContext ? reusedPage : page, story.id, captureElement, ignoreElements, ); // TODO: Support this // NOTE: Bear in mind that page.locator('#root > *') is not working // await expect(page.locator(captureElement)).toHaveScreenshot(name); // 5. Assert Image await assertImage(screenshot); }); }); } }); }