UNPKG

creevey

Version:

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

327 lines (285 loc) 11.6 kB
import * as Events from '@storybook/core-events'; import type { PreviewWeb } from '@storybook/preview-web'; import type { AnyFramework, StoryContextForEnhancers } from '@storybook/csf'; import type { StoryStore } from '@storybook/client-api'; import { makeDecorator } from '@storybook/preview-api'; import { Channel } from '@storybook/channels'; import { CaptureOptions, CreeveyStoryParams, isObject, noop, StoriesRaw, StorybookGlobals } from '../../types.js'; import { serializeRawStories } from '../../shared/index.js'; import { getConnectionUrl } from '../shared/helpers.js'; declare global { interface Window { __CREEVEY_SERVER_HOST__: string; __CREEVEY_SERVER_PORT__: number; __CREEVEY_WORKER_ID__: number; __CREEVEY_GET_STORIES__: () => Promise<StoriesRaw | undefined>; __CREEVEY_SELECT_STORY__: ( storyId: string, shouldWaitForReady: boolean, callback: (response: [error?: string | null, isCaptureCalled?: boolean]) => void, ) => Promise<void>; __CREEVEY_UPDATE_GLOBALS__: (globals: StorybookGlobals) => void; __CREEVEY_INSERT_IGNORE_STYLES__: (ignoreElements: string[]) => HTMLStyleElement; __CREEVEY_REMOVE_IGNORE_STYLES__: (ignoreStyles: HTMLStyleElement) => void; __CREEVEY_HAS_PLAY_COMPLETED_YET__: (callback: (isPlayCompleted: boolean) => void) => void; __CREEVEY_SET_READY_FOR_CAPTURE__?: () => void; __STORYBOOK_ADDONS_CHANNEL__: Channel; __STORYBOOK_STORY_STORE__: StoryStore<AnyFramework>; __STORYBOOK_PREVIEW__: PreviewWeb<AnyFramework>; } } interface CreeveyTestsState { setStoriesCounter?: number; creeveyHost?: string; creeveyPort?: number; isTestBrowser?: boolean; } const disableAnimationsStyles = ` *, *:hover, *::before, *::after { animation-delay: -0.0001ms !important; animation-duration: 0s !important; animation-play-state: paused !important; cursor: none !important; caret-color: transparent !important; transition: 0s !important; } `; function catchRenderError(channel: Channel): Promise<void> & { cancel: () => void } { let rejectCallback: (reason?: unknown) => void; const promise = 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 removeHandlers(): void { channel.off(Events.STORY_ERRORED, errorHandler); channel.off(Events.STORY_THREW_EXCEPTION, errorHandler); } channel.once(Events.STORY_ERRORED, errorHandler); channel.once(Events.STORY_THREW_EXCEPTION, exceptionHandler); return Object.assign(promise, { cancel: removeHandlers }); } function waitForStoryRendered(channel: Channel): Promise<void> & { cancel: () => void } { let resolveCallback: () => void; const promise = new Promise<void>((resolve) => (resolveCallback = resolve)); function renderHandler(): void { resolveCallback(); } function removeHandlers(): void { channel.off(Events.STORY_RENDERED, renderHandler); } channel.once(Events.STORY_RENDERED, renderHandler); return Object.assign(promise, { cancel: removeHandlers }); } function waitForFontsLoaded(): Promise<void> | void { // TODO Use document.fonts.ready instead const areFontsLoading = Array.from(document.fonts).some((font) => font.status == 'loading'); if (areFontsLoading) { return new Promise((resolve) => { const fontsLoadedHandler = (): void => { document.fonts.removeEventListener('loadingdone', fontsLoadedHandler); resolve(); }; document.fonts.addEventListener('loadingdone', fontsLoadedHandler); }); } } function waitForCaptureCall(): Promise<void> { return new Promise((resolve) => (captureResolver = resolve)); } function initCreeveyState(): void { const prevState = JSON.parse(window.localStorage.getItem('Creevey_Tests') ?? '{}') as CreeveyTestsState; if (prevState.creeveyHost) window.__CREEVEY_SERVER_HOST__ = prevState.creeveyHost; if (prevState.creeveyPort) window.__CREEVEY_SERVER_PORT__ = prevState.creeveyPort; if (prevState.setStoriesCounter) setStoriesCounter = prevState.setStoriesCounter; if (prevState.isTestBrowser) isTestBrowser = prevState.isTestBrowser; window.addEventListener('beforeunload', () => { window.localStorage.setItem( 'Creevey_Tests', JSON.stringify({ creeveyHost: window.__CREEVEY_SERVER_HOST__, creeveyPort: window.__CREEVEY_SERVER_PORT__, setStoriesCounter, isTestBrowser, } as CreeveyTestsState), ); }); } let isTestBrowser = false; let captureResolver: () => void; let waitForCreevey: Promise<void>; let creeveyReady: () => void; let setStoriesCounter = 0; export function withCreevey(): ReturnType<typeof makeDecorator> { const addonsChannel = (): Channel => window.__STORYBOOK_ADDONS_CHANNEL__; let isAnimationDisabled = false; initCreeveyState(); function disableAnimation(): void { isAnimationDisabled = true; const style = document.createElement('style'); const textNode = document.createTextNode(disableAnimationsStyles); style.setAttribute('type', 'text/css'); style.appendChild(textNode); document.head.appendChild(style); } async function getStories(): Promise<StoriesRaw | undefined> { const stories = serializeRawStories(await window.__STORYBOOK_PREVIEW__.extract()); const storiesByFiles = new Map<string, StoryContextForEnhancers[]>(); Object.values(stories).forEach((story) => { const fileName = story.parameters.fileName as string; const storiesFromFile = storiesByFiles.get(fileName); if (storiesFromFile) storiesFromFile.push(story); else storiesByFiles.set(fileName, [story]); }); void fetch(`http://${getConnectionUrl()}/stories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setStoriesCounter, stories: [...storiesByFiles.entries()] }), }); return stories; } // TODO Use Events.STORY_RENDER_PHASE_CHANGED: `loading/rendering/completed` with storyId // TODO Check other statuses and statuses with play function async function selectStory( storyId: string, shouldWaitForReady: boolean, callback: (response: [error?: string | null, isCaptureCalled?: boolean]) => void, ): Promise<void> { const currentStory = window.__STORYBOOK_PREVIEW__.currentSelection?.storyId ?? ''; if (!isAnimationDisabled) disableAnimation(); isTestBrowser = true; const channel = addonsChannel(); const waitForReady = shouldWaitForReady ? new Promise<void>((resolve) => (window.__CREEVEY_SET_READY_FOR_CAPTURE__ = resolve)) : Promise.resolve(); let isCaptureCalled = false; const renderPromise = waitForStoryRendered(channel); const errorPromise = catchRenderError(channel); const capturePromise = waitForCaptureCall().then(() => (isCaptureCalled = true)); setTimeout(() => { if (storyId == currentStory) channel.emit(Events.FORCE_REMOUNT, { storyId }); else channel.emit(Events.SET_CURRENT_STORY, { storyId }); }, 0); try { await Promise.race([ (async () => { await Promise.race([renderPromise, capturePromise]); await waitForFontsLoaded(); await waitForReady; })(), errorPromise, ]); callback([null, isCaptureCalled]); } 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); callback([errorMessage]); } finally { renderPromise.cancel(); errorPromise.cancel(); } } function updateGlobals(globals: StorybookGlobals): void { addonsChannel().emit(Events.UPDATE_GLOBALS, { globals }); } function insertIgnoreStyles(ignoreSelectors: string[]): HTMLStyleElement { const stylesElement = document.createElement('style'); stylesElement.setAttribute('type', 'text/css'); document.head.appendChild(stylesElement); ignoreSelectors.forEach((selector) => { stylesElement.innerHTML += ` ${selector} { background: #000 !important; box-shadow: none !important; text-shadow: none !important; outline: 0 !important; color: rgba(0,0,0,0) !important; } ${selector} *, ${selector}::before, ${selector}::after { visibility: hidden !important; } `; }); return stylesElement; } function removeIgnoreStyles(ignoreStyles: HTMLStyleElement): void { ignoreStyles.parentNode?.removeChild(ignoreStyles); } function hasPlayCompletedYet(callback: (isPlayCompleted: boolean) => void): void { creeveyReady(); let isCaptureCalled = false; let isPlayCompleted = false; const channel = addonsChannel(); void waitForStoryRendered(channel).then(() => { if (isCaptureCalled) return; isPlayCompleted = true; callback(true); }); void waitForCaptureCall().then(() => { if (isPlayCompleted) return; isCaptureCalled = true; callback(false); }); } window.__CREEVEY_GET_STORIES__ = getStories; window.__CREEVEY_SELECT_STORY__ = selectStory; window.__CREEVEY_UPDATE_GLOBALS__ = updateGlobals; window.__CREEVEY_INSERT_IGNORE_STYLES__ = insertIgnoreStyles; window.__CREEVEY_REMOVE_IGNORE_STYLES__ = removeIgnoreStyles; window.__CREEVEY_HAS_PLAY_COMPLETED_YET__ = hasPlayCompletedYet; window.__CREEVEY_SET_READY_FOR_CAPTURE__ = noop; return makeDecorator({ name: 'withCreevey', parameterName: 'creevey', wrapper: (getStory, context) => { // TODO Define proper types, like captureElement is a promise const { captureElement } = (context.parameters.creevey = (context.parameters.creevey ?? {}) as CreeveyStoryParams); Object.defineProperty(context.parameters.creevey, 'captureElement', { get() { switch (true) { case captureElement === undefined: return Promise.resolve(context.canvasElement); case captureElement === null: return Promise.resolve(document.documentElement); case typeof captureElement == 'string': return Promise.resolve((context.canvasElement as Element).querySelector(captureElement)); case typeof captureElement == 'function': // TODO Define type for it return Promise.resolve( (captureElement as unknown as (ctx: typeof context) => Promise<HTMLElement> | HTMLElement)(context), ); } }, enumerable: true, configurable: true, }); return getStory(context); }, }); } export async function capture(options?: CaptureOptions): Promise<void> { if (!isTestBrowser) return; captureResolver(); waitForCreevey = new Promise((resolve) => (creeveyReady = resolve)); void fetch(`http://${getConnectionUrl()}/capture`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workerId: window.__CREEVEY_WORKER_ID__, options }), }); await waitForCreevey; }