diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 1659886d1..7fba38706 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -10,6 +10,7 @@ import postBuildEvents from './post-build-event.js'; import flushSnapshots from './flush-snapshots.js'; import captureAutomateScreenshot from './post-screenshot.js'; import getResponsiveWidths from './get-responsive-widths.js'; +import mergeSnapshotOptions from './merge-snapshot-options.js'; import { waitForReadyScript, getReadinessConfig, @@ -47,6 +48,7 @@ export { captureAutomateScreenshot, postBuildEvents, getResponsiveWidths, + mergeSnapshotOptions, DEFAULT_MAX_IFRAME_DEPTH, HARD_MAX_IFRAME_DEPTH, clampIframeDepth, diff --git a/packages/sdk-utils/src/merge-snapshot-options.js b/packages/sdk-utils/src/merge-snapshot-options.js new file mode 100644 index 000000000..d4868d798 --- /dev/null +++ b/packages/sdk-utils/src/merge-snapshot-options.js @@ -0,0 +1,34 @@ +import percy from './percy-info.js'; + +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +// Recursively merge `override` onto `base`. Plain (non-array) objects are merged +// key-by-key so overriding one nested key keeps the base's sibling keys; arrays, +// scalars, null and functions from `override` replace the base value wholesale. +function deepMerge(base, override) { + const result = { ...base }; + for (const key of Object.keys(override)) { + const baseVal = base[key]; + const overrideVal = override[key]; + result[key] = isPlainObject(baseVal) && isPlainObject(overrideVal) + ? deepMerge(baseVal, overrideVal) + : overrideVal; + } + return result; +} + +// Merges .percy.yml config snapshot options with per-snapshot options. +// Per-snapshot options take priority over config options. +// +// The merge is deep: nested objects (e.g. `discovery`) are merged recursively so +// a per-snapshot override of one nested key does not drop the config's sibling +// nested keys. At the leaves, per-snapshot values win; arrays are replaced, not +// concatenated. +export function mergeSnapshotOptions(options = {}) { + const configOptions = percy?.config?.snapshot || {}; + return deepMerge(configOptions, options); +} + +export default mergeSnapshotOptions; diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 3bf19be3a..5362f2592 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -936,4 +936,73 @@ describe('SDK Utils', () => { expect(result).toBe(null); }); }); + + describe('mergeSnapshotOptions(options)', () => { + let { mergeSnapshotOptions } = utils; + + beforeEach(async () => { + await helpers.setupTest(); + await utils.isPercyEnabled(); + }); + + it('merges config snapshot options with per-snapshot options', () => { + const result = mergeSnapshotOptions({ enableJavaScript: true }); + expect(result.enableJavaScript).toBe(true); + expect(result.widths).toEqual([375, 1280]); + }); + + it('gives per-snapshot options priority over config', () => { + const result = mergeSnapshotOptions({ widths: [768] }); + expect(result.widths).toEqual([768]); + }); + + it('returns config options when no per-snapshot options are provided', () => { + const result = mergeSnapshotOptions(); + expect(result.widths).toEqual([375, 1280]); + }); + + it('returns empty object when config.snapshot is undefined and no options given', () => { + const savedConfig = utils.percy.config; + utils.percy.config = { ...savedConfig, snapshot: undefined }; + + const result = mergeSnapshotOptions(); + expect(result).toEqual({}); + + utils.percy.config = savedConfig; + }); + + it('returns only per-snapshot options when config.snapshot is undefined', () => { + const savedConfig = utils.percy.config; + utils.percy.config = { ...savedConfig, snapshot: undefined }; + + const result = mergeSnapshotOptions({ enableJavaScript: true }); + expect(result).toEqual({ enableJavaScript: true }); + + utils.percy.config = savedConfig; + }); + + it('deep-merges nested objects, keeping config sibling keys not overridden', () => { + const savedConfig = utils.percy.config; + utils.percy.config = { + ...savedConfig, + snapshot: { discovery: { networkIdleTimeout: 50, disableCache: false } } + }; + + const result = mergeSnapshotOptions({ discovery: { disableCache: true } }); + // per-snapshot wins on the overridden nested key, config sibling key survives + expect(result.discovery).toEqual({ networkIdleTimeout: 50, disableCache: true }); + + utils.percy.config = savedConfig; + }); + + it('replaces (does not concatenate) arrays from per-snapshot options', () => { + const savedConfig = utils.percy.config; + utils.percy.config = { ...savedConfig, snapshot: { widths: [375, 1280] } }; + + const result = mergeSnapshotOptions({ widths: [768] }); + expect(result.widths).toEqual([768]); + + utils.percy.config = savedConfig; + }); + }); });