Skip to content
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
10 changes: 8 additions & 2 deletions packages/sdk-utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,7 +52,8 @@ export {
clampIframeDepth,
waitForReadyScript,
getReadinessConfig,
isReadinessDisabled
isReadinessDisabled,
runReadinessGate
};

// export the namespace by default
Expand Down
59 changes: 55 additions & 4 deletions packages/sdk-utils/src/serialize-dom.js
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
Shivanshu-07 marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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;
126 changes: 126 additions & 0 deletions packages/sdk-utils/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand All @@ -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);
});
});
});
Loading