UNPKG

@percy/storybook

Version:

Storybook addons for visual testing with Percy

274 lines (254 loc) 10.7 kB
import { logger, PercyConfig } from '@percy/cli-command'; import { yieldAll } from '@percy/cli-command/utils'; import qs from 'qs'; import { fetchStorybookPreviewResource, evalStorybookEnvironmentInfo, evalStorybookStorySnapshots, evalSetCurrentStory, validateStoryArgs, encodeStoryArgs, withPage } from './utils.js'; // Returns true or false if the provided story should be skipped by matching against include and // exclude filter options. If any global filters are provided, they will override story filters. function shouldSkipStory(name, options, config) { let matches = regexp => { /* istanbul ignore else: sanity check */ if (typeof regexp === 'string') { let [, parsed, flags] = /^\/(.+)\/(\w+)?$/.exec(regexp) || []; regexp = new RegExp(parsed ?? regexp, flags); } return regexp?.test?.(name); }; // if a global filter is present, disregard story filters let filter = config?.include || config?.exclude ? config : options; let include = [].concat(filter?.include).filter(Boolean); let exclude = [].concat(filter?.exclude).filter(Boolean); // if included, don't skip; if excluded always exclude let skip = include?.length ? !include.some(matches) : options.skip; if (!skip && !exclude?.some(matches)) return false; return true; } // Returns snapshot config options for a Storybook story merged with global Storybook // options. Validation error messages will be added to the provided validations set. function getSnapshotConfig(story, config, invalid) { let { id, ...options } = PercyConfig.migrate(story, '/storybook'); let errors = PercyConfig.validate(options, '/storybook'); for (let e of errors || []) invalid.set(e.path, e.message); return PercyConfig.merge([config, options, { id }], (path, prev, next) => { // normalize, but do not merge include or exclude options if (path.length === 1 && ['include', 'exclude'].includes(path[0])) { return [path, [].concat(next).filter(Boolean)]; } }); } // Returns a copy of the provided config object with encoded Storybook args and globals function encodeStorybookConfig(config = {}, invalid) { return Object.entries(config).reduce((acc, [key, value]) => Object.assign(acc, { [key]: (key === 'args' || key === 'globals') && encodeStoryArgs(Object.entries(value).reduce((acc, [k, v]) => { if (validateStoryArgs(k, v)) return Object.assign(acc, { [k]: v }); invalid.set(`${key}.${k}`, `omitted potentially unsafe ${key.slice(0, -1)}`); return acc; }, {})) || key === 'additionalSnapshots' && value.map(s => encodeStorybookConfig(s, invalid)) || value }), {}); } // Split snapshots into chunks of shards according to the provided size, count, and index function shardSnapshots(snapshots, { shardSize, shardCount, shardIndex }) { if (!shardSize && !shardCount) { throw new Error("Found '--shard-index' but missing '--shard-size' or '--shard-count'"); } else if (shardSize && shardCount) { throw new Error("Must specify either '--shard-size' OR '--shard-count' not both"); } let total = snapshots.length; let size = shardSize ?? Math.ceil(total / shardCount); let count = shardCount ?? Math.ceil(total / shardSize); if (shardIndex == null || shardIndex >= count) { throw new Error((!shardIndex ? "Missing '--shard-index'." : `The provided '--shard-index' (${shardIndex}) is out of range.`) + ` Found ${count} shards of ${size} snapshots each (${total} total)`); } return snapshots.splice(size * shardIndex, size); } // Transforms a set of pre-encoded args into a single query parameter value function buildStorybookArgsParam(args) { let argsParam = qs.stringify(args, { encode: false, delimiter: ';', allowDots: true, format: 'RFC1738' }); return argsParam.replace(/ /g, '+').replace(/=/g, ':'); } // Map and reduce collected Storybook stories into an array of snapshot options function mapStorybookSnapshots(stories, { previewUrl, flags, config }) { let log = logger('storybook:config'); let invalid = new Map(stories.invalid); let conf = encodeStorybookConfig(config, invalid); let snapshots = stories.data.reduce((all, story) => { if (shouldSkipStory(story.name, story, config)) { log.debug(`Skipping story: ${story.name}`); return all; } let { additionalSnapshots = [], ...options } = getSnapshotConfig(story, conf, invalid); return all.concat(options, additionalSnapshots.reduce((add, { prefix = '', suffix = '', ...snap }) => shouldSkipStory(story.name, snap) ? add : add.concat(PercyConfig.merge([options, { name: `${prefix}${story.name}${suffix}` }, snap])), [])); }, []); // log validation warnings if (invalid.size) { log.warn('Invalid Storybook parameters:'); invalid = Array.from(invalid.entries()).sort(([a], [b]) => a.localeCompare(b)); for (let [k, msg] of invalid) log.warn(`- percy.${k}: ${msg}`); } // maybe split snapshots into shards if ((flags.shardSize || flags.shardCount || flags.shardIndex) != null) { snapshots = shardSnapshots(snapshots, flags, log); } // error when missing snapshots if (!snapshots.length) throw new Error('No snapshots found'); // remove filter options and generate story snapshot URLs return snapshots.map(({ skip, include, exclude, ...story }) => { let url = `${previewUrl}?id=${story.id}`; if (story.args) url += `&args=${buildStorybookArgsParam(story.args)}`; if (story.globals) url += `&globals=${buildStorybookArgsParam(story.globals)}`; for (let [k, v] of Object.entries(story.queryParams ?? {})) url += `&${k}=${v}`; return Object.assign(story, { url }); }); } // Starts the percy instance and collects Storybook snapshots, calling the callback when done export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags }) { try { let aboutUrl = new URL('?path=/settings/about', baseUrl).href; let previewUrl = new URL('iframe.html', baseUrl).href; let log = logger('storybook'); let lastCount; log.debug(`Requesting Storybook: ${baseUrl}`); // start a timeout to show a log if storybook takes a few seconds to respond let logTimeout = setTimeout(log.warn, 3000, 'Waiting on a response from Storybook...'); let previewResource = yield fetchStorybookPreviewResource(percy, previewUrl); clearTimeout(logTimeout); // start percy yield* percy.yield.start(); // launch the percy browser if not launched during dry-runs yield percy.browser.launch(); // gather storybook data in parallel let [environmentInfo, stories] = yield* yieldAll([withPage(percy, aboutUrl, p => p.eval(evalStorybookEnvironmentInfo), undefined, { from: 'about url' }), withPage(percy, previewUrl, p => p.eval(evalStorybookStorySnapshots), undefined, { from: 'preview url' })]); // map stories to snapshot options let snapshots = mapStorybookSnapshots(stories, { config: percy.config.storybook, previewUrl, flags }); // set storybook environment info percy.client.addEnvironmentInfo(environmentInfo); // We use an outer and inner loop on same snapshots.length // - we create a new page and load one story on it at a time for snapshotting // - if it throws exception then we want to catch it outside of `withPage` call as // when `withPage` returns it closes the page // - we want to make sure we close the page that had exception in story to make sure // we dont reuse a page which is possibly in a weird state due to last exception // - so post exception we come out of inner loop and skip the story, create new page // using outer loop and continue next stories again on a new page while (snapshots.length) { try { // use a single page to capture story snapshots without reloading // loading an existing story instead of just iframe.html as that triggers `storyMissing` event // This in turn leads to promise rejection and failure yield* withPage(percy, `${previewUrl}?id=${snapshots[0].id}&viewMode=story`, async function* (page) { // determines when to retry page crashes lastCount = snapshots.length; while (snapshots.length) { // separate story and snapshot options let { id, args, globals, queryParams, ...options } = snapshots[0]; const enableJavaScript = options.enableJavaScript ?? percy.config.snapshot.enableJavaScript; if (flags.dryRun || enableJavaScript) { log.debug(`Loading story via previewResource: ${options.name}`); // when dry-running or when javascript is enabled, use the preview dom options.domSnapshot = previewResource.content; } else { log.debug(`Loading story: ${options.name}`); // when not dry-running and javascript is not enabled, capture the story dom yield page.eval(evalSetCurrentStory, { id, args, globals, queryParams }); /* istanbul ignore next: tested, but coverage is stripped */ let { dom, domSnapshot = dom } = yield page.snapshot(options); options.domSnapshot = domSnapshot; } // validate without logging to prune all other options PercyConfig.validate(options, '/snapshot/dom'); // snapshots are queued and do not need to be awaited on percy.snapshot(options); // discard this story snapshot when done snapshots.shift(); } }, () => { log.debug(`Page crashed while loading story: ${snapshots[0].name}`); // return true to retry as long as the length decreases return lastCount > snapshots.length; }, { snapshotName: snapshots[0].name }); } catch (e) { if (process.env.PERCY_SKIP_STORY_ON_ERROR === 'true') { let { name } = snapshots[0]; log.error(`Failed to capture story: ${name}`); log.error(e); // ignore story snapshots.shift(); } else { throw e; } } } // will stop once snapshots are done processing yield* percy.yield.stop(); } catch (error) { // force stop and re-throw await percy.stop(true); throw error; } finally { await callback(); } }