chore: upgrade Node 14 -> 20 (toolchain, published packages, executables) - #2386
chore: upgrade Node 14 -> 20 (toolchain, published packages, executables)#2386aryanku-dev wants to merge 23 commits into
Conversation
scripts/loader.js implemented getFormat/getSource/transformSource, which Node removed in 16.12.0, and relied on hooks running in the main thread so it could read global.__MOCK_IMPORTS__ and the memfs volume at fs.$vol. Node 18.19/20 run module customization hooks on a dedicated worker, so both assumptions are gone. Measured on Node 18.20.8 before this change: @percy/cli failed 12/27 and @percy/core 63/1217, while @percy/logger passed 150/150 with 0% coverage and `nyc report --check-coverage` still exiting 0 -- the removed hooks are ignored with a warning, not an error, and .nycrc sets instrument:false, so the 100% gate silently stopped enforcing anything. - loader-alias.js (new): LOADER_ALIAS extracted, side-effect free. rollup.config.js imports it in the main thread; previously importing the hooks module to read a regex also installed a global Proxy on every rollup and karma run. - register-hooks.mjs (new): main-thread half. Owns the mock registry and mirrors export names + materialised module paths to a manifest on the real filesystem, the one channel both threads share. Loaded via --import. - loader.js: resolve + load + initialize. Mock values never cross the boundary -- the generated shim reads global.__MOCK_IMPORTS__ and executes in the main thread, so only export names travel. Public signatures are unchanged (mockfs, fs.$vol, fs.$bypass, __MOCK_IMPORTS__.set), so no test file changes and no ripple into the SDK repos that consume the published test/helpers. Three details worth keeping: resolve() must set shortCircuit on every early return; load() must fall back to the original source when transformAsync returns null; and the ?__mock__=<uid> cache-buster must survive, because Node's ESM cache cannot be invalidated and without it mocks bleed across specs as flakiness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.nvmrc/.node-version and the test, windows, lint and typecheck workflows.
20.19.5 rather than a bare 20: module.register() landed in 20.6.0, so 20.0.x-20.5.x
would silently lack the API the test harness now depends on.
Cache namespaces move to a literal node-20/ rather than ${{ matrix.node }}.
test.yml's build and regression jobs have no matrix, so the interpolation would
evaluate empty there and collapse the namespace. setup-node also moves to the
v5.0.0 SHA already pinned in release.yml.
The pre-existing test-node20 job (added by PPLT-5844 to exercise the snyk
lockfile path) is left as-is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
babel.config.cjs targets node 20 in both overrides, and engines.node becomes ">=20" across all 18 packages (packages/dom gains the field, which it lacked). This drops support for Node 14/16/18 in the published packages -- a deliberate breaking change, so this should ship as a major. rollup.config.js:56 carries a THIRD hardcoded node:'14' target, deliberately left alone here: it compiles the browser bundles (@percy/dom, @percy/sdk-utils), so changing it alters code injected into customer pages rather than anything Node runs. It needs the karma suites to validate and is better decided on its own. lockfileDiff.js: correct the rationale only. The lazy require and the cjsRequire binding both stay -- the dependency is still optional, and the binding exists to survive the CJS transpile. The comments claimed "the CLI supports Node >=14", which is no longer true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three behaviour changes in Node itself, each of which can break a customer who is fully up to date. DNS (Node 17+ defaults dns.lookup to verbatim): `localhost` can resolve ::1 before 127.0.0.1, so snapshotting http://localhost:3000 against an IPv4-only dev server gets ECONNREFUSED. Restore Node 14 ordering for Percy's own requests by defaulting verbatim:false in @percy/client's request() and forwarding it through core's directFetch lookup wrapper. Deliberately NOT dns.setDefaultResultOrder(): that is process-global, and @percy/core is imported into SDK consumers' processes -- silently reordering their DNS is out of bounds. dns is imported lazily, matching the convention the file already uses for http/https (a top-level import risks a MISSING_NODE_BUILTINS warning that rollup silently swallows). Chromium resolves independently, so this is invisible to the browser path and to every unit test -- it needs the packaged binary against an IPv4-only server. bin/run.cjs: add the missing .catch() on the startup import chain. Node <15 reported that rejection as a warning and exited 0, which is how a binary that crashes on startup used to ship; on Node 20 it is a fatal unhandled rejection with a raw stack trace. Exit codes change for anyone whose CI tolerated a broken binary. bin/run.cjs also guards on <20 now. This file must stay parseable by old Node or the guard is unreachable and consumers get a syntax error instead of the message -- it is excluded from Babel because cli's `files` is ["bin","dist"]. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vercel/pkg is archived at 5.8.1 and fails with "No available node version satisfies 'node20'", so the executable pipeline moves to the maintained @yao-pkg/pkg fork, pinned at 6.22.0. An unversioned `npm install -g pkg` in the release path lets the registry decide what compiles the binaries customers download. Targets are now explicit: node20-linux-x64,node20-macos-x64,node20-win-x64. pkg defaults to linux,macos,win at the HOST arch, so the only thing keeping the binaries x64 today is `architecture: x64` on an arm64 macos-latest runner -- remove that line, or let the runner image change, and every x64 customer silently gets an arm64 binary while --version passes on the arm64 runner that built it. With targets pinned, that workflow line is now redundant and removed. verify-executable.sh gains a `file`-based x86-64 assertion so the guarantee is enforced rather than remembered, and its header comment is rewritten: it justified itself by Node 14 turning startup crashes into warnings, which no longer holds. The rename block matches by prefix, because pkg appends -<arch> when the target arch differs from the host. NOT YET VERIFIED against a real run -- see the PR description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ection
Node >=20 enables autoSelectFamily (Happy Eyeballs) by default, so a failed
connection to a dual-stack host -- `localhost`, or any name with both A and AAAA
records -- rejects with an AggregateError wrapping one error per address instead
of the single error earlier versions produced.
Measured on Node 20.19.5: Node DOES copy `.code` onto the AggregateError, so the
retry gate in request() and percy-idle.js keep working. What it does not carry is
a message -- AggregateError's is the empty string:
name: AggregateError code: ECONNREFUSED message: ""
Percy classifies some failures by message rather than code:
client/src/proxy.js and sdk-utils/src/proxy.js both test
`err.message.includes('ECONNREFUSED')` to decide a proxy is unreachable, and
those checks silently stopped matching. core/test/api.test.js also asserts on
/ECONNREFUSED/ and failed for the same reason.
flattenAggregateError() borrows the first sub-error's message ("connect
ECONNREFUSED 127.0.0.1:5883"), which is exactly what Node used to produce, and
mutates rather than rewraps so callers keep the AggregateError and its `.errors`.
It prefers a retryable cause when the addresses failed for different reasons.
Deliberately NOT applied to @percy/sdk-utils: its only code-based consumer is
percy-idle.js, and since Node preserves `.code` that path never broke -- adding a
second copy of the helper there would be untested surface for no fix.
Also drops a Node-version assertion in proxy.test.js: Node <18 threw
"Invalid URL: invalid-url" and Node >=18 dropped the offending input. Assert on
the prefix we own instead of Node's wording.
@percy/client: 288 of 288 specs, 100% coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ubuntu matrix had no fail-fast: false, so a single red job cancelled 14 others and `gh pr checks` rendered them all as failures -- which made a 7-failure run look like a 21-failure one. windows.yml already sets this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both cli-command jobs failed at 99.75% lines / 99.85% branches while all 189 specs passed. The uncovered lines were intelliStory.js's parser-unavailable bail. That branch was only ever covered BECAUSE CI ran a Node too old for the feature: snyk-nodejs-lockfile-parser declares engines >=18, so on Node 14 the optional install was skipped, loadSnyk() always threw, and the bail was the only path taken. On Node 20 the parser installs, the lockfile diff succeeds, and the bail stops executing -- taking its coverage with it. Marked ignored rather than tested, deliberately, and the comment says why: forcing it needs a refactor, not a spec. loadSnyk() caches _snykModule at module scope and intelliStory.js imports diffLockfileDeps statically, so neither the require nor the import can be made to fail once any earlier spec has loaded the parser. The comment also warns the next reader not to re-justify the pragma with "CI runs Node 14", which is what the previous ones said. Worth noting the direction: SmartSnap/IntelliStory lockfile diffing only ever worked on Node >=18. On Node 14 any dependency change bailed to a full snapshot set. This upgrade makes the working path the only path. cli-command: 189 of 189 specs, 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
semgrep/ci reported 7 blocking findings across 5 files. Confirmed locally with
semgrep 1.173.0 rather than guessed -- the CI job reports findings to the semgrep
cloud app, not the job log, so the hypothesis had to be reproduced:
javascript.lang.security.audit.path-traversal.path-join-resolve-traversal
scripts/loader-alias.js :18,19,21
scripts/loader.js :70,70,122
packages/config/test/helpers.js :111,119,123,124
This is the same rule .semgrepignore already suppresses for lock.js, archive.js,
api.js, maestro-screenshot-file.js and intelliStory.js. The harness is dev-only
and joins paths derived from the repo's own layout -- a hardcoded ROOT, the
workspace package.json `exports` map, an importer's own directory, and the paths
of virtual modules mockfs() created inside a jasmine spec. No request input
reaches any of them.
Added with the caveat recorded in the file: a .semgrepignore entry disables EVERY
rule for the whole file, including rules and code added later, so these should
become line-scoped as soon as the CI semgrep version honors inline nosemgrep.
Also builds the new AggregateError fixtures with plain assignment instead of
Object.assign, so insecure-object-assign has nothing to flag in that file.
@percy/client: 288 of 288 specs, 100% coverage. yarn lint clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Build & verify executable` failed on the real runner with:
Error! 404: Not Found
Not found in remote cache:
Error! Not able to build for 'linux' here, only for 'macos'
@yao-pkg/pkg resolves the `node20` range to a patch that has no prebuilt in
pkg-fetch, so the fetch 404s and it cannot cross-compile. Checked the actual
asset list: prebuilts exist for v20 up to 20.20.2 and v22 up to 22.23.2 with full
linux/macos/win x64 coverage, and node22 is the fork's documented primary target
(its docs list node22/node24, mentioning node18/20 only in passing).
This is the runtime compiled INTO the binary, which is a separate decision from
the `engines` floor the packages declare -- consumers still need >=20; the binary
carries its own Node. Two reasons node22 is the better choice regardless of the
404:
- Node 20 reached EOL on 30 Apr 2026 and gets no further security releases.
- Binary users cannot patch the runtime we ship them, unlike npm consumers who
choose their own Node. It is the one channel where our version choice becomes
someone else's vulnerability, so shipping a supported runtime matters most
here.
Not verified locally: `pkg` needs a global install and downloads prebuilts for
three targets, and executable.sh rewrites every package.json via `gsed -i`, so it
cannot run in a live worktree. CI is the check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the previous commit's move to node22 — Node 20 is the base for this
upgrade, binaries included. The 404 that commit was working around is fixed by
pinning an exact patch instead of switching major.
`Build & verify executable` failed with:
Error! 404: Not Found
Not found in remote cache:
Error! Not able to build for 'linux' here, only for 'macos'
The cause is the `node20` *range*: @yao-pkg/pkg resolves it to a patch that has
no prebuilt in pkg-fetch, then cannot cross-compile without one. Checked the
published assets — pkg-fetch ships v20 prebuilts for 20.10.0, 20.11.0, 20.11.1,
20.17.0, 20.18.0/.1/.2, 20.19.1, 20.19.4, 20.19.5, 20.19.6, 20.20.0 and 20.20.2,
each with all three x64 platforms.
Pinned to 20.19.5, which is what .nvmrc pins, so the runtime compiled into the
binary is the same one we build and test with rather than whatever the range
happens to resolve to. Left a note to bump both together, with the one-liner for
checking a target exists before pinning it.
Still unverified locally: pkg needs a global install and downloads prebuilts for
three targets, and executable.sh rewrites every package.json via `gsed -i`, so it
cannot run in a live worktree. CI is the check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ode 20
Second attempt at `Build & verify executable`. Pinning an exact patch was
necessary but not sufficient — 6.22.0 then failed differently:
Error! No available node version satisfies 'v20.19.5'
Root cause, traced rather than guessed: pkg resolves targets against its
@yao-pkg/pkg-fetch dependency, and pkg-fetch serves prebuilt Node binaries from
the GitHub release matching its own minor.
pkg 6.20.0+ -> pkg-fetch 3.6.x -> release v3.6 -> node 22, 24, 26 ONLY
pkg 6.19.0 -> pkg-fetch 3.5.33 -> release v3.5 -> node 14/16/18/19/20/22/24
So the 3.6 line dropped Node 20 entirely. That is why the plain `node20` range
404'd ("Not found in remote cache" -> "Not able to build for 'linux' here, only
for 'macos'"): there is no node20 prebuilt to fetch and pkg will not
cross-compile without one.
6.19.0 is the newest release still on the 3.5 line, and v3.5 publishes 20.19.5
for linux, macos and win x64 — verified against the release assets.
Consequence worth knowing: the executable pipeline cannot take @yao-pkg/pkg
upgrades while Node 20 is the target. The comment in executable.sh spells that
out so the next person bumping the pin understands why it is held back rather
than treating it as staleness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the last 8 @percy/sdk-utils failures. They were a regression from this
branch's own loader rewrite, not a Node 20 behaviour change.
Symptom: three consecutive isPercyEnabled() calls resolved true while
/test/requests came back `{}` -- the healthcheck was never issued. Dumping the
real responses from inside a failing spec showed the control plane working
perfectly, so the specs were reading stale state.
Cause: load() returned transformed source together with `format: 'commonjs'`.
Returning a source for a CommonJS module makes Node load it as a distinct module
rather than going through the `require` cache, so a package imported BOTH ways
ends up with two live instances. @percy/sdk-utils is imported as ESM by the specs
and required as CJS by test/helpers.js, so setupTest()'s
`delete utils.percy.enabled` mutated one object while isPercyEnabled() read the
other. percy.enabled stayed cached, the healthcheck was skipped, error/disconnect
directives had no effect, and percy.config never populated -- which is every one
of the 8 failures.
Fix: for a package that positively declares itself non-ESM, declare the format
and return NO source, letting Node's CJS require path load it and @babel/register
do the transform -- exactly the arrangement Node 18 fell into on its own. One
shared instance again.
Scoped deliberately to files BABEL_REG matches inside packages that declare
themselves non-ESM. The first attempt applied it whenever the package-type walk
came back empty, which stripped the source from genuinely ESM files it knew
nothing about and broke cli (10), cli-build (80), cli-snapshot (26) and
cli-upload (10). nearestPackageType() now distinguishes "found, not module" from
"found nothing" instead of conflating them as undefined.
Verified on Node 20.19.5: logger 150, config 82, env 123, cli 27,
cli-command 189 (100% coverage), cli-config 22, cli-build 84, cli-snapshot 28,
cli-upload 13, monitoring 40, webdriver-utils 238, sdk-utils 169 -- all green.
client's 1 and cli-doctor's 4 remaining local failures are this machine's ambient
proxy and PERCY_* env vars leaking into specs that assert their absence; both
suites pass in CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third and final piece of the executable fix. With pkg pinned to 6.19.0 the exact
patch still failed:
Error! No available node version satisfies 'v20.19.5'
pkg matches a target against its own known-version list, not against whatever
pkg-fetch actually publishes, so an exact patch is rejected even when that
prebuilt exists in the v3.5 release. The range is the supported form; the 6.19.0
pin is what makes it resolve to something fetchable.
Recorded in the comment: the binary's runtime is therefore whichever node20 patch
pkg-fetch 3.5.33 resolves to, which need not equal the .nvmrc pin. They only have
to share a major, but it does mean the pkg pin decides what ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aryanku-dev
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 6 inline finding(s). Full report in the PR comment below. Verdict: Passed.
Node 20 reached EOL on 30 April 2026 and 20.20.2 (2026-03-24) is the final
release on the line, so `node-version: 20` resolves to a de facto fixed
version rather than a moving target. Taking it picks up the four releases
missed since 20.19.5 -- 20.20.0 and 20.20.2 are security releases -- without
the usual drift risk, because nothing new will ship for 20.
This is also what the rest of the repo already expresses: `engines` says
`>=20`, and executable.sh pins `pkg --targets node20-*` as a deliberate range
whose own comment notes the packaged runtime "will not always equal the .nvmrc
pin ... they only have to share a major".
.nvmrc takes the bare major, which nvm resolves; .node-version keeps a fully
qualified 20.20.2 because nodenv matches it against a literal directory name
under ~/.nodenv/versions and errors on a partial version. That split is the
pre-existing convention here (.nvmrc was `14`, .node-version was `14.18.0`),
not a new inconsistency.
release.yml and version-bump.yml move 24 -> 20 as well. Neither needs 24: both
only run yarn install, yarn build and lerna publish, Node 20 satisfies the new
engines floor, and the compile target comes from babel.config.cjs rather than
the running Node -- so the published dist is unchanged. Building release
artifacts on a major nothing else tests on was the weaker option.
Also restores `${{ matrix.node }}` in the two matrix jobs' cache keys, which
had been hardcoded to `node-20`. The matrices stayed parameterised, so a second
entry -- which the Node 22 work will add -- would have made both versions share
one cache key and silently restore the wrong node_modules. The two static jobs
(build, regression) keep a literal key, which is correct for them.
Verified on 20.20.2: lint clean, build across 18 projects, and 15 of 16 suites
green at 100% coverage (2798 specs). core is left to CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ble.sh The header explained the output scan by saying the executables "are built on Node 14, where an unhandled promise rejection is reported as a *warning* and the process still exits 0". That stopped being true in this branch. On Node 20 the hole is closed twice over: unhandled rejections are fatal, and bin/run.cjs now catches startup failures and exits 1. The scan is still worth keeping as defence in depth -- it catches a binary that prints a stack trace and then exits 0 for some other reason -- so the check is unchanged and only its rationale is corrected. Carries the same "do NOT re-justify with Node 14" note this branch already added to intelliStory.js and lockfileDiff.js, so the next reader does not reintroduce a constraint that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rationale blocks had grown to 63 lines of `#` comments across three files, which is more explanation than the code they sit above. Cut to 12, keeping only the non-obvious facts a future reader cannot recover from the code itself: - executable.sh: why pkg is pinned (later pkg-fetch has no node20 prebuilt), why --targets is explicit (pkg follows the host arch), and why the rename globs. - verify-executable.sh: what makes the binary "broken". Also drops the last stale "Node 14 turns startup crashes into warnings" claim. - .semgrepignore: that the harness path joins come from the repo layout, and that the suppression is file-level because inline nosemgrep is ignored in CI. No behaviour change: `bash -n` clean on both scripts, and all 15 .semgrepignore entries are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aryanku-dev
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 6 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| matrix: | ||
| os: [ubuntu-latest] | ||
| node: [14] | ||
| node: [20] |
There was a problem hiding this comment.
[Medium] test-node20 is now a duplicate job
This matrix moving to node: [20] removed the reason test-node20 exists — its comment says it's there because the primary matrix ran Node 14. The primary package list contains all 17 packages including @percy/cli-command, which is the only entry in test-node20's matrix, and both matrices are now [20].
So cli-command's suite runs twice per CI run, same Node, same cache key, same cache paths. Visible in the check list as Test @percy/cli-command alongside Test @percy/cli-command (Node 20).
Suggestion: delete test-node20, or repoint it at Node 22 so it becomes a real forward-looking signal for the next migration. Either way the comment needs rewriting.
Reviewer: stack-code-reviewer
| # literals, no user input flows here. semgrep flags the path.join() anyway. | ||
| packages/cli-command/test/noRequireBinding.test.js | ||
|
|
||
| # ESM test harness (dev-only, never shipped): path joins derive from the repo's own |
There was a problem hiding this comment.
[Medium] "never shipped" is not true of one of these four files
packages/config/test/helpers.js is published:
files: ["dist", "types/index.d.ts", "test/helpers.js"]
exports: { "./test/helpers": "./test/helpers.js" }
It ships to npm and SDK test suites import it, so a file on the public surface now carries a suppression justified by "dev-only, never shipped". The three scripts/* entries are correctly described.
The inaccuracy predates the trim — the original long comment grouped it the same way — but the trim made it the entire stated rationale.
Suggestion: split the block; give config/test/helpers.js its own line noting it is published for SDK test mocking, but its path joins are test-fixture-derived rather than attacker-influenced.
Reviewer: stack-code-reviewer
| mv run-linux percy && chmod +x percy | ||
| mv run-macos percy-osx && chmod +x percy-osx | ||
| mv run-win.exe percy.exe && chmod +x percy.exe | ||
| # Targets are explicit: pkg otherwise follows the HOST arch, silently shipping |
There was a problem hiding this comment.
[Medium] The new arch assertion never sees the Linux binary
This script renames run-linux* → percy, then both branches below (signed and unsigned) run mv percy-osx percy — overwriting the Linux artifact with the macOS one before any verification runs.
So verify-executable.sh ./percy validates the macOS binary in both executable-check.yml and the release job, and OK: $BIN is x86-64 never refers to Linux. percy-linux.zip ships with no smoke test and no arch check; on the PR gate (no Apple secrets) the Linux binary isn't even zipped, just silently discarded after being clobbered.
The mv sequencing predates this PR, but this PR adds the arch assertion and the explicit multi-target build, so the net it appears to cast is narrower than it reads.
Suggestion: rename to non-colliding filenames (percy-linux, percy-osx, percy.exe) and verify all three. file can assert arch on all three from the macOS runner without executing them, even though --version can only run natively.
Reviewer: stack-code-reviewer
| }, | ||
| "engines": { | ||
| "node": ">=14" | ||
| "node": ">=20" |
There was a problem hiding this comment.
[Medium] engines >=20 has no runtime guard for direct SDK consumers
Carried forward from the previous review, still open. bin/run.cjs guards CLI users correctly — it's placed before import('../dist/index.js') and is syntactically safe on old Node. But SDKs importing @percy/core or @percy/sdk-utils directly never pass through it, and engines only warns at install time unless the consumer sets engine-strict.
Those consumers get a raw SyntaxError from the Node-20-targeted dist instead of the message this PR adds everywhere else.
Suggestion: add a minimal guard at @percy/core's entry point, or record the gap in "Known breaking changes" as accepted. A decision, not a defect.
Reviewer: stack-code-reviewer
| .catch(error => { | ||
| // Node <15 reported this as a warning and still exited 0, which is how a | ||
| // binary that crashes on startup used to ship (see verify-executable.sh). | ||
| console.error(`Percy failed to start: ${(error && error.stack) || error}`); |
There was a problem hiding this comment.
[Low] "failed to start" also reports mid-run failures
This .catch wraps the whole chain including await percy(process.argv.slice(2)) — the entire command run, not just startup. A failure deep in a snapshot, build or upload flow still prints Percy failed to start: …, which misleads anyone trying to tell a mid-run error from an import problem.
The comment two lines up also still cites "Node <15 reported this as a warning" — the same stale version rationale that ce32dd4/70625e9 cleaned out of the shell scripts, missed because those commits only touched .sh files.
Suggestion: scope the catch to import() / checkForUpdate() and let percy() own its own errors, or broaden the wording to Percy CLI exited with an error: …. Drop the Node 15 reference while there.
Reviewer: stack-code-reviewer
| // ordering for Percy's own requests only -- dns.setDefaultResultOrder() is | ||
| // process-global and @percy/core runs inside SDK consumers' processes. | ||
| lookup: requestOptions.lookup || ((hostname, opts, cb) => | ||
| dns.lookup(hostname, { ...opts, verbatim: false }, cb)), |
There was a problem hiding this comment.
[Low] This lookup closure is duplicated in core/src/network.js
The identical (hostname, opts, cb) => dns.lookup(hostname, { ...opts, verbatim: false }, cb) exists in both files. Both are correct today — the spread preserves Happy Eyeballs' opts.all/opts.family, and both respect a caller-supplied lookup — but two copies of a subtle resolution-order override will drift.
Suggestion: extract one shared helper. That would also give this behaviour (currently untested in both places) a single place to test.
Reviewer: stack-code-reviewer
Claude Code PR ReviewPR: #2386 • Head: 70625e9 • Reviewers: stack-code-reviewer SummaryMoves the three "Node versions" this repo conflates — CI/dev toolchain, the Re-review at a new head. Since the last review of The three risks flagged for scrutiny came back clean, and I verified each rather than taking it on trust:
Review Table
Findings
Raised by other reviewers (not independently confirmed)
Verdict: PASS — no High or Critical findings. Four Mediums: one duplicated CI job, one incorrect suppression rationale, one verification gap on the shipped Linux binary, and one open decision on SDK guards. |
`PercyServer#close()` guarded both `closeIdleConnections` and `closeAllConnections` behind `typeof … === 'function'`, falling back to manual socket iteration on Node 14. Both APIs landed in Node 18.2 and the engines floor is now >=20, so the fallbacks are unreachable — and the `closeIdleConnections` one was an empty block that did nothing anyway. The `istanbul ignore next` above it mattered more than the dead code. Its stated reason was "CI matrix includes Node 14, where closeIdleConnections is missing", which is no longer true, so it was suppressing coverage on a branch Node 20 always takes. Removing the conditional removes the branch, so the pragma goes with it. This branch's whole argument is that a vacuous coverage gate is the failure mode to avoid; an unearned ignore works against that. Kept deliberately: the `#sockets` Set (still used by the `drainMs: 0` path) and the `istanbul ignore next` on the 5s force-close timer, which is justified by the timeout being impractical to exercise under nyc, not by any Node version. Also fixes bin/run.cjs's catch message. It wraps the whole chain including `await percy(argv)`, so a failure deep in a snapshot or upload run was reported as "Percy failed to start" — misleading when distinguishing a mid-run error from an import failure. Now "Percy CLI exited with an error". Drops the stale "Node <15 reported this as a warning" comment with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment-only; no behaviour change. Each of these described real behaviour but attributed it to Node 14, and two cited windows.yml as "pinned to node-version: 14" — a file this branch changed to 20, so they pointed at their own refutation. - core/src/lock.js: renameSync-over-existing is unreliable on Windows generally, not specifically Node 14 Windows. The unlink + retry-`wx` reclaim stays. - core/src/discovery.js: the trailing .catch guards against an unhandled rejection, fatal since Node 15 — not a Node-14-specific mode. - sdk-utils/src/index.js: the iframe-depth constants are mirrored rather than imported across the package boundary; "broke Node 14 CI" is no longer why, and the parity test is what actually enforces alignment. Also splits the .semgrepignore block. It grouped packages/config/test/helpers.js under "dev-only, never shipped", but that file ships: @percy/config lists it in `files` and exports it as ./test/helpers, and SDK test suites import it. The suppression is still right — the joined paths are virtual modules mockfs() created in-process — but the stated reason was wrong for a file on the public surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test-node20 existed because the primary matrix ran Node 14: the snyk-backed lockfileDiff path needs Node >=18, so those specs were skipped there and this job re-ran @percy/cli-command on Node 20 to cover them (PPLT-5844). This branch moved the primary matrix to node: [20], and that matrix already lists all 17 packages including @percy/cli-command. So the suite ran twice per push on the same Node version, under the same cache key and cache paths — the duplication was visible in the checks as `Test @percy/cli-command` alongside `Test @percy/cli-command (Node 20)`. Verified the primary matrix still covers cli-command before deleting; no coverage is lost. Also drops the `PE32\+` alternative from verify-executable.sh's arch grep. It is unreachable: executable.sh ends both branches with `mv percy-osx percy`, so the script is only ever handed the macOS binary, which `file` reports as `Mach-O 64-bit executable x86_64` and the `x86[-_]64` alternative already matches. Note this only removes dead code — it does not close the underlying gap that percy-linux.zip and percy.exe are never verified at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts packages/core/src/discovery.js, packages/core/src/lock.js and packages/sdk-utils/src/index.js to master. All three carried nothing but reworded comments — one net line each — and each pulled a previously untouched file into an already-large PR purely to fix prose. The corrections themselves were right: all three justified real behaviour with "Node 14", and two cited windows.yml as pinned to node-version: 14, a file this branch changes to 20. But they don't need to ship with the toolchain migration and are better batched into their own pass. Kept: core/src/server.js, where the same sweep removed two genuinely dead branches and an `istanbul ignore` resting on a false premise, and the .semgrepignore split, which corrects a "never shipped" claim about a file @percy/config actually publishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores `PE32\+` in verify-executable.sh's arch assertion. It was removed on the grounds that the script is only ever handed ./percy — the macOS binary, after executable.sh's `mv percy-osx percy` — which made the PE branch unreachable. That was wrong. executable.yml:107 runs `verify-executable.sh ./percy.exe` in the Windows signing job, against a genuine 64-bit PE. Whether the generic `x86[-_]64` alternative still matches depends on what that runner's libmagic prints for PE output; most builds emit `PE32+ executable (console) x86-64, for MS Windows`, which would match, but nothing here verifies it. The job only fires on `release: published`, so a wrong guess surfaces as a failed release rather than a failed PR check. The removal bought nothing — it deleted an alternative that was believed dead — so the asymmetry says put it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewPR: #2386 • Head: 3dee1ed • Reviewers: stack-code-reviewer Continues the previous review — changes since SummaryRaises Percy CLI's minimum Node from 14 to 20. This delta is a cleanup pass: it removes two dead Node 14 fallback branches from Four of the six previously-open findings are resolved. One new Medium was found and has already been fixed on this head — details below. Review Table
Findings
Carried forward, still open
Resolved by this delta
Verdict: PASS — the one new Medium is already fixed on this head. Three findings carry forward, none of them regressions and none blocking. |
Ready for review. CI is green (47/47) and
masteris merged in. The two remaining items are decisions rather than unfinished work — see Open decisions.Moves all three "Node versions" this repo conflates — the toolchain, the floor the published packages declare, and the runtime
pkgcompiles into the standalone executables — from 14 to 20.Node 14 went EOL in April 2023. The pin has already distorted the codebase:
image-sizewas frozen on a vulnerable line to keep Node 14 support (#2301, resolved by #2390), and the whole snyk-backed IntelliStory lockfile path carriesistanbul ignorepragmas whose stated reason is "CI runs the suite on Node 14".Implements the floor recommended in PER-10249, which found Node 20 is the largest single runtime in the customer fleet — 45.8% of builds across 17,563 orgs. The floor lands at 20, not above it, so no customer on a runtime we intend to support is broken.
Why
20and not an exact patchCI,
.nvmrcand thepkgtargets all take the major, not20.19.5. Node 20 hit EOL on 30 April 2026 and20.20.2(2026-03-24) is the final release on the line, sonode-version: 20resolves to a de facto fixed version rather than a moving target — it picks up the four releases missed since 20.19.5 (20.20.0 and 20.20.2 are security releases) without the usual drift risk, because nothing new will ship for 20. This also matches what the repo already expresses:enginessays>=20, andscripts/executable.shpinspkg --targets node20-*as a deliberate range..node-versionkeeps a fully qualified20.20.2because nodenv resolves it against a literal directory name and errors on a partial version. That split is the pre-existing convention here — these files were14and14.18.0respectively.Why this isn't a version-string bump
1. The test harness could not run on Node 20.
scripts/loader.jsimplementedgetFormat/getSource/transformSource— removed in Node 16.12.0 — and needed hooks to run in the main thread so it could readglobal.__MOCK_IMPORTS__and the memfs volume atfs.$vol. Node 18.19/20 moved module hooks to a dedicated worker.2.
vercel/pkgcannot build a Node 20 binary. Archived at 5.8.1; fails withNo available node version satisfies 'node20'.3. A latent bug that ships the wrong architecture.
pkgran with no--targets, so output arch follows the host. The only thing keeping the binaries x64 was onearchitecture: x64line on an arm64 runner — delete it, or let GitHub change the image, and every x64 customer silently gets an arm64 binary while--versionpasses on the arm64 runner that built it.The dangerous part: the coverage gate stops existing
Measured on Node 18.20.8 (same hooks architecture as 20), before this change:
The removed hooks are ignored with a warning, not an error, so execution is unaffected — the
srcis already valid modern ESM and Node runs it natively. But.nycrcsetsinstrument: false, so nyc relies entirely onbabel-plugin-istanbulinside the removed hook. Coverage silently goes to zero, andnyc report --check-coveragepasses on an empty coverage map because there are no files to check.Only mock-heavy suites go red. Fix those, see green everywhere with
test:coveragesucceeding, and the 100% gate has been switched off for every future PR. A half-finished migration's failure mode is success. Exit code is not evidence here — assert the coverage map has content.This also means the existing
test-node20job (added by PPLT-5844) has been enforcing nothing since it landed.Verified on real Node 20.20.2, after merging current
masteryarn lintclean.yarn install --frozen-lockfileclean.yarn buildsucceeds across 18 projects with the new Babel target.2,798 specs, 100% coverage everywhere it is measured, with populated coverage maps (see the section above for why the map — not the exit code — is the thing to check):
masteradded IntelliStory specs)masteradded a request-timeout spec)End-to-end, not just unit tests: a real Percy build with the CLI built from this branch — 23/23 snapshots, 46 comparisons, 0 failed.
cli-doctorand thedomkarma suite are green in CI; both have local-only failures on a developer machine (PERCY_*env vars leaking into the env-audit assertions, and no local Firefox) which is the same class of false signal retracted below.Correction to an earlier revision of this description. It claimed the
core/sdk-utils/clientfailures were pre-existing and not regressions, based on a control worktree that reproduced them atmaster. That control was on a developer laptop, where those suites fail for local environment reasons — it does not transfer to CI, and master's Windows run atf86a18de(the commit this branches from) is green. The claim was wrong and is retracted.What a clean control does show, run at
masterwith the original loader on Node 20:@percy/sdk-utilscannot even load —ReferenceError: __dirname is not defined in ES module scope, because Node 20 reparses its CJS-typed test files as ESM once the dead Babel transform stops running. This branch takes it from crash-at-load to 169 specs executing.A fourth bug was only findable on real Node 20:
cli-commandpassed 189/189 on Node 18 but failed 14 on Node 20 with identical code. Node 20 reportspackages/sdk-utils/src/index.jsasformat: moduledespite its package having no"type": "module"— whilebabel.config.cjskeys off that same missing field and compiles it to CommonJS. Node then parsed CommonJS output as an ES module and every export vanished.load()now declares the format Babel actually emitted.CI
47 pass, 0 fail.
Test @percy/corepasses on both ubuntu (19m49s) and Windows.One flake was hit and is worth recording, because the retry machinery could not absorb it: a single karma spec (
postSnapshot … disables snapshots when a build fails) timed out at 10s in Firefox on Windows only — Chrome passed the same spec on the same runner, and ubuntu passed the identical suite. It went green on re-run with all four retry steps skipped.The retries were useless the first time for a structural reason:
scripts/test.jsbuilds its retry list from the jasminespecDonereporter, but karma runs separately viakarma.start()and its failures never enter that array. A karma-only failure therefore writes[]and every retry bails with "No recorded spec failures to retry — preserving the previous failure." The mechanism's own comment says it exists for "Windows CI, where ~1 spec out of 1000+ flakes per run on browser/server timing" — which is exactly the case it cannot handle. Not fixed here (out of scope); worth its own change.Everything that was red on the first run is now green, and each fix is a separate commit with its cause recorded:
clientAggregateErrorcarries.codebut an empty.message, andclient/src/proxy.js+sdk-utils/src/proxy.jsclassify proxy failures viaerr.message.includes('ECONNREFUSED')flattenAggregateError()restores the message Node used to produce. 288/288, 100% coveragecore's/ECONNREFUSED/speccli-command×2intelliStory.js's parser-unavailable bail was only ever covered because Node 14 couldn't install the snyk parsersdk-utils(8)load()returned transformed source alongsideformat: 'commonjs', which makes Node load a second copy instead of sharing therequirecache — so the ESM-importing specs and the CJS-requiringtest/helpers.jsheld two instances anddelete utils.percy.enabledmutated the wrong one@babel/registertransform as on Node 18. 169/169semgrep/ci(7)path-join-resolve-traversalon the new harness — the same rule already suppressed for 5 other filesBuild & verify executablepkg-fetch3.6.x dropped Node 20 entirely (release v3.6 ships only node 22/24/26; v3.5 has 14–24)@yao-pkg/pkg@6.19.0, the last release on the 3.5 line.OK: ./percy is healthy/OK: ./percy is x86-64fail-fast: false, so one red package cancelled the restwindows.ymlalready had itTwo of those were mine rather than Node's — the
sdk-utilsregression and the over-broad first cut of its fix (which brokecli,cli-build,cli-snapshotandcli-uploadbefore being scoped correctly). Both are described in their commits.Open decisions
rollup.config.js:56— the third hardcodednode: '14', compiling the browser bundles. Left untouched on purpose; changing it alters code injected into customer pages.Merge order — resolved
An earlier revision of this description said #2382 must merge first, warning that the
enginessweep here "rewritespackages/cli-upload/package.jsonwholesale and would clobber theimage-sizeremoval." Both halves of that are now obsolete:probe-image-sizedependency while letting the package do the bounded read. fix(cli-upload): replace image-size with probe-image-size (PER-10427) #2382 is closed.packages/cli-upload/package.jsonis the singleenginesline — not a wholesale rewrite.masteris merged in and the file carries both:engines: >=20andprobe-image-size: ^7.3.0, withimage-sizegone fromyarn.lockentirely.No ordering constraint remains.
Known breaking changes
engines: >=20across all 18 packages —EBADENGINEon older Node, hard fail underengine-strict. Should ship as a major.dist/compiled for Node 20 — syntax errors below it. Thebin/run.cjsguard turns that into a readable message for CLI users, but not for SDKs importing@percy/coredirectly.percy-linux.zipnow requires glibc ≥2.28 — drops CentOS 7, RHEL 7, Amazon Linux 2, Ubuntu 18.04.Also worth flagging: Node 20 reached EOL on 30 April 2026. This improves the posture substantially over Node 14 (three years of CVEs, plus OpenSSL 3 instead of an EOL 1.1.1) but will not clear a scanner rule that flags unsupported majors. Treat it as a known-temporary landing spot and schedule 22/24.
🤖 Generated with Claude Code