@percy/storybook
Version:
Storybook addons for visual testing with Percy
428 lines (392 loc) • 16.2 kB
JavaScript
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, getWidthsForDomCapture, isResponsiveSnapshotCaptureEnabled, captureSerializedDOM, captureResponsiveDOM, hasRules, getDocCaptureFlagsWithRules, generateDocRuleOptions, isDocAutodoc } from './utils.js';
// Main capture function
export async function captureDOM(page, options, percy, log, story) {
const responsiveSnapshotCapture = isResponsiveSnapshotCaptureEnabled(options, percy.config);
if (responsiveSnapshotCapture) {
log.debug('captureDOM: Using responsive snapshot capture', {
options
});
return await captureResponsiveDOM(page, options, percy, log, story);
}
log.debug('captureDOM: Using single snapshot capture');
const eligibleWidths = {
config: percy.config.snapshot?.widths || []
};
const widths = getWidthsForDomCapture(options.widths, eligibleWidths);
return await captureSerializedDOM(page, {
...options,
widths
}, log);
}
// 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, ':');
}
// Priority: rule-level capture > .percy.yml > env var
function mapDocSnapshots(docs, config = {}, conf, invalid, log, globalDocSettings) {
const {
captureDocs,
captureAutodocs
} = globalDocSettings;
const mdxRules = config.docs?.mdx?.rules;
const autodocsRules = config.docs?.autodocs?.rules;
const hasMdxRules = hasRules(mdxRules);
const hasAutodocsRules = hasRules(autodocsRules);
const getDocTypeConfig = isAutodoc => isAutodoc ? {
captureAll: captureAutodocs,
rules: autodocsRules,
hasTypeRules: hasAutodocsRules
} : {
captureAll: captureDocs,
rules: mdxRules,
hasTypeRules: hasMdxRules
};
return docs.reduce((all, doc) => {
const isAutodoc = isDocAutodoc(doc);
const {
captureAll,
rules,
hasTypeRules
} = getDocTypeConfig(isAutodoc);
const ruleOptions = generateDocRuleOptions(doc, rules, hasTypeRules, captureAll, log);
if (!ruleOptions) return all;
const {
id,
name,
type,
...docPercy
} = doc;
const {
match,
capture,
...storyParams
} = ruleOptions || {};
const docStoryConfig = {
id,
name,
type: type || 'docs',
...docPercy,
...storyParams
};
let {
additionalSnapshots = [],
...options
} = getSnapshotConfig(docStoryConfig, conf, invalid);
return all.concat(options, processAdditionalSnapshots(additionalSnapshots, options, doc.name));
}, []);
}
function processAdditionalSnapshots(additionalSnapshots, baseOptions, storyName, skipCallback = null) {
return (additionalSnapshots || []).reduce((add, {
prefix = '',
suffix = '',
...snap
}) => {
const snapshot = PercyConfig.merge([baseOptions, {
name: `${prefix}${storyName}${suffix}`
}, snap]);
return skipCallback?.(snap) ? add : add.concat(snapshot);
}, []);
}
// Map and reduce collected Storybook stories into an array of snapshot options
function mapStorybookSnapshots(stories, {
previewUrl,
flags,
config,
globalDocSettings
}) {
let log = logger('storybook:config');
let invalid = new Map(stories.invalid);
let conf = encodeStorybookConfig(config, invalid);
// Extract snapshot options only, exclude doc config metadata
const {
captureDocs,
captureAutodocs,
docs,
...confOptions
} = conf;
// Separate stories from docs: extract() may return docs without type='docs', so check id suffix
const storyEntries = stories.data.filter(s => s.type !== 'docs' && !String(s.id || '').endsWith('--docs'));
const docEntries = stories.data.filter(s => s.type === 'docs' || String(s.id || '').endsWith('--docs'));
let snapshots = storyEntries.reduce((all, story) => {
if (shouldSkipStory(story.name, story, config)) {
log.debug(`Skipping story: ${story.name}`);
return all;
}
let {
additionalSnapshots = [],
...options
} = getSnapshotConfig(story, confOptions, invalid);
return all.concat(options, processAdditionalSnapshots(additionalSnapshots, options, story.name, snap => shouldSkipStory(story.name, snap)));
}, []);
const docSnapshots = mapDocSnapshots(docEntries, config, confOptions, invalid, log, globalDocSettings);
snapshots = snapshots.concat(docSnapshots);
// 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
});
});
}
// Helper function to check if a story has state that could contaminate the page
function hasContaminatingState(story) {
return story.globals && Object.keys(story.globals).length > 0 || story.queryParams && Object.keys(story.queryParams).length > 0;
}
// Helper function to determine if a fresh page is needed
function needsFreshPage(previousStory) {
// Only need fresh page if previous story had contaminating state
// The current story will be loaded correctly regardless of globals/queryParams
return previousStory && (previousStory.type === 'docs' || hasContaminatingState(previousStory));
}
// Process a single story and capture its DOM
async function* processStory(page, story, previewResource, percy, flags, log) {
// Extract story details
let {
id,
args,
globals,
queryParams,
...options
} = story;
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
});
options.domSnapshot = await captureDOM(page, options, percy, log, story);
}
// validate without logging to prune all other options
PercyConfig.validate(options, '/snapshot/dom');
return options;
}
// 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');
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();
const storybookConfig = percy.config.storybook;
const {
isDocDiscoveryEnabled,
isAutodocDiscoveryEnabled,
globalDocSettings
} = getDocCaptureFlagsWithRules(storybookConfig);
let [environmentInfo, stories] = yield* yieldAll([withPage(percy, aboutUrl, p => p.eval(evalStorybookEnvironmentInfo), undefined, {
from: 'about url'
}), withPage(percy, previewUrl, p => p.eval(evalStorybookStorySnapshots, {
docCapture: isDocDiscoveryEnabled,
autodocCapture: isAutodocDiscoveryEnabled
}), undefined, {
from: 'preview url'
})]);
// map stories to snapshot options
let snapshots = mapStorybookSnapshots(stories, {
config: storybookConfig,
previewUrl,
flags,
globalDocSettings
});
// set storybook environment info
percy.client.addEnvironmentInfo(environmentInfo);
// Track previous story state to determine when fresh pages are needed
let previousStory = null;
// Main snapshot processing loop
while (snapshots.length) {
let initialSnapshotsCount = snapshots.length;
let currentStory = snapshots[0];
// Check if we need a fresh page (only when previous story had contaminating state)
let needsNewPage = needsFreshPage(previousStory);
if (needsNewPage) {
log.debug(`Fresh page needed for story "${currentStory.name}" - previous story had contaminating state`);
}
try {
// Use a single page for as many stories as possible until a context error occurs
yield* withPage(percy, `${previewUrl}?id=${snapshots[0].id}&viewMode=story`, async function* (page) {
// Process snapshots one by one with the current page
while (snapshots.length) {
try {
let currentStory = snapshots[0];
// If we need a fresh page for state reset, break out to create a new page
if (needsNewPage && snapshots.length < initialSnapshotsCount) {
log.debug(`Breaking to create fresh page for story: ${currentStory.name}`);
break; // Break out of the inner loop to create a new page
}
// Process the story and capture its DOM
const options = yield* processStory(page, currentStory, previewResource, percy, flags, log);
// Take the snapshot
percy.snapshot(options);
// Update previous story tracking
previousStory = currentStory;
// Remove processed story from queue
snapshots.shift();
// Check if next story needs fresh page (only if current story has contaminating state)
if (snapshots.length > 0) {
needsNewPage = needsFreshPage(currentStory);
}
} catch (storyError) {
// Handle execution context destruction errors specially
if (storyError.isExecutionContextDestroyed) {
log.warn(`Execution context was destroyed while processing story: ${snapshots[0].name}`);
}
// Need to create a new page - break out of the inner loop
// but don't remove the story from the queue so it can be retried
throw storyError;
}
}
}, undefined, {
snapshotName: snapshots[0].name
});
} catch (pageError) {
// Check if the error is an execution context destruction
if (pageError.isExecutionContextDestroyed) {
log.debug(`Execution context error caught for: ${snapshots[0]?.name}, will create new page`);
// Don't skip the story - we'll retry with a new page in the next iteration
} else if (process.env.PERCY_SKIP_STORY_ON_ERROR === 'true') {
let {
name
} = snapshots[0];
log.error(`Failed to capture story: ${name}`);
log.error(pageError);
// Skip this story
snapshots.shift();
} else {
// Propagate other errors
throw pageError;
}
}
// Safety check to prevent infinite loop - if we've processed no stories in this iteration
// and we're not skipping on error, something is wrong
if (initialSnapshotsCount === snapshots.length && !process.env.PERCY_SKIP_STORY_ON_ERROR) {
log.error('No stories processed in this iteration, breaking loop to prevent infinite run');
break;
}
}
// 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();
}
}