creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
263 lines (230 loc) • 9.23 kB
text/typescript
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;
}