diff --git a/packages/sdk-utils/src/index.js b/packages/sdk-utils/src/index.js index 371046c6b..1659886d1 100644 --- a/packages/sdk-utils/src/index.js +++ b/packages/sdk-utils/src/index.js @@ -10,7 +10,12 @@ 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 { waitForReadyScript, getReadinessConfig, isReadinessDisabled } from './serialize-dom.js'; +import { + waitForReadyScript, + getReadinessConfig, + isReadinessDisabled, + runReadinessGate +} from './serialize-dom.js'; // Iframe depth constants shared with @percy/dom's serialize-frames. Kept // here so external Percy SDKs (Capybara, Cypress, Playwright, etc.) can @@ -47,7 +52,8 @@ export { clampIframeDepth, waitForReadyScript, getReadinessConfig, - isReadinessDisabled + isReadinessDisabled, + runReadinessGate }; // export the namespace by default diff --git a/packages/sdk-utils/src/serialize-dom.js b/packages/sdk-utils/src/serialize-dom.js index 0d5e8caef..53abfe93e 100644 --- a/packages/sdk-utils/src/serialize-dom.js +++ b/packages/sdk-utils/src/serialize-dom.js @@ -1,12 +1,20 @@ import percy from './percy-info.js'; // Returns the readiness config for a snapshot. -// Priority: per-snapshot options > global percy.config > empty object (triggers balanced default). +// Shallow-merge of global .percy.yml config with per-snapshot overrides: +// per-snapshot keys win, unspecified keys are inherited from the global config. // SDKs obtain percy.config via the healthcheck endpoint in isPercyEnabled(). +// +// Why shallow-merge instead of `||`: +// - `options.readiness = {}` would otherwise wipe the global config entirely. +// - A partial per-snapshot override like `{ stabilityWindowMs: 500 }` would +// drop a global `preset: disabled` kill switch — silently re-enabling the +// gate for a snapshot the user thought was opted out. export function getReadinessConfig(snapshotOptions = {}) { - return snapshotOptions?.readiness || - percy.config?.snapshot?.readiness || - {}; + return { + ...(percy.config?.snapshot?.readiness || {}), + ...(snapshotOptions?.readiness || {}) + }; } // Returns true if readiness should be skipped for this snapshot. @@ -66,4 +74,47 @@ export function waitForReadyScript(readinessConfig = {}, { callback = false } = `; } +// Runs the readiness gate end-to-end so every JS SDK collapses to a single +// call. The SDK's only responsibility is to provide an `evalScript` callback +// that ships the script string to the browser via its driver's evaluator +// (page.evaluate, driver.executeAsyncScript, b.executeAsync, etc.). +// +// Centralised here: +// - isReadinessDisabled kill-switch check +// - getReadinessConfig shallow-merge of global + per-snapshot config +// - waitForReadyScript script generation (callback or promise mode) +// - try/catch with debug logging — serialize is never blocked +// +// Returns: the diagnostics object from PercyDOM.waitForReady, or null +// when readiness is disabled / unavailable / failed. The caller attaches +// the non-null result to domSnapshot.readiness_diagnostics. +// +// Usage: +// // Puppeteer/Playwright (promise-mode): +// const diag = await utils.runReadinessGate( +// (script) => page.evaluate(script), +// options, +// { log } +// ); +// +// // Selenium-js / WebdriverIO / Nightwatch (callback-mode): +// const diag = await utils.runReadinessGate( +// (script) => driver.executeAsyncScript(script), +// options, +// { callback: true, log } +// ); +export async function runReadinessGate(evalScript, snapshotOptions = {}, { callback = false, log } = {}) { + if (isReadinessDisabled(snapshotOptions)) return null; + const config = getReadinessConfig(snapshotOptions); + const script = waitForReadyScript(config, { callback }); + try { + return await evalScript(script); + } catch (err) { + if (log && typeof log.debug === 'function') { + log.debug(`waitForReady failed, proceeding to serialize: ${err?.message || err}`); + } + return null; + } +} + export default waitForReadyScript; diff --git a/packages/sdk-utils/test/index.test.js b/packages/sdk-utils/test/index.test.js index 7fb4d64fe..20bd7884a 100644 --- a/packages/sdk-utils/test/index.test.js +++ b/packages/sdk-utils/test/index.test.js @@ -779,6 +779,37 @@ describe('SDK Utils', () => { expect(getReadinessConfig({ readiness: { preset: 'fast' } })).toEqual({ preset: 'fast' }); percy.config = undefined; }); + + it('shallow-merges per-snapshot overrides into global config', () => { + percy.config = { + snapshot: { + readiness: { preset: 'balanced', timeoutMs: 8000, stabilityWindowMs: 200 } + } + }; + // Partial override — `preset` and `timeoutMs` are inherited; `stabilityWindowMs` wins. + expect(getReadinessConfig({ readiness: { stabilityWindowMs: 500 } })).toEqual({ + preset: 'balanced', + timeoutMs: 8000, + stabilityWindowMs: 500 + }); + percy.config = undefined; + }); + + it('inherits global preset: disabled when per-snapshot omits preset', () => { + percy.config = { snapshot: { readiness: { preset: 'disabled' } } }; + // A partial override must NOT silently re-enable the kill switch. + expect(getReadinessConfig({ readiness: { stabilityWindowMs: 500 } })).toEqual({ + preset: 'disabled', + stabilityWindowMs: 500 + }); + percy.config = undefined; + }); + + it('empty per-snapshot readiness does not wipe the global config', () => { + percy.config = { snapshot: { readiness: { preset: 'strict' } } }; + expect(getReadinessConfig({ readiness: {} })).toEqual({ preset: 'strict' }); + percy.config = undefined; + }); }); describe('isReadinessDisabled(snapshotOptions)', () => { @@ -805,4 +836,99 @@ describe('SDK Utils', () => { percy.config = undefined; }); }); + + describe('runReadinessGate(evalScript, snapshotOptions[, opts])', () => { + let { runReadinessGate, percy } = utils; + + afterEach(() => { percy.config = undefined; }); + + it('returns null and skips evalScript when preset is disabled', async () => { + let called = false; + let result = await runReadinessGate( + () => { called = true; return { passed: true }; }, + { readiness: { preset: 'disabled' } } + ); + expect(result).toBe(null); + expect(called).toBe(false); + }); + + it('returns null and skips evalScript when global preset is disabled', async () => { + percy.config = { snapshot: { readiness: { preset: 'disabled' } } }; + let called = false; + let result = await runReadinessGate(() => { called = true; }); + expect(result).toBe(null); + expect(called).toBe(false); + }); + + it('passes the merged shallow-merge config script to evalScript and returns its result', async () => { + percy.config = { snapshot: { readiness: { preset: 'balanced', timeoutMs: 8000, stabilityWindowMs: 200 } } }; + let captured; + let diagnostics = { passed: true, timed_out: false, preset: 'balanced' }; + let result = await runReadinessGate( + (script) => { captured = script; return Promise.resolve(diagnostics); }, + { readiness: { stabilityWindowMs: 500 } } + ); + expect(result).toEqual(diagnostics); + // Shallow-merged: per-snapshot stabilityWindowMs wins, global preset+timeoutMs inherited. + expect(captured).toContain('"preset":"balanced"'); + expect(captured).toContain('"timeoutMs":8000'); + expect(captured).toContain('"stabilityWindowMs":500'); + }); + + it('emits callback-mode script when opts.callback is true', async () => { + let captured; + await runReadinessGate( + (script) => { captured = script; return null; }, + {}, + { callback: true } + ); + expect(captured).toContain('arguments[arguments.length - 1]'); + expect(captured).toContain('PercyDOM.waitForReady'); + }); + + it('emits promise-mode script by default', async () => { + let captured; + await runReadinessGate( + (script) => { captured = script; return null; }, + {} + ); + expect(captured).toContain('return PercyDOM.waitForReady'); + expect(captured).not.toContain('arguments[arguments.length - 1]'); + }); + + it('returns null and never throws when evalScript rejects (with Error)', async () => { + let logged; + let result = await runReadinessGate( + () => Promise.reject(new Error('readiness boom')), + {}, + { log: { debug: (m) => { logged = m; } } } + ); + expect(result).toBe(null); + expect(logged).toContain('readiness boom'); + }); + + it('returns null and never throws when evalScript rejects (non-Error)', async () => { + let logged; + // Exercises the `err?.message || err` second branch where the + // rejection value has no `.message`. + let result = await runReadinessGate( + // eslint-disable-next-line prefer-promise-reject-errors + () => Promise.reject('plain-string-rejection'), + {}, + { log: { debug: (m) => { logged = m; } } } + ); + expect(result).toBe(null); + expect(logged).toContain('plain-string-rejection'); + }); + + it('returns null and never throws when evalScript throws synchronously', async () => { + let result = await runReadinessGate(() => { throw new Error('sync boom'); }, {}); + expect(result).toBe(null); + }); + + it('tolerates absent log (no opts.log)', async () => { + let result = await runReadinessGate(() => Promise.reject(new Error('no log')), {}); + expect(result).toBe(null); + }); + }); });