fix(dom): keep readiness gate async-free so TestCafe t.eval accepts the bundle#2346
Conversation
…he bundle 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) <noreply@anthropic.com>
prklm10
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 4 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| ]); | ||
| } catch (error) { | ||
| return Promise.race([ | ||
| runAllChecks(config, result, aborted).then(() => { settled = true; }), |
There was a problem hiding this comment.
[Low] Sync throws now escape waitForReady instead of landing in result.error
Previously waitForReady was async, so a synchronous throw during check setup became a rejection captured by the catch. Now runAllChecks runs synchronously before .catch attaches, so a sync throw (e.g. in checkFontReady, whose body executes outside a Promise executor) propagates out as an exception. All in-repo callers are protected, but third-party SDK code calling PercyDOM.waitForReady(cfg).then(...) directly would see an escaped exception instead of a captured error.
Suggestion: wrap the call async-free to convert sync throws into rejections:
new Promise(resolve => resolve(runAllChecks(config, result, aborted))).then(() => { settled = true; }),Reviewer: stack:code-reviewer
| @@ -0,0 +1,16 @@ | |||
| # The @percy/dom bundle is injected into the browser verbatim by SDKs. Some | |||
There was a problem hiding this comment.
[Low] The guard protects source, not the shipped artifact
The real contract is "the built bundle is async-free"; a future bundled dependency, babel helper, or rollup-target change could reintroduce async into dist/bundle.js without any src/ lint violation.
Suggestion: add a small regression test (or CI step) that parses the built bundle with acorn (already in the dependency tree) and asserts zero async/generator nodes — it would have caught the original 1.31.14 regression.
Reviewer: stack:code-reviewer
| - 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).' |
There was a problem hiding this comment.
[Low] Top-level for await slips through the selectors
for await (const x of y) at module top level is a ForOfStatement[await=true] — none of the four selectors match it there (inside a function, :function[async=true] catches the container). Verified against ESLint 7.32.
Suggestion: add a fifth selector: ForOfStatement[await=true].
Reviewer: stack:code-reviewer
| # 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). |
There was a problem hiding this comment.
[Low] Guard silently dies under an ESLint flat-config migration
Extensionless .eslintrc relies on legacy cascading config, which ESLint 9 flat config removes — if the repo migrates, this guard stops applying silently with no error.
Suggestion: add a one-line note in this header comment so a flat-config migration doesn't silently drop the guard (a bundle-AST regression test would also backstop this).
Reviewer: stack:code-reviewer
Claude Code PR ReviewPR: #2346 • Head: a102dac • Reviewers: stack:code-reviewer SummaryRewrites the only two async functions in Review Table
Findings
Verdict: PASS — Fix is correct, minimal, and verified: rebuilt bundle is async/generator-free by AST inspection, all 902 dom tests pass at head, and the lint guard demonstrably fires on violations. All findings are Low severity and non-blocking; Finding 1 is a recommended one-line hardening. |
|
RUN_REGRESSION |
Fixes PER-10121
Root cause
The
@percy/dombundle is injected into the browser verbatim by SDKs.@percy/testcafe/index.js:29does:TestCafe's
t.eval/ClientFunction machinery statically rejects anyasync/awaitor generator syntax in the evaluated source.The readiness gate shipped in 1.31.14 (PER-7348, #2184) added two
asyncfunctions —runAllChecksandwaitForReadyinpackages/dom/src/readiness.js— which are the only async/await in the entire dom source. Because the rollup babel target (browsers: ['last 2 versions and supports async-functions']) intentionally does not down-level async, those tokens flow straight intodist/bundle.js. Every@percy/testcafesnapshot then fails with:and the build finalizes with
Failure: Snapshot command was not called. Playwright/other SDKs are unaffected because they don't route the bundle through TestCafe's restrictive evaluator.Bundle token profile (published):
asyncawaitFix
Rewrite
runAllChecksandwaitForReadyto explicit Promise chains (return Promise.all(...),Promise.race(...).catch(...).then(...)). Behavior is unchanged — both were already Promise-returning at every call site (runAllChecks(...).then(...),await waitForReady(...)), and the try/catch →.catch/.thenmapping preserves the timeout/abort/post-processing semantics exactly. The rebuilt bundle is back to 0 async / 0 await / 0 generators, matching the 1.31.13 profile that TestCafe accepts.Add a package-local
packages/dom/src/.eslintrcthat forbidsasync/await/generators viano-restricted-syntax, so this browser-injection contract can't silently regress again (which is exactly how it shipped).Testing
@percy/domsuite green: 902 tests pass, including the readiness suite (waitForReady returns a Promise,stays sync even when readiness config is present, timeout/graceful-degradation cases).dist/bundle.jsverified async/await/generator-free.readiness.js.🤖 Generated with Claude Code