Skip to content

Commit ab3bdfd

Browse files
Handling responsiveSnapshotCapture from Build and Snapshot Level (#1169)
* responsiveSnapshotCapture implementation * eslint fixes * enableJS condition fix * enableJS condition fix * console statements changed to log * unit test fix * fixes * mobile browser widths inclusion * lint fix * added unit tests * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * unit tests fixes * lint fix * Update src/utils.js Co-authored-by: Copilot <[email protected]> * added comment * removing redundant code and adding unit tests * fixing unit tests * import fix * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * unit tests * adding debug logs in catureResponsiveDOM * Update src/utils.js Co-authored-by: Copilot <[email protected]> * checkresize function refactor * adding test for changeviewport function and changing name of getwidths function * adding test for changeviewport function and changing name of getwidths function * test fixes * test fixes * test fixes * test fixes * test fixes --------- Co-authored-by: Copilot <[email protected]>
1 parent 833d07b commit ab3bdfd

File tree

6 files changed

+765
-4
lines changed

6 files changed

+765
-4
lines changed

src/common.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ export const flags = [{
1212
type: 'pattern',
1313
multiple: true,
1414
short: 'e'
15+
}, {
16+
name: 'responsive-snapshot-capture',
17+
description: 'Enable responsive DOM capture for multiple viewport widths',
18+
percyrc: 'snapshot.responsiveSnapshotCapture',
19+
type: 'boolean',
20+
default: false
21+
}, {
22+
name: 'widths',
23+
description: 'Comma-separated list of viewport widths for responsive capture (e.g. 375,768,1280)',
24+
percyrc: 'snapshot.widths',
25+
type: 'array',
26+
parse: (value) => value.split(',').map(w => parseInt(w.trim(), 10)).filter(w => !isNaN(w))
1527
}, {
1628
name: 'shard-count',
1729
description: 'Number of shards to split snapshots into',

src/config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ export const storybookSchema = {
4242
queryParams: {
4343
type: 'object',
4444
normalize: false
45+
},
46+
responsiveSnapshotCapture: {
47+
type: 'boolean',
48+
default: false
49+
},
50+
widths: {
51+
type: 'array',
52+
items: {
53+
type: 'integer',
54+
minimum: 1
55+
},
56+
default: [375, 1280]
4557
}
4658
}
4759
},

src/snapshots.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,52 @@ import {
88
evalSetCurrentStory,
99
validateStoryArgs,
1010
encodeStoryArgs,
11-
withPage
11+
withPage,
12+
getWidthsForDomCapture,
13+
isResponsiveSnapshotCaptureEnabled,
14+
captureSerializedDOM,
15+
captureResponsiveDOM
1216
} from './utils.js';
1317

18+
// Main capture function
19+
export async function captureDOM(page, options, percy, log) {
20+
const responsiveSnapshotCapture = isResponsiveSnapshotCaptureEnabled(
21+
options,
22+
percy.config
23+
);
24+
25+
let widths;
26+
if (responsiveSnapshotCapture) {
27+
const deviceDetails = await percy.client.getDeviceDetails(percy.build?.id);
28+
const eligibleWidths = {
29+
mobile: Array.isArray(deviceDetails) ? deviceDetails.map(d => d.width).filter(Boolean) : [],
30+
config: percy.config.snapshot?.widths || []
31+
};
32+
widths = getWidthsForDomCapture(
33+
options.widths,
34+
eligibleWidths
35+
);
36+
37+
const responsiveOptions = { ...options, responsiveSnapshotCapture: true, widths };
38+
log.debug('captureDOM: Using responsive snapshot capture', { responsiveOptions });
39+
return await captureResponsiveDOM(page, responsiveOptions, percy, log);
40+
} else {
41+
const eligibleWidths = {
42+
config: percy.config.snapshot?.widths || []
43+
};
44+
widths = getWidthsForDomCapture(
45+
options.widths,
46+
eligibleWidths
47+
);
48+
49+
const singleDOMOptions = { ...options, widths };
50+
log.debug('captureDOM: Using single snapshot capture', {
51+
singleDOMOptions
52+
});
53+
return await captureSerializedDOM(page, singleDOMOptions, log);
54+
}
55+
}
56+
1457
// Returns true or false if the provided story should be skipped by matching against include and
1558
// exclude filter options. If any global filters are provided, they will override story filters.
1659
function shouldSkipStory(name, options, config) {
@@ -217,9 +260,7 @@ export async function* takeStorybookSnapshots(percy, callback, { baseUrl, flags
217260
log.debug(`Loading story: ${options.name}`);
218261
// when not dry-running and javascript is not enabled, capture the story dom
219262
yield page.eval(evalSetCurrentStory, { id, args, globals, queryParams });
220-
/* istanbul ignore next: tested, but coverage is stripped */
221-
let { dom, domSnapshot = dom } = yield page.snapshot(options);
222-
options.domSnapshot = domSnapshot;
263+
options.domSnapshot = await captureDOM(page, options, percy, log);
223264
}
224265

225266
// validate without logging to prune all other options

src/utils.js

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,175 @@ export function evalSetCurrentStory({ waitFor }, story) {
417417
}
418418
});
419419
}
420+
421+
// Utility functions for responsive snapshot capture
422+
423+
// Process widths for responsive DOM capture with proper hierarchy
424+
export function getWidthsForDomCapture(userPassedWidths, eligibleWidths) {
425+
let allWidths = [];
426+
427+
if (eligibleWidths?.mobile?.length > 0) {
428+
allWidths = allWidths.concat(eligibleWidths?.mobile);
429+
}
430+
if (userPassedWidths && userPassedWidths.length) {
431+
allWidths = allWidths.concat(userPassedWidths);
432+
} else {
433+
allWidths = allWidths.concat(eligibleWidths.config);
434+
}
435+
436+
// Remove duplicates
437+
return [...new Set(allWidths)].filter(e => e);
438+
}
439+
440+
// Check if responsive snapshot capture is enabled with proper hierarchy
441+
export function isResponsiveSnapshotCaptureEnabled(options, config) {
442+
if (options && 'responsiveSnapshotCapture' in options) {
443+
const value = !!(options.responsiveSnapshotCapture);
444+
return value;
445+
}
446+
447+
if (config?.snapshot && 'responsiveSnapshotCapture' in config.snapshot) {
448+
const value = !!(config.snapshot.responsiveSnapshotCapture);
449+
return value;
450+
}
451+
452+
return false;
453+
}
454+
455+
async function changeViewportDimensionAndWait(page, width, height, resizeCount, log) {
456+
try {
457+
// Use Percy's CDP-based resize method
458+
await page.resize({
459+
width,
460+
height,
461+
deviceScaleFactor: 1,
462+
mobile: false
463+
});
464+
} catch (e) {
465+
log.debug('Resizing using CDP failed, falling back to page eval for width', width, e);
466+
// Fallback to JavaScript execution
467+
try {
468+
await page.eval(({ width, height }) => {
469+
window.resizeTo(width, height);
470+
// Trigger resize events and force layout
471+
window.dispatchEvent(new Event('resize'));
472+
document.body.offsetHeight;
473+
}, { width, height });
474+
} catch (fallbackError) {
475+
log.error('Fallback resize using page.eval failed', fallbackError);
476+
throw new Error(`Failed to resize viewport using both CDP and page.eval: ${fallbackError.message}`);
477+
}
478+
}
479+
480+
try {
481+
// Wait for resize to complete
482+
await page.eval(({ resizeCount }) => {
483+
return new Promise((resolve, reject) => {
484+
const timeout = setTimeout(() => reject(new Error('Timeout')), 1000);
485+
const checkResize = () => {
486+
if (window.resizeCount === resizeCount) {
487+
clearTimeout(timeout);
488+
resolve();
489+
} else {
490+
setTimeout(checkResize, 50);
491+
}
492+
};
493+
checkResize();
494+
});
495+
}, { resizeCount });
496+
} catch (e) {
497+
log.debug('Timed out waiting for window resize event for width', width, e);
498+
}
499+
}
500+
501+
export async function captureSerializedDOM(page, options, log) {
502+
try {
503+
let { dom, domSnapshot = dom } = await page.snapshot(options);
504+
return domSnapshot;
505+
} catch (error) {
506+
log.error('Error in captureSerializedDOM:', error);
507+
throw new Error(`Failed to capture DOM snapshot: ${error.message}`);
508+
}
509+
}
510+
511+
// Capture responsive DOM snapshots across different widths
512+
export async function captureResponsiveDOM(page, options, percy, log) {
513+
const domSnapshots = [];
514+
let currentWidth, currentHeight;
515+
516+
// Get current viewport size
517+
const viewportSize = await page.eval(() => ({
518+
width: window.innerWidth,
519+
height: window.innerHeight
520+
}));
521+
522+
currentWidth = viewportSize.width;
523+
currentHeight = viewportSize.height;
524+
525+
let lastWindowWidth = currentWidth;
526+
let resizeCount = 0;
527+
528+
// Setup the resizeCount listener
529+
await page.eval(() => {
530+
if (typeof window.PercyDOM !== 'undefined' && window.PercyDOM.waitForResize) {
531+
window.PercyDOM.waitForResize();
532+
}
533+
window.resizeCount = window.resizeCount || 0;
534+
});
535+
536+
// PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT: (number) If set, overrides the minimum height for the viewport during capture.
537+
let height = currentHeight;
538+
if (process.env.PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) {
539+
height = await page.eval((configMinHeight) => {
540+
return window.outerHeight - window.innerHeight + configMinHeight;
541+
}, percy?.config?.snapshot?.minHeight || 600);
542+
log.debug(`Using custom minHeight for responsive capture: ${height}`);
543+
}
544+
545+
for (let width of options?.widths) {
546+
log.debug(`Capturing snapshot at width: ${width}`);
547+
if (lastWindowWidth !== width) {
548+
resizeCount++;
549+
log.debug(`Resizing viewport to width=${width}, height=${height}, resizeCount=${resizeCount}`);
550+
await page.eval(({ resizeCount }) => {
551+
window.resizeCount = resizeCount;
552+
}, { resizeCount });
553+
await changeViewportDimensionAndWait(page, width, height, resizeCount, log);
554+
lastWindowWidth = width;
555+
}
556+
557+
// PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE: (any value) If set, reloads the page before each snapshot width.
558+
if (process.env.PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) {
559+
const currentUrl = await page.eval(() => window.location.href);
560+
log.debug('Reloading page for responsive capture');
561+
await page.goto(currentUrl, { forceReload: true });
562+
563+
// Re-inject PercyDOM if needed
564+
await page.insertPercyDom();
565+
}
566+
567+
// PERCY_RESPONSIVE_CAPTURE_SLEEP_TIME: (number, seconds) If set, waits this many seconds before capturing each snapshot.
568+
if (process.env.PERCY_RESPONSIVE_CAPTURE_SLEEP_TIME) {
569+
let sleepTime = parseInt(process.env.PERCY_RESPONSIVE_CAPTURE_SLEEP_TIME, 10);
570+
if (isNaN(sleepTime) || sleepTime < 0) {
571+
log.warn(`Invalid value for PERCY_RESPONSIVE_CAPTURE_SLEEP_TIME: "${process.env.PERCY_RESPONSIVE_CAPTURE_SLEEP_TIME}". Using fallback value of 0 seconds.`);
572+
sleepTime = 0;
573+
}
574+
log.debug(`Sleeping for ${sleepTime} seconds before capturing snapshot`);
575+
await new Promise(resolve => setTimeout(resolve, sleepTime * 1000));
576+
}
577+
578+
// Capture DOM snapshot
579+
log.debug(`Taking DOM snapshot at width=${width}`);
580+
let domSnapshot = await captureSerializedDOM(page, options, log);
581+
let snapshotWithWidth = { ...domSnapshot, width };
582+
domSnapshots.push(snapshotWithWidth);
583+
log.debug(`Snapshot captured for width=${width}`);
584+
}
585+
586+
// Reset viewport size back to original dimensions
587+
log.debug('Resetting viewport to original size after responsive capture');
588+
await changeViewportDimensionAndWait(page, currentWidth, currentHeight, resizeCount + 1, log);
589+
log.debug('Responsive DOM capture complete');
590+
return domSnapshots;
591+
}

0 commit comments

Comments
 (0)