-
Notifications
You must be signed in to change notification settings - Fork 56
feat: Fully Loaded Snapshot Capture — Readiness Gate (PER-7348) #2172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
c945e4a
8e69c92
03cac9b
b629f9d
f86bb0d
3f60c07
3ad8623
76f48fc
282936f
80573d4
477e214
b3e6fd1
02156f1
c298f87
9702ef9
94e0451
3859f4a
dc2a874
36bb99c
bf21491
8c91570
10270e3
02f2714
b701423
56f3178
47d323b
77f54fd
0ce9e04
e7084f5
1d1b8fd
17447a9
dc77dee
8d02b80
80d28e1
ae83fc3
968160b
9e06c1e
454f73d
d761c1c
b7d98f4
73475d9
524f273
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -359,6 +359,11 @@ async function* captureSnapshotResources(page, snapshot, options) { | |
| yield waitForFontLoading(page); | ||
| yield waitForDiscoveryNetworkIdle(page, discovery); | ||
| yield* captureResponsiveAssets(); | ||
| // Readiness does NOT run on SDK-provided domSnapshots. The DOM is already | ||
| // serialized at this point — running readiness cannot modify it. Preserving | ||
| // the domSnapshot maintains modal state, post-interaction state, and supports | ||
| // localhost testing. SDK users who need fully-loaded snapshots should wait | ||
| // for page stability in their test code before calling percySnapshot(). | ||
| capture(processSnapshotResources(snapshot)); | ||
| } | ||
| } | ||
|
|
@@ -483,10 +488,29 @@ export function createDiscoveryQueue(percy) { | |
| } | ||
| }); | ||
|
|
||
| // Set readiness config on page for Storybook (which calls page.snapshot() without readiness in options) | ||
| page._readinessConfig = snapshot.readiness || percy.config?.snapshot?.readiness; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why we need separate parsing, check how other config options are merged and loaded. |
||
|
|
||
| try { | ||
| // Wrap callback to send failed readiness diagnostics to smart debugging | ||
| /* istanbul ignore next: diagnostic callback requires live browser readiness timeout */ | ||
| let captureWithDiagnostics = (captured) => { | ||
| if (captured?.readiness_diagnostics && !captured.readiness_diagnostics.passed) { | ||
| let diag = captured.readiness_diagnostics; | ||
| let failedChecks = Object.entries(diag.checks || {}) | ||
| .filter(([, c]) => !c.passed) | ||
| .map(([name]) => name); | ||
| percy.suggestionsForFix( | ||
| `Snapshot "${captured.name}" readiness timed out after ${diag.total_duration_ms}ms. Failed checks: ${failedChecks.join(', ')}`, | ||
| { snapshotLevel: true, snapshotName: captured.name } | ||
| ); | ||
| } | ||
| callback(captured); | ||
| }; | ||
|
|
||
|
Comment on lines
+497
to
+510
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check how |
||
| yield* captureSnapshotResources(page, snapshot, { | ||
| captureWidths: !snapshot.domSnapshot && percy.deferUploads, | ||
| capture: callback, | ||
| capture: captureWithDiagnostics, | ||
| captureForDevices: percy.deviceDetails || [] | ||
| }); | ||
| } finally { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import fs from 'fs'; | |
| import logger from '@percy/logger'; | ||
| import Network from './network.js'; | ||
| import { PERCY_DOM } from './api.js'; | ||
| import { waitForReadiness } from './readiness.js'; | ||
| import { | ||
| hostname, | ||
| waitFor, | ||
|
|
@@ -11,6 +12,9 @@ import { | |
|
|
||
| export class Page { | ||
| static TIMEOUT = undefined; | ||
| // Global readiness config set by Percy constructor, accessible by all page instances. | ||
| // Needed for Storybook which calls page.snapshot() without readiness in options. | ||
| static _globalReadinessConfig = null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why separate variable needed percy.config should have the config. |
||
|
|
||
| log = logger('core:page'); | ||
|
|
||
|
|
@@ -213,6 +217,15 @@ export class Page { | |
|
|
||
| await this.insertPercyDom(); | ||
|
|
||
| // Run readiness checks before capturing — only when explicitly configured. | ||
| // Config priority: per-snapshot > per-page > global (from Percy constructor) | ||
| let readinessDiagnostics = null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM — Percy DOM injection may trigger false DOM instability:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good observation. |
||
| let effectiveReadiness = snapshot.readiness || this._readinessConfig || Page._globalReadinessConfig; | ||
| /* istanbul ignore next: readiness execution requires live browser + active config */ | ||
| if (effectiveReadiness && effectiveReadiness.preset !== 'disabled') { | ||
| readinessDiagnostics = await waitForReadiness(this, { ...snapshot, readiness: effectiveReadiness }); | ||
| } | ||
|
|
||
| // serialize and capture a DOM snapshot | ||
| this.log.debug('Serialize DOM', this.meta); | ||
|
|
||
|
|
@@ -223,6 +236,8 @@ export class Page { | |
| url: document.URL | ||
| }), { enableJavaScript, disableShadowDOM, forceShadowAsLightDOM, domTransformation, reshuffleInvalidTags, ignoreCanvasSerializationErrors, ignoreStyleSheetSerializationErrors, pseudoClassEnabledElements }); | ||
|
|
||
| /* istanbul ignore next: readinessDiagnostics only set when readiness runs (requires live browser) */ | ||
| if (readinessDiagnostics) capture.readiness_diagnostics = readinessDiagnostics; | ||
| return { ...snapshot, ...capture }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import PercyConfig from '@percy/config'; | |
| import logger from '@percy/logger'; | ||
| import { getProxy } from '@percy/client/utils'; | ||
| import Browser from './browser.js'; | ||
| import { Page } from './page.js'; | ||
| import Pako from 'pako'; | ||
| import { | ||
| base64encode, | ||
|
|
@@ -96,6 +97,11 @@ export class Percy { | |
| this.config = config; | ||
| this.cliStartTime = null; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM — Static mutable state not safe for concurrent instances: Fix: Attach readiness config to the Percy instance and pass it through the call chain rather than using a class-level static.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid concern. In practice, Percy CLI creates only one instance per process (the server binds to a single port). Multiple instances are only used in tests, where |
||
| // Set global readiness config for all Page instances | ||
| if (config.snapshot?.readiness) { | ||
| Page._globalReadinessConfig = config.snapshot.readiness; | ||
| } | ||
|
|
||
| if (testing) loglevel = 'silent'; | ||
| if (loglevel) this.loglevel(loglevel); | ||
|
|
||
|
|
@@ -363,8 +369,9 @@ export class Percy { | |
| await this.#discovery.end(); | ||
| await this.#snapshots.end(); | ||
|
|
||
| // mark instance as stopped | ||
| // mark instance as stopped and reset global readiness config | ||
| this.readyState = 3; | ||
| Page._globalReadinessConfig = null; | ||
| } catch (err) { | ||
| this.log.error(err); | ||
| throw err; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import logger from '@percy/logger'; | ||
|
|
||
| const log = logger('core:readiness'); | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need separate file, why can't we reuse dom.js readiness file only which exposes function?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also if dom.js is handling internally it should not be even required as snapshot and sdk both flow execute same dom capture script only |
||
| export const PRESETS = { | ||
| balanced: { stability_window_ms: 300, network_idle_window_ms: 200, timeout_ms: 10000, image_ready: true, font_ready: true, js_idle: true }, | ||
| strict: { stability_window_ms: 1000, network_idle_window_ms: 500, timeout_ms: 30000, image_ready: true, font_ready: true, js_idle: true }, | ||
| fast: { stability_window_ms: 100, network_idle_window_ms: 100, timeout_ms: 5000, image_ready: false, font_ready: true, js_idle: true } | ||
| }; | ||
|
|
||
| // Resolve readiness config from preset + per-snapshot overrides. | ||
| // Accepts camelCase (from Percy config normalizer) and outputs snake_case for @percy/dom. | ||
| /* istanbul ignore next: default params and ?? branches tested via unit tests */ | ||
| export function resolveReadinessConfig(options = {}) { | ||
| let readiness = options.readiness || {}; | ||
| let presetName = readiness.preset || 'balanced'; | ||
| if (presetName === 'disabled') return { preset: 'disabled' }; | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 LOW — PRESETS duplicated: Both
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged. The duplication exists because |
||
| let preset = PRESETS[presetName] || PRESETS.balanced; | ||
| return { | ||
| preset: presetName, | ||
| stability_window_ms: (readiness.stabilityWindowMs ?? readiness.stability_window_ms) ?? preset.stability_window_ms, | ||
| network_idle_window_ms: (readiness.networkIdleWindowMs ?? readiness.network_idle_window_ms) ?? preset.network_idle_window_ms, | ||
| timeout_ms: (readiness.timeoutMs ?? readiness.timeout_ms) ?? preset.timeout_ms, | ||
| image_ready: (readiness.imageReady ?? readiness.image_ready) ?? preset.image_ready, | ||
| font_ready: (readiness.fontReady ?? readiness.font_ready) ?? preset.font_ready, | ||
| js_idle: (readiness.jsIdle ?? readiness.js_idle) ?? preset.js_idle, | ||
| ...((readiness.readySelectors ?? readiness.ready_selectors) && { ready_selectors: readiness.readySelectors ?? readiness.ready_selectors }), | ||
| ...((readiness.notPresentSelectors ?? readiness.not_present_selectors) && { not_present_selectors: readiness.notPresentSelectors ?? readiness.not_present_selectors }) | ||
| }; | ||
| } | ||
|
|
||
| // CLI-side readiness orchestrator. | ||
| // Calls PercyDOM.waitForReady() in the browser context via page.eval(). | ||
| /* istanbul ignore next: warning logging branches tested via unit tests with mocks */ | ||
| export async function waitForReadiness(page, options = {}) { | ||
| let config = resolveReadinessConfig(options); | ||
| if (config.preset === 'disabled') return null; | ||
|
|
||
| log.debug(`Running readiness checks: preset=${config.preset}, not_present=${JSON.stringify(config.not_present_selectors || [])}`); | ||
|
|
||
| // Note: insertPercyDom() is already called by the caller in page.snapshot() (line 218) | ||
| // before waitForReadiness is invoked — no need to call it again here. | ||
|
|
||
| let result; | ||
| try { | ||
| /* istanbul ignore next: no instrumenting injected code */ | ||
| /* istanbul ignore next: no instrumenting injected code */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM — Double
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 80d28e1. Removed the redundant |
||
| result = await page.eval((_, readinessConfig) => { | ||
| // eslint-disable-next-line no-undef | ||
| if (typeof PercyDOM === 'undefined' || typeof PercyDOM.waitForReady !== 'function') { | ||
| return { passed: true, error: 'waitForReady not available', checks: {} }; | ||
| } | ||
| // eslint-disable-next-line no-undef | ||
| return PercyDOM.waitForReady(readinessConfig); | ||
| }, config); | ||
| } catch (error) { | ||
| log.debug(`Readiness check error: ${error.message}`); | ||
| result = { passed: false, timed_out: false, error: error.message, total_duration_ms: 0, checks: {} }; | ||
| } | ||
|
|
||
| if (result && !result.passed) { | ||
| let lines = [`Snapshot "${options.name || 'unknown'}" captured before stable (timed out after ${result.total_duration_ms}ms)`]; | ||
| for (let [name, check] of Object.entries(result.checks || {})) { | ||
| let status = check.passed ? 'passed' : 'FAILED'; | ||
| let detail = check.duration_ms != null ? ` (${check.duration_ms}ms)` : ''; | ||
| if (!check.passed && check.mutations_observed) detail = ` (${check.mutations_observed} mutations in ${check.duration_ms}ms)`; | ||
| lines.push(` - ${name}: ${status}${detail}`); | ||
| } | ||
| let failed = Object.entries(result.checks || {}).filter(([, c]) => !c.passed); | ||
| if (failed.length) { | ||
| let [name] = failed[0]; | ||
| let tips = { | ||
| dom_stability: 'Try adding notPresentSelectors for loading indicators, or increase stabilityWindowMs.', | ||
| network_idle: 'Check for long-polling or analytics. Try adding endpoints to disallowedHostnames.', | ||
| image_ready: 'Images still loading. Try imageReady: false if images load lazily.', | ||
| js_idle: 'JavaScript still executing (long tasks detected on main thread). Try increasing timeoutMs or set jsIdle: false for pages with continuous JS.', | ||
| ready_selectors: 'Required selector(s) not found. Verify selectors exist on the page.', | ||
| not_present_selectors: 'Loading indicators still present. These may be skeleton loaders or spinners.' | ||
| }; | ||
| if (tips[name]) lines.push(` Tip: ${tips[name]}`); | ||
| } | ||
| log.warn(`Warning: ${lines[0]}`); | ||
| for (let l of lines.slice(1)) log.warn(l); | ||
| } else if (result) { | ||
| log.debug(`Readiness checks passed in ${result.total_duration_ms}ms`); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unnecessary comment