diff --git a/src/snapshots.js b/src/snapshots.js
index fd4fc1ef..35f10a73 100644
--- a/src/snapshots.js
+++ b/src/snapshots.js
@@ -232,7 +232,7 @@ export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags
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];
diff --git a/src/utils.js b/src/utils.js
index fabed9b2..3a4e221a 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,4 +1,5 @@
import { request, createRootResource, yieldTo } from '@percy/cli-command/utils';
+import { logger } from '@percy/cli-command';
import spawn from 'cross-spawn';
// check storybook version
@@ -138,52 +139,66 @@ export function decodeStoryArgs(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) {
- // 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
- });
+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);
+ }
- // 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';
+ if (!(enableRetry === 'true') || attempt === retries) {
+ throw error;
+ }
+ log.warn(`Retrying Story: ${args.snapshotName}`);
}
-
- /* 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();
}
}
@@ -271,8 +286,8 @@ export function evalSetCurrentStory({ waitFor }, story) {
let { id, queryParams, globals, args } = story;
// emit a series of events to render the desired story
- channel.emit('updateGlobals', { globals: {} });
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) });
diff --git a/test/storybook.test.js b/test/storybook.test.js
index 7530a97f..4a42d43a 100644
--- a/test/storybook.test.js
+++ b/test/storybook.test.js
@@ -107,6 +107,7 @@ describe('percy storybook', () => {
});
it('errors when the client api is missing', async () => {
+ process.env.PERCY_RETRY_STORY_ON_ERROR = false;
await expectAsync(storybook(['http://localhost:8000'])).toBeRejected();
expect(logger.stderr).toEqual([
@@ -147,6 +148,7 @@ describe('percy storybook', () => {
});
it('errors when the storybook page errors', async () => {
+ process.env.PERCY_RETRY_STORY_ON_ERROR = false;
server.reply('/iframe.html', () => [200, 'text/html', [
``,
+ ``
+ ].join('')]);
+
+ server.reply('/iframe.html?id=1&viewMode=story', () => [200, 'text/html', [
+ ``,
+ ``
+ ].join('')]);
+
+ // does not reject
+ await storybook(['http://localhost:8000']);
+
+ // contains logs of story error
+ expect(logger.stderr).toEqual([
+ '[percy] Retrying Story: foo: bar',
+ '[percy] Retrying Story: foo: bar',
+ '[percy] Failed to capture story: foo: bar',
+ // error logs contain the client stack trace
+ jasmine.stringMatching(/^\[percy\] Error: Story Error\n.*\/iframe\.html.*$/s),
+ // does not create a build if all stories failed [ 1 in this case ]
+ '[percy] Build not created'
+ ]);
+ });
+ });
+
it('uses the preview dom when javascript is enabled', async () => {
const FAKE_PREVIEW_V8 = `{ async extract() { return ${JSON.stringify([
{ id: '1', kind: 'foo', name: 'bar' },
@@ -514,6 +568,7 @@ describe('percy storybook', () => {
});
it('handles page crashes while taking snapshots', async () => {
+ process.env.PERCY_RETRY_STORY_ON_ERROR = false;
// eslint-disable-next-line import/no-extraneous-dependencies
let { Percy } = await import('@percy/core');