Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add retry in withPage function #993

Merged
merged 7 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/snapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
105 changes: 60 additions & 45 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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) });
Expand Down
55 changes: 55 additions & 0 deletions test/storybook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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', [
`<script>__STORYBOOK_PREVIEW__ = { async extract() { return ${JSON.stringify([
{ id: '1', kind: 'foo', name: 'bar' }
Expand Down Expand Up @@ -183,10 +185,12 @@ describe('percy storybook', () => {
describe('with PERCY_SKIP_STORY_ON_ERROR set to true', () => {
beforeAll(() => {
process.env.PERCY_SKIP_STORY_ON_ERROR = true;
process.env.PERCY_RETRY_STORY_ON_ERROR = false;
});

afterAll(() => {
delete process.env.PERCY_SKIP_STORY_ON_ERROR;
delete process.env.PERCY_RETRY_STORY_ON_ERROR;
});

it('skips the story and logs the error but does not break build', async () => {
Expand Down Expand Up @@ -226,6 +230,56 @@ describe('percy storybook', () => {
});
});

describe('with PERCY_RETRY_STORY_ON_ERROR set to true', () => {
beforeAll(() => {
process.env.PERCY_SKIP_STORY_ON_ERROR = true;
process.env.PERCY_RETRY_STORY_ON_ERROR = true;
});

afterAll(() => {
delete process.env.PERCY_SKIP_STORY_ON_ERROR;
delete process.env.PERCY_RETRY_STORY_ON_ERROR;
});

it('retries story logs the error but does not break build if skip is enabled', async () => {
server.reply('/iframe.html', () => [200, 'text/html', [
`<script>__STORYBOOK_PREVIEW__ = { async extract() { return ${JSON.stringify([
{ id: '1', kind: 'foo', name: 'bar' }
])} }, ${
'channel: { emit() {}, on: (a, c) => a === "storyErrored" && c(new Error("Story Error")) }'
} }</script>`,
`<script>__STORYBOOK_STORY_STORE__ = { raw: () => ${JSON.stringify([
{ id: '1', kind: 'foo', name: 'bar' }
])} }</script>`
].join('')]);

server.reply('/iframe.html?id=1&viewMode=story', () => [200, 'text/html', [
`<script>__STORYBOOK_PREVIEW__ = { async extract() { return ${JSON.stringify([
{ id: '1', kind: 'foo', name: 'bar' }
])} }, ${
'channel: { emit() {}, on: (a, c) => a === "storyErrored" && c(new Error("Story Error")) }'
} }</script>`,
`<script>__STORYBOOK_STORY_STORE__ = { raw: () => ${JSON.stringify([
{ id: '1', kind: 'foo', name: 'bar' }
])} }</script>`
].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' },
Expand Down Expand Up @@ -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');

Expand Down
Loading