UNPKG

creevey

Version:

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

263 lines (230 loc) 9.23 kB
import type { Renderer } from 'storybook/internal/types'; import type { PreviewWeb, StoryStore } from 'storybook/preview-api'; import { Channel } from 'storybook/internal/channels'; import type { CreeveyStoryParams, StoriesRaw, StorybookGlobals } from '../types.js'; import type { SerializedRegExp } from '../shared/serializeRegExp.js'; declare global { interface Window { __CREEVEY_ANIMATION_DISABLED__: boolean; __CREEVEY_SESSION_ID__: string; __CREEVYE_STORYBOOK_READY__: boolean; __CREEVEY_STORYBOOK_STORIES__: undefined | StoriesRaw; __CREEVEY_STORYBOOK_GLOBALS__: undefined | StorybookGlobals; __CREEVEY_SELECT_STORY_RESULT__?: null | { status: 'success' } | { status: 'error'; message: string }; __STORYBOOK_ADDONS_CHANNEL__: Channel; __STORYBOOK_MODULE_CORE_EVENTS__: Record<string, string>; __STORYBOOK_STORY_STORE__: StoryStore<Renderer>; __STORYBOOK_PREVIEW__: PreviewWeb<Renderer>; } } // TODO Use StorybookEvents.STORY_RENDER_PHASE_CHANGED: `loading/rendering/completed` with storyId // TODO Check other statuses and statuses with play function export function selectStory(storyId: string, callback?: (error: string | null) => void): void { const STORYBOOK_EVENTS = { SET_STORIES: 'setStories', SET_CURRENT_STORY: 'setCurrentStory', FORCE_REMOUNT: 'forceRemount', STORY_RENDERED: 'storyRendered', STORY_FINISHED: 'storyFinished', STORY_ERRORED: 'storyErrored', // STORY_MISSING: 'storyMissing', STORY_THREW_EXCEPTION: 'storyThrewException', PLAY_FUNCTION_THREW_EXCEPTION: 'playFunctionThrewException', UPDATE_STORY_ARGS: 'updateStoryArgs', SET_GLOBALS: 'setGlobals', UPDATE_GLOBALS: 'updateGlobals', GLOBALS_UPDATED: 'globalsUpdated', }; const addonsChannel = (): Channel => window.__STORYBOOK_ADDONS_CHANNEL__; 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 isObject(x: unknown): x is Record<string, unknown> { return typeof x == 'object' && x != null; } function disableAnimation(): void { window.__CREEVEY_ANIMATION_DISABLED__ = true; const style = document.createElement('style'); const textNode = document.createTextNode(disableAnimationsStyles); style.setAttribute('type', 'text/css'); style.appendChild(textNode); document.head.appendChild(style); } 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 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(STORYBOOK_EVENTS.STORY_ERRORED, errorHandler); channel.off(STORYBOOK_EVENTS.STORY_THREW_EXCEPTION, exceptionHandler); channel.off(STORYBOOK_EVENTS.PLAY_FUNCTION_THREW_EXCEPTION, exceptionHandler); } channel.once(STORYBOOK_EVENTS.STORY_ERRORED, errorHandler); channel.once(STORYBOOK_EVENTS.STORY_THREW_EXCEPTION, exceptionHandler); if (window.__STORYBOOK_MODULE_CORE_EVENTS__.PLAY_FUNCTION_THREW_EXCEPTION) { channel.once(STORYBOOK_EVENTS.PLAY_FUNCTION_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(STORYBOOK_EVENTS.STORY_FINISHED, renderHandler); channel.off(STORYBOOK_EVENTS.STORY_RENDERED, renderHandler); } if (window.__STORYBOOK_MODULE_CORE_EVENTS__.STORY_FINISHED) { channel.once(STORYBOOK_EVENTS.STORY_FINISHED, renderHandler); } else { // NOTE: Earlier versions of Storybook don't have STORY_FINISHED event channel.once(STORYBOOK_EVENTS.STORY_RENDERED, renderHandler); } return Object.assign(promise, { cancel: removeHandlers }); } let currentStory = ''; const currentSelection = window.__STORYBOOK_PREVIEW__.currentSelection; if (currentSelection) { currentStory = currentSelection.storyId; } if (!window.__CREEVEY_ANIMATION_DISABLED__) disableAnimation(); const channel = addonsChannel(); const renderPromise = waitForStoryRendered(channel); const errorPromise = catchRenderError(channel); setTimeout(() => { if (storyId == currentStory) channel.emit(STORYBOOK_EVENTS.FORCE_REMOUNT, { storyId }); else channel.emit(STORYBOOK_EVENTS.SET_CURRENT_STORY, { storyId }); }, 0); void (async () => { try { await Promise.race([ (async () => { await renderPromise; await waitForFontsLoaded(); })(), errorPromise, ]); if (callback) callback(null); window.__CREEVEY_SELECT_STORY_RESULT__ = { status: 'success' }; } 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 ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing reason.stack || reason.message : isObject(reason) ? `${reason.message as string}\n ${reason.stack as string}` : (reason as string); if (callback) callback(errorMessage); window.__CREEVEY_SELECT_STORY_RESULT__ = { status: 'error', message: errorMessage }; } finally { renderPromise.cancel(); errorPromise.cancel(); } })(); } export 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; } export function removeIgnoreStyles(ignoreStyles: HTMLStyleElement): void { ignoreStyles.remove(); } // TODO Find a way to send stories updates to the server export async function getStories(callback?: (stories: StoriesRaw) => void): Promise<StoriesRaw> { function isRegExp(exp: unknown): exp is RegExp { return exp instanceof RegExp; } function serializeRegExp(exp: RegExp): SerializedRegExp { const { source, flags } = exp; return { __regexp: true, source, flags, }; } function cloneDeepWith<T>(value: T, customizer: (value: unknown) => unknown): T { const customized = customizer(value); if (customized !== undefined) return customized as T; if (Array.isArray(value)) { return value.map((item) => cloneDeepWith(item as T, customizer)) as T; } if (value && typeof value === 'object') { const out: Record<string, unknown> = {}; for (const [k, v] of Object.entries(value as Record<string, unknown>)) { out[k] = cloneDeepWith(v, customizer); } return out as unknown as T; } return value; } function serializeRawStories(stories: StoriesRaw) { for (const storyId in stories) { const story = stories[storyId]; const creevey = story.parameters.creevey as CreeveyStoryParams | undefined; if (creevey && 'skip' in creevey && creevey.skip) { creevey.skip = cloneDeepWith(creevey.skip, (value) => { if (isRegExp(value)) { return serializeRegExp(value); } return undefined; }) as CreeveyStoryParams['skip']; } } } const stories = await window.__STORYBOOK_PREVIEW__.extract(); serializeRawStories(stories); window.__CREEVEY_STORYBOOK_STORIES__ = stories; if (callback) callback(stories); return stories; }