From a102dacb299b28c1c5b38387786ca3611d4b77a7 Mon Sep 17 00:00:00 2001 From: prklm10 Date: Mon, 20 Jul 2026 15:06:02 +0530 Subject: [PATCH] fix(dom): keep readiness gate async-free so TestCafe t.eval accepts the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @percy/dom bundle is injected into the browser verbatim by SDKs. TestCafe's `t.eval` (used by @percy/testcafe/index.js) statically rejects any async/await or generator syntax in the evaluated source. The readiness gate added in 1.31.14 (PER-7348) introduced two `async` functions into the bundle, which broke every @percy/testcafe snapshot with: eval code, arguments or dependencies cannot contain generators or "async/await" syntax (use Promises instead). Rewrite `runAllChecks` and `waitForReady` to explicit Promise chains (behavior unchanged — both were already Promise-returning at every call site) so the bundle is async-free again, matching the pre-1.31.14 (1.31.13) profile. Add a package-local .eslintrc in packages/dom/src that forbids async/await and generators via no-restricted-syntax, so this contract can't silently regress. Fixes PER-10121 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dom/src/.eslintrc | 16 ++++++++ packages/dom/src/readiness.js | 69 +++++++++++++++++++---------------- 2 files changed, 53 insertions(+), 32 deletions(-) create mode 100644 packages/dom/src/.eslintrc diff --git a/packages/dom/src/.eslintrc b/packages/dom/src/.eslintrc new file mode 100644 index 000000000..dfba8d83f --- /dev/null +++ b/packages/dom/src/.eslintrc @@ -0,0 +1,16 @@ +# The @percy/dom bundle is injected into the browser verbatim by SDKs. Some +# evaluators — notably TestCafe's `t.eval` — statically reject ANY async/await +# or generator syntax in the evaluated source, so the shipped bundle must stay +# async-free. Enforce that at authoring time here (this config cascades onto +# every file in src/); use explicit Promise chains instead (PER-10121). +rules: + no-restricted-syntax: + - error + - selector: ':function[async=true]' + message: 'async functions are forbidden in @percy/dom src — the bundle is injected via TestCafe t.eval, which rejects async/await. Use explicit Promise chains (PER-10121).' + - selector: AwaitExpression + message: 'await is forbidden in @percy/dom src — the bundle is injected via TestCafe t.eval, which rejects async/await. Use explicit Promise chains (PER-10121).' + - selector: ':function[generator=true]' + message: 'generator functions are forbidden in @percy/dom src — the bundle is injected via TestCafe t.eval, which rejects generators (PER-10121).' + - selector: YieldExpression + message: 'yield is forbidden in @percy/dom src — the bundle is injected via TestCafe t.eval, which rejects generators (PER-10121).' diff --git a/packages/dom/src/readiness.js b/packages/dom/src/readiness.js index 8f1a4dc51..eca91d0be 100644 --- a/packages/dom/src/readiness.js +++ b/packages/dom/src/readiness.js @@ -452,7 +452,12 @@ export function createAbortHandle() { }; } -async function runAllChecks(config, result, aborted) { +// Returns a Promise rather than using `async`/`await`. The shipped +// @percy/dom bundle is injected into the browser verbatim by SDKs, and some +// evaluators — notably TestCafe's `t.eval` — statically reject ANY async/await +// or generator syntax in the evaluated source. Keeping this file async-free +// preserves the pre-1.31.14 bundle contract those SDKs depend on (PER-10121). +function runAllChecks(config, result, aborted) { let checks = []; let expected = []; // dom_stability: false is an explicit kill switch for the MutationObserver @@ -476,7 +481,7 @@ async function runAllChecks(config, result, aborted) { if (config.ready_selectors?.length) { expected.push('ready_selectors'); checks.push(checkReadySelectors(config.ready_selectors, aborted).then(r => { result.checks.ready_selectors = r; })); } if (config.not_present_selectors?.length) { expected.push('not_present_selectors'); checks.push(checkNotPresentSelectors(config.not_present_selectors, aborted).then(r => { result.checks.not_present_selectors = r; })); } result._expectedChecks = expected; - await Promise.all(checks); + return Promise.all(checks); } // Normalize camelCase config keys (from .percy.yml / SDK options) to the @@ -499,9 +504,11 @@ export function normalizeOptions(options = {}) { }; } -export async function waitForReady(options = {}) { +// Async-free by design — see the note on `runAllChecks` (PER-10121). Returns +// a Promise resolving to the readiness result. +export function waitForReady(options = {}) { let presetName = options.preset || 'balanced'; - if (presetName === 'disabled') return { passed: true, timed_out: false, skipped: true, checks: {} }; + if (presetName === 'disabled') return Promise.resolve({ passed: true, timed_out: false, skipped: true, checks: {} }); let preset = PRESETS[presetName] || PRESETS.balanced; // Normalize user options to snake_case, then merge. Only overrides @@ -518,39 +525,37 @@ export async function waitForReady(options = {}) { let settled = false; let aborted = createAbortHandle(); - try { - await Promise.race([ - runAllChecks(config, result, aborted).then(() => { settled = true; }), - new Promise(resolve => setTimeout(() => { - if (!settled) { - result.timed_out = true; - // Abort all running checks — clears intervals, disconnects observers - aborted.abort(); - } - resolve(); - }, effectiveTimeout)) - ]); - } catch (error) { + return Promise.race([ + runAllChecks(config, result, aborted).then(() => { settled = true; }), + new Promise(resolve => setTimeout(() => { + if (!settled) { + result.timed_out = true; + // Abort all running checks — clears intervals, disconnects observers + aborted.abort(); + } + resolve(); + }, effectiveTimeout)) + ]).catch(error => { /* istanbul ignore next: safety net for unexpected errors in readiness checks */ result.error = error.message || String(error); - } - - // Mark any checks that didn't complete before timeout as failed. - // `_expectedChecks` is always set by runAllChecks, but coverage here - // depends on whether any expected check was skipped due to timeout. - /* istanbul ignore next: only falsy when the catch block above fires before runAllChecks sets _expectedChecks */ - if (result._expectedChecks) { - for (let name of result._expectedChecks) { - if (!result.checks[name]) { - result.checks[name] = { passed: false, timed_out: true }; + }).then(() => { + // Mark any checks that didn't complete before timeout as failed. + // `_expectedChecks` is always set by runAllChecks, but coverage here + // depends on whether any expected check was skipped due to timeout. + /* istanbul ignore next: only falsy when the catch block above fires before runAllChecks sets _expectedChecks */ + if (result._expectedChecks) { + for (let name of result._expectedChecks) { + if (!result.checks[name]) { + result.checks[name] = { passed: false, timed_out: true }; + } } + delete result._expectedChecks; } - delete result._expectedChecks; - } - result.total_duration_ms = Math.round(performance.now() - startTime); - result.passed = !result.timed_out && !result.error && Object.values(result.checks).every(c => c.passed); - return result; + result.total_duration_ms = Math.round(performance.now() - startTime); + result.passed = !result.timed_out && !result.error && Object.values(result.checks).every(c => c.passed); + return result; + }); } export { PRESETS };