Skip to content
Open
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
50 changes: 50 additions & 0 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,48 @@ export const configSchema = {
sync: {
type: 'boolean'
},
readiness: {
type: 'object',
additionalProperties: false,
properties: {
preset: { type: 'string', enum: ['balanced', 'strict', 'fast', 'disabled'] },
stabilityWindowMs: { type: 'integer', minimum: 50, maximum: 30000 },
Comment thread
Shivanshu-07 marked this conversation as resolved.
jsIdleWindowMs: { type: 'integer', minimum: 50, maximum: 30000 },
networkIdleWindowMs: { type: 'integer', minimum: 50, maximum: 10000 },
timeoutMs: { type: 'integer', minimum: 1000, maximum: 60000 },
domStability: { type: 'boolean' },
imageReady: { type: 'boolean' },
fontReady: { type: 'boolean' },
jsIdle: { type: 'boolean' },
readySelectors: {
type: 'array',
items: {
oneOf: [
{ type: 'string' },
{
type: 'object',
additionalProperties: false,
properties: { css: { type: 'string' }, xpath: { type: 'string' } }
}
]
}
},
notPresentSelectors: {
type: 'array',
items: {
oneOf: [
{ type: 'string' },
{
type: 'object',
additionalProperties: false,
properties: { css: { type: 'string' }, xpath: { type: 'string' } }
}
]
}
},
maxTimeoutMs: { type: 'integer', minimum: 1000, maximum: 60000 }
}
},
responsiveSnapshotCapture: {
type: 'boolean',
default: false
Expand Down Expand Up @@ -507,6 +549,7 @@ export const snapshotSchema = {
domTransformation: { $ref: '/config/snapshot#/properties/domTransformation' },
enableLayout: { $ref: '/config/snapshot#/properties/enableLayout' },
sync: { $ref: '/config/snapshot#/properties/sync' },
readiness: { $ref: '/config/snapshot#/properties/readiness' },
responsiveSnapshotCapture: { $ref: '/config/snapshot#/properties/responsiveSnapshotCapture' },
testCase: { $ref: '/config/snapshot#/properties/testCase' },
labels: { $ref: '/config/snapshot#/properties/labels' },
Expand Down Expand Up @@ -701,6 +744,13 @@ export const snapshotSchema = {
type: 'array',
items: { type: 'string' }
},
readiness_diagnostics: {
type: 'object',
normalize: false,
description: 'Diagnostics from readiness checks run before serialization. ' +
'normalize: false preserves the snake_case wire format the SDKs send (timed_out, ' +
'total_duration_ms, etc.) instead of camelCasing inner keys at validate time.'
},
corsIframes: {
type: 'array',
items: {
Expand Down
30 changes: 28 additions & 2 deletions packages/core/src/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,27 @@ export class Page {

await this.insertPercyDom();

// serialize and capture a DOM snapshot
this.log.debug('Serialize DOM', this.meta);
// Run readiness checks before serializing — wait for page stability
let readiness = snapshot.readiness || this.browser?.percy?.config?.snapshot?.readiness;
let readinessDiagnostics;

Comment thread
Shivanshu-07 marked this conversation as resolved.
if (readiness && readiness.preset !== 'disabled') {
Comment thread
Shivanshu-07 marked this conversation as resolved.
this.log.debug('Waiting for readiness', this.meta);
readinessDiagnostics = await this.eval(
/* istanbul ignore next: no instrumenting injected code */
async (_, config) => {
// eslint-disable-next-line no-undef
if (typeof PercyDOM?.waitForReady === 'function') return PercyDOM.waitForReady(config);
},
readiness
).catch(e => {
this.log.debug(`Readiness check failed: ${e}`, this.meta);
});

if (readinessDiagnostics?.timed_out) {
this.log.debug('Readiness timed out, capturing anyway', this.meta);
}
}

let capture = await this.eval(serializeDomCapture, {
enableJavaScript,
Expand All @@ -295,6 +314,13 @@ export class Page {
pseudoClassEnabledElements
});

// Attach readiness diagnostics onto the captured DOM snapshot so the backend/UI can surface
// readiness metrics. Only valid when domSnapshot is the structured object form — the legacy
// string form (HTML only) has no place to embed diagnostics.
if (readinessDiagnostics && capture?.domSnapshot && typeof capture.domSnapshot === 'object') {
capture.domSnapshot.readiness_diagnostics = readinessDiagnostics;
}

return { ...snapshot, ...capture };
}

Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,27 @@ export function validateSnapshotOptions(options) {
log.warn('Encountered snapshot serialization warnings:');
for (let w of domWarnings) log.warn(`- ${w}`);
}

// log readiness diagnostics when present.
// domSnapshot is a union of `string` (legacy SDK payload — HTML only) and `object`
// ({ html, warnings, readiness_diagnostics, ... }). Diagnostics only exist on the object form;
// gate explicitly on typeof so the intent is obvious to readers.
// The schema marks readiness_diagnostics with normalize: false to preserve the snake_case wire
// format. The dual-read fallback below is defensive — it keeps the log working even if a future
// SDK sends camelCase keys, or if a path in PercyConfig.migrate skips the normalize: false hint.
let domSnapshotObj = (migrated.domSnapshot && typeof migrated.domSnapshot === 'object') ? migrated.domSnapshot : null;
let readinessDiag = domSnapshotObj?.readiness_diagnostics ?? domSnapshotObj?.readinessDiagnostics;
if (readinessDiag) {
let timedOut = readinessDiag.timed_out ?? readinessDiag.timedOut;
let durationMs = readinessDiag.total_duration_ms ?? readinessDiag.totalDurationMs;
let presetName = readinessDiag.preset || 'custom';
if (timedOut) {
log.warn(`Readiness timed out after ${durationMs}ms (preset: ${presetName})`);
} else {
log.debug(`Readiness passed in ${durationMs}ms (preset: ${presetName})`);
}
}

// warn on validation errors
let errors = PercyConfig.validate(migrated, schema);
if (errors?.length > 0) {
Expand Down
124 changes: 124 additions & 0 deletions packages/core/test/percy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,130 @@ describe('Percy', () => {
]));
});

it('runs readiness check before serializing when readiness option is set', async () => {
server.reply('/', () => [200, 'text/html', '<p>Hello Percy!</p>']);

await percy.browser.launch();
let page = await percy.browser.page();
await page.goto('http://localhost:8000');

percy.loglevel('debug');

let snapshot = await page.snapshot({
readiness: { preset: 'fast', timeoutMs: 1000 }
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Waiting for readiness/)
]));
expect(snapshot.url).toEqual('http://localhost:8000/');
});

it('skips readiness check when readiness preset is disabled', async () => {
server.reply('/', () => [200, 'text/html', '<p>Hello Percy!</p>']);

await percy.browser.launch();
let page = await percy.browser.page();
await page.goto('http://localhost:8000');

percy.loglevel('debug');

let snapshot = await page.snapshot({
readiness: { preset: 'disabled' }
});

expect(logger.stderr).not.toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Waiting for readiness/)
]));
expect(snapshot.url).toEqual('http://localhost:8000/');
});

it('logs when readiness times out and continues to capture', async () => {
server.reply('/', () => [200, 'text/html', '<p>Hello Percy!</p>']);

await percy.browser.launch();
let page = await percy.browser.page();
await page.goto('http://localhost:8000');

percy.loglevel('debug');

// Force the readiness eval to resolve with a timed_out diagnostics
// payload while letting all other eval calls (PercyDOM injection,
// serialize, etc.) pass through untouched. The readiness eval is the
// only call whose first arg is a config object containing `preset`.
let originalEval = page.eval.bind(page);
spyOn(page, 'eval').and.callFake((fn, ...args) => {
if (args[0] && typeof args[0] === 'object' && 'preset' in args[0]) {
return Promise.resolve({ timed_out: true, total_duration_ms: 1234 });
}
return originalEval(fn, ...args);
});

let snapshot = await page.snapshot({
readiness: { preset: 'balanced' }
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Waiting for readiness/),
jasmine.stringMatching(/Readiness timed out, capturing anyway/)
]));
expect(snapshot.url).toEqual('http://localhost:8000/');
// diagnostics should be attached to the captured DOM snapshot so the
// backend/UI can surface readiness metrics
expect(snapshot.domSnapshot).toEqual(jasmine.objectContaining({
readiness_diagnostics: { timed_out: true, total_duration_ms: 1234 }
}));
});

it('debug logs when the readiness eval rejects and continues to capture', async () => {
server.reply('/', () => [200, 'text/html', '<p>Hello Percy!</p>']);

await percy.browser.launch();
let page = await percy.browser.page();
await page.goto('http://localhost:8000');

percy.loglevel('debug');

// Reject only the readiness eval (its first arg is the readiness config
// object containing `preset`); all other evals pass through.
let originalEval = page.eval.bind(page);
spyOn(page, 'eval').and.callFake((fn, ...args) => {
if (args[0] && typeof args[0] === 'object' && 'preset' in args[0]) {
return Promise.reject(new Error('boom'));
}
return originalEval(fn, ...args);
});

let snapshot = await page.snapshot({
readiness: { preset: 'fast' }
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Readiness check failed: Error: boom/)
]));
// serialize still runs after a readiness failure
expect(snapshot.url).toEqual('http://localhost:8000/');
});

it('falls back to the percy config readiness when none is set per snapshot', async () => {
server.reply('/', () => [200, 'text/html', '<p>Hello Percy!</p>']);

percy.config.snapshot.readiness = { preset: 'fast', timeoutMs: 1000 };

await percy.browser.launch();
let page = await percy.browser.page();
await page.goto('http://localhost:8000');

percy.loglevel('debug');

let snapshot = await page.snapshot({});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Waiting for readiness/)
]));
expect(snapshot.url).toEqual('http://localhost:8000/');
});

describe('.start()', () => {
// rather than stub prototypes, extend and mock
class TestPercy extends Percy {
Expand Down
109 changes: 109 additions & 0 deletions packages/core/test/snapshot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,115 @@ describe('Snapshot', () => {
expect(uploads[2]).toEqual(Buffer.from(textResource.content).toString('base64'));
});

it('warns when readiness diagnostics indicate a timeout', async () => {
// domSnapshot is sent as a JSON string by SDKs, which preserves snake_case
// keys like readiness_diagnostics through option normalization.
await percy.snapshot({
name: 'Readiness Timed Out',
url: 'http://localhost:8000/',
domSnapshot: JSON.stringify({
html: testDOM,
readiness_diagnostics: {
timed_out: true,
total_duration_ms: 12345,
preset: 'balanced'
}
})
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Readiness timed out after 12345ms (preset: balanced)'
]));
expect(logger.stdout).toEqual(jasmine.arrayContaining([
'[percy] Snapshot taken: Readiness Timed Out'
]));
});

it('falls back to "custom" preset label when readiness timeout omits the preset', async () => {
await percy.snapshot({
name: 'Readiness Timed Out Custom',
url: 'http://localhost:8000/',
domSnapshot: JSON.stringify({
html: testDOM,
readiness_diagnostics: {
timed_out: true,
total_duration_ms: 9999
}
})
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Readiness timed out after 9999ms (preset: custom)'
]));
});

it('debug logs readiness diagnostics when readiness passes', async () => {
percy.loglevel('debug');

await percy.snapshot({
name: 'Readiness Passed',
url: 'http://localhost:8000/',
domSnapshot: JSON.stringify({
html: testDOM,
readiness_diagnostics: {
timed_out: false,
total_duration_ms: 250
}
})
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy:core:snapshot] Readiness passed in 250ms (preset: custom)'
]));
});

// The previous three tests pass `domSnapshot` as a JSON-stringified string —
// a path that bypasses PercyConfig.migrate's recursive case-conversion of
// nested keys. Real SDKs (post-PR2184) submit `domSnapshot` as an OBJECT in
// the snapshot post body, so the normalize layer rewrites snake_case nested
// keys to camelCase. The schema marks readiness_diagnostics with
// `normalize: false` to preserve the wire shape; these regression tests
// pin both the schema flag and the snapshot.js dual-read fallback.
it('logs readiness timeout when domSnapshot is submitted as an object (real SDK wire shape)', async () => {
await percy.snapshot({
name: 'Readiness Timeout Object',
url: 'http://localhost:8000/',
domSnapshot: {
html: testDOM,
readiness_diagnostics: {
timed_out: true,
total_duration_ms: 4321,
preset: 'balanced'
}
}
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Readiness timed out after 4321ms (preset: balanced)'
]));
});

it('logs readiness pass when domSnapshot is submitted as an object', async () => {
percy.loglevel('debug');

await percy.snapshot({
name: 'Readiness Passed Object',
url: 'http://localhost:8000/',
domSnapshot: {
html: testDOM,
readiness_diagnostics: {
timed_out: false,
total_duration_ms: 175,
preset: 'fast'
}
}
});

expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy:core:snapshot] Readiness passed in 175ms (preset: fast)'
]));
});

it('handles duplicate snapshots when testCase is not passed', async () => {
await percy.snapshot([{
url: 'http://localhost:8000/foobar',
Expand Down
2 changes: 2 additions & 0 deletions packages/dom/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export {
} from './serialize-dom';

export { loadAllSrcsetLinks } from './serialize-image-srcset';

export { waitForReady } from './readiness';
Loading
Loading