UNPKG

@percy/storybook

Version:

Storybook addons for visual testing with Percy

300 lines (284 loc) 12.2 kB
import { request, createRootResource, yieldTo } from '@percy/cli-command/utils'; import { logger } from '@percy/cli-command'; import spawn from 'cross-spawn'; // check storybook version export function checkStorybookVersion() { return new Promise((resolve, reject) => { const childProcess = spawn('storybook', ['--version']); let stdout = ''; let stderr = ''; childProcess.stdout.on('data', data => { stdout += data.toString(); }); childProcess.stderr.on('data', data => { stderr += data.toString(); }); childProcess.on('exit', code => { if (code === 0) { // Successful execution const versionMatch = stdout.match(/\d+/); // Match only major version if (versionMatch) { resolve(parseInt(versionMatch[0], 10)); // Parse as integer } else { reject(new Error('Unable to parse Storybook version')); } } else { // Non-zero exit code reject(new Error(`Storybook command failed with exit code ${code}: ${stderr}`)); } }); childProcess.on('error', err => { if (err.code === 'ENOENT') { resolve(6); } // Error occurred while spawning the child process reject(err); }); }); } // Transforms authorization credentials into a basic auth header and returns all config request // headers with the additional authorization header if not already set. function getAuthHeaders(config) { let headers = { ...config.requestHeaders }; let auth = config.authorization; if (auth && !(headers.authorization || headers.Authorization)) { let creds = auth.username + (auth.password ? `:${auth.password}` : ''); headers.Authorization = `Basic ${Buffer.from(creds).toString('base64')}`; } return headers; } // Fetch the raw Storybook preview resource to use when JS is enabled export async function fetchStorybookPreviewResource(percy, previewUrl) { return createRootResource(previewUrl, await request(previewUrl, { headers: getAuthHeaders(percy.config.discovery), retryNotFound: true, interval: 1000, retries: 30 })); } // Used during args/globals validation, encoding, and decoding const VAL_REG = /^[a-zA-Z0-9 _-]*$/; const NUM_REG = /^-?[0-9]+(\.[0-9]+)?$/; const HEX_REG = /^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/i; const COL_REG = /^(rgba?|hsla?)\(([0-9]{1,3}),\s*([0-9]{1,3})%?,\s*([0-9]{1,3})%?,?\s*([0-9]?(\.[0-9]{1,2})?)?\)$/i; const VALID_REG = new RegExp([VAL_REG, NUM_REG, HEX_REG, COL_REG].map(r => r.source).join('|'), 'i'); function isPlainObject(obj) { return typeof obj === 'object' && (Object.getPrototypeOf(obj) === null || Object.getPrototypeOf(obj) === Object.prototype); } // Validate story args & globals like storybook export function validateStoryArgs(key, value) { if (key == null || key === '' || !VAL_REG.test(key)) return false; if (value == null || value instanceof Date) return true; if (typeof value === 'number' || typeof value === 'boolean') return true; if (typeof value === 'string') return VALID_REG.test(value); if (Array.isArray(value)) return value.every(v => validateStoryArgs(key, v)); if (isPlainObject(value)) return Object.entries(value).every(e => validateStoryArgs(...e)); return false; } // Encode story args & globals like storybook export function encodeStoryArgs(value) { if (value == null) return `!${value}`; if (value instanceof Date) return `!date(${value.toISOString()})`; if (typeof value === 'string' && HEX_REG.test(value)) return `!hex(${value.slice(1)})`; if (typeof value === 'string' && COL_REG.test(value)) return `!${value.replace(/\s|%/g, '')}`; if (Array.isArray(value)) { return value.map(encodeStoryArgs); } else if (isPlainObject(value)) { return Object.entries(value).reduce((acc, [k, v]) => Object.assign(acc, { [k]: encodeStoryArgs(v) }), {}); } else { return value; } } // Decode story args & globals from encoded values export function decodeStoryArgs(value) { if (typeof value === 'string') { if (value === '!null') return null; if (value === '!undefined') return undefined; if (value.startsWith('!date(')) return new Date(value.slice(6, -1)); if (value.startsWith('!hex(')) return `#${value.slice(5, -1)}`; if (COL_REG.test(value.slice(1))) { let c = value.slice(1).match(COL_REG); let p = c[1][0] === 'h' ? '%' : ''; let a = c[1][3] === 'a' ? `, ${c[5]}` : ''; return `${c[1]}(${c[2]}, ${c[3]}${p}, ${c[4]}${p}${a})`; } return NUM_REG.test(value) ? Number(value) : value; } else if (Array.isArray(value)) { return value.map(decodeStoryArgs); } else if (isPlainObject(value)) { return Object.entries(value).reduce((acc, [k, v]) => Object.assign(acc, { [k]: decodeStoryArgs(v) }), {}); } else { return value; } } // Borrows a percy discovery browser page to navigate to a URL and evaluate a function, returning // the results and normalizing any thrown errors. export async function* withPage(percy, url, callback, retry, args) { let log = logger('storybook:utils'); let attempt = 0; let retries = 3; while (attempt < retries) { try { // provide discovery options that may impact how the page loads let page = yield percy.browser.page({ networkIdleTimeout: percy.config.discovery.networkIdleTimeout, requestHeaders: getAuthHeaders(percy.config.discovery), captureMockedServiceWorker: percy.config.discovery.captureMockedServiceWorker, userAgent: percy.config.discovery.userAgent }); // patch eval to include storybook specific helpers in the local scope page.eval = (fn, ...args) => page.constructor.prototype.eval.call(page, typeof fn === 'string' ? fn : ['function withPercyStorybookHelpers() {', ` const VAL_REG = ${VAL_REG};`, ` const NUM_REG = ${NUM_REG};`, ` const HEX_REG = ${HEX_REG};`, ` const COL_REG = ${COL_REG};`, ` const VALID_REG = ${VALID_REG};`, ` return (${fn})(...arguments);`, ` ${isPlainObject}`, ` ${validateStoryArgs}`, ` ${encodeStoryArgs}`, ` ${decodeStoryArgs}`, '}'].join('\n'), ...args); try { yield page.goto(url); return yield* yieldTo(callback(page)); } catch (error) { // if the page crashed and retry returns truthy, try again if (error.message?.includes('crashed') && retry?.()) { return yield* withPage(...arguments); } /* istanbul ignore next: purposefully not handling real errors */ throw typeof error !== 'string' ? error : new Error(error.replace( // strip generic error names and confusing stack traces /^Error:\s((.+?)\n\s+at\s.+)$/s, // keep the stack trace if the error came from a client script /\n\s+at\s.+?\(https?:/.test(error) ? '$1' : '$2')); } finally { // always clean up and close the page await page?.close(); } } catch (error) { attempt++; let enableRetry = process.env.PERCY_RETRY_STORY_ON_ERROR || 'true'; const from = args?.from; if (!(enableRetry === 'true') || attempt === retries) { // Add snapshotName to the error message const snapshotName = args?.snapshotName; if (from) { error.message = `${from}: \n${error.message}`; } if (snapshotName) { error.message = `Snapshot Name: ${snapshotName}: \n${error.message}`; } throw error; } // throw warning message with snapshot name if it is present. if (args?.snapshotName) { log.warn(`Retrying Story: ${args.snapshotName}, attempt: ${attempt}`); } // throw warning message with from where it is called if from in present. if (from) { log.warn(`Retrying because error occurred in: ${from}, attempt: ${attempt}`); } } } } // Evaluate and return Storybook environment information from the about page /* istanbul ignore next: no instrumenting injected code */ export function evalStorybookEnvironmentInfo({ waitForXPath }) { let possibleEnvs = []; possibleEnvs.push(waitForXPath("//header[starts-with(text(), 'Storybook ')]", 5000)); possibleEnvs.push(waitForXPath("//strong[starts-with(text(), 'You are on Storybook ')]", 5000)); return Promise.any(possibleEnvs).then(el => `storybook/${el.innerText.match(/-?\d*\.?\d+/g).join('')}`).catch(() => 'storybook/unknown'); } // Evaluate and return serialized Storybook stories to snapshot /* istanbul ignore next: no instrumenting injected code */ export function evalStorybookStorySnapshots({ waitFor }) { let serialize = (what, value, invalid) => { if (what === 'include' || what === 'exclude') { return [].concat(value).filter(Boolean).map(v => v.toString()); } else if (what === 'args' || what === 'globals') { return encodeStoryArgs(Object.entries(value).reduce((acc, [k, v]) => { if (validateStoryArgs(k, v)) return Object.assign(acc, { [k]: v }); invalid.set(`${what}.${k}`, `omitted potentially unsafe ${what.slice(0, -1)}`); return acc; }, {})); } else if (what === 'queryParams') { return Object.entries(value).reduce((acc, [k, v]) => Object.assign(acc, { [k]: encodeURIComponent(v) }), {}); } else if (what === 'additionalSnapshots') { return value.map(s => serialize('snapshot', s, invalid)); } else if (what === 'snapshot') { return Object.entries(value).reduce((acc, [k, v], i, a) => Object.assign(acc, { [k]: serialize(k, v, invalid) }), {}); } else { return value; } }; return waitFor(async () => { await window.__STORYBOOK_PREVIEW__?.ready?.(); // uncache stories, if cached via storyStorev7: true await (window.__STORYBOOK_PREVIEW__?.cacheAllCSFFiles?.() || window.__STORYBOOK_STORY_STORE__?.cacheAllCSFFiles?.()); const storiesObj = await window.__STORYBOOK_PREVIEW__?.extract?.(); if (storiesObj && !Array.isArray(storiesObj)) { return Object.values(storiesObj); } await window.__STORYBOOK_STORY_STORE__?.extract?.(); return window.__STORYBOOK_STORY_STORE__.raw(); }, 5000).catch(() => Promise.reject(new Error('Storybook object not found on the window. ' + 'Open Storybook and check the console for errors.'))).then(stories => { let invalid = new Map(); let data = stories.map(story => serialize('snapshot', { name: `${story.kind}: ${story.name}`, ...story.parameters?.percy, id: story.id }, invalid)); return { invalid: Array.from(invalid), data }; }); } // Change the currently selected story within Storybook, decoding args and globals as necessary /* istanbul ignore next: no instrumenting injected code */ export function evalSetCurrentStory({ waitFor }, story) { return waitFor(() => { // get the correct channel depending on the storybook version return window.__STORYBOOK_PREVIEW__?.channel || window.__STORYBOOK_STORY_STORE__?._channel; }, 5000).catch(() => Promise.reject(new Error('Storybook object not found on the window. ' + 'Open Storybook and check the console for errors.'))).then(channel => { let { id, queryParams, globals, args } = story; // emit a series of events to render the desired story channel.emit('setCurrentStory', { storyId: id }); channel.emit('updateGlobals', { globals: {} }); channel.emit('updateQueryParams', { ...queryParams }); if (globals) channel.emit('updateGlobals', { globals: decodeStoryArgs(globals) }); if (args) channel.emit('updateStoryArgs', { storyId: id, updatedArgs: decodeStoryArgs(args) }); // resolve when rendered, reject on any other renderer event return new Promise((resolve, reject) => { channel.on('storyRendered', resolve); channel.on('storyMissing', err => reject(err || new Error('Story Missing'))); channel.on('storyErrored', err => reject(err || new Error('Story Errored'))); channel.on('storyThrewException', err => reject(err || new Error('Story Threw Exception'))); }); }); }