Skip to content
Draft
2 changes: 1 addition & 1 deletion .github/.cache-key
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
; ;
/ \
_____________/_ __ \_____________
Times we have broken CI: 4
Times we have broken CI: 5
Times Windows has broken CI: 99+
2 changes: 1 addition & 1 deletion .github/workflows/executable-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 14
node-version: 22
architecture: x64
- name: Build executables (no signing, no upload)
run: ./scripts/executable.sh
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/executable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 14
node-version: 22
architecture: x64
- run: ./scripts/executable.sh
env:
Expand Down Expand Up @@ -53,7 +53,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- name: Install resedit
run: npm install resedit
- name: Update exe metadata
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,19 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
- run: yarn
- run: yarn lint
14 changes: 7 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,19 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
- run: yarn
- run: yarn build
Expand All @@ -58,7 +58,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
node: [14]
node: [22]
package:
- '@percy/env'
- '@percy/client'
Expand Down Expand Up @@ -171,19 +171,19 @@ jobs:
fetch-depth: 50
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,19 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
- run: yarn
- run: yarn test:types
12 changes: 6 additions & 6 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,19 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
- run: yarn
- run: yarn build
Expand Down Expand Up @@ -85,19 +85,19 @@ jobs:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: 14
node-version: 22
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-14/
${{ runner.os }}/node-22/
${{ hashFiles('.github/.cache-key') }}/
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
Expand Down
17 changes: 14 additions & 3 deletions packages/cli-doctor/test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export function createHttpServer(handler) {
* @param {'block'} [opts.mode] Return 502 for everything
* @returns {Promise<{server: net.Server, url: string, port: number, close: function}>}
*/
// Minimal proxy double. Every response below closes the socket immediately, so
// each one must say `Connection: close`. Node's http.globalAgent enables
// keepAlive by default from Node 19, and without that header the client pools
// the socket and reuses it for the next request — which then fails with
// ECONNRESET ("socket hang up") because the server already ended it.
export function createProxyServer(opts = {}) {
return new Promise((resolve, reject) => {
const sockets = new Set();
Expand All @@ -78,7 +83,9 @@ export function createProxyServer(opts = {}) {

// ── block mode ──────────────────────────────────────────────────────
if (opts.mode === 'block') {
clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
clientSocket.end(
'HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'
);
return;
}

Expand All @@ -89,6 +96,7 @@ export function createProxyServer(opts = {}) {
clientSocket.end(
'HTTP/1.1 407 Proxy Authentication Required\r\n' +
'Proxy-Authenticate: Basic realm="proxy"\r\n' +
'Connection: close\r\n' +
'Content-Length: 0\r\n\r\n'
);
return;
Expand All @@ -97,7 +105,8 @@ export function createProxyServer(opts = {}) {
const [user, pass] = decoded.split(':');
if (user !== opts.auth.user || pass !== opts.auth.pass) {
clientSocket.end(
'HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n'
'HTTP/1.1 407 Proxy Authentication Required\r\n' +
'Connection: close\r\nContent-Length: 0\r\n\r\n'
);
return;
}
Expand Down Expand Up @@ -126,7 +135,9 @@ export function createProxyServer(opts = {}) {

// ── Plain HTTP proxy ────────────────────────────────────────────────
// For non-CONNECT requests just return 200 (sufficient for our tests)
clientSocket.end('HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n');
clientSocket.end(
'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'
);
};

clientSocket.on('data', onData);
Expand Down
32 changes: 24 additions & 8 deletions packages/cli-exec/test/exec.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ describe('percy exec', () => {
spyOn(process, 'exit').and.callFake(c => c);
process.env.PERCY_CLIENT_ERROR_LOGS = false;

// Ensure global.__MOCK_IMPORTS__ is defined
global.__MOCK_IMPORTS__ = global.__MOCK_IMPORTS__ || new Map();
// The loader defines this registry when scripts/loader-register.js is
// imported. Substituting a plain Map when it is missing would be worse than
// useless: the loader would not consult it, so every mock below would
// silently no-op while the suite still reported green.
if (!global.__MOCK_IMPORTS__) {
throw new Error('global.__MOCK_IMPORTS__ is undefined — the test loader is not registered');
}
});

afterEach(() => {
Expand Down Expand Up @@ -275,9 +280,14 @@ describe('percy exec', () => {
'[percy] Finalized build #1: https://percy.io/test/test/123'
]));

expect(logger.instance.query(log => log.debug === 'ci')[0].message).toContain([
'Some error with secret: [REDACTED]'
]);
// Each 'data' event on the child's stderr becomes its own ci log entry, so
// indexing [0] assumes the error arrives in the very first chunk. Node 22
// emits an "[UNDICI-EHPA] EnvHttpProxyAgent is experimental" warning ahead
// of it, which took that slot; stream chunking makes the position
// unreliable in general. Assert the redacted secret is captured somewhere.
expect(logger.instance.query(log => log.debug === 'ci')
.map(log => log.message).join('')
).toContain('Some error with secret: [REDACTED]');
expect(stderrSpy).toHaveBeenCalled();
});

Expand Down Expand Up @@ -325,15 +335,21 @@ describe('percy exec', () => {
it('throws when the command receives an error event and stops percy', async () => {
let { default: EventEmitter } = await import('events');
let [e, err] = [new EventEmitter(), new Error('spawn error')];
let crossSpawn = () => (setImmediate(() => e.emit('error', err)), e);
// A spy, not a bare function: spawning the nonexistent `foobar` below fails
// with the real cross-spawn too, so without an assertion on the double this
// spec would pass even when the loader mock never applied.
let crossSpawn = jasmine.createSpy('crossSpawn').and.callFake(() => (
setImmediate(() => e.emit('error', err)), e
));
global.__MOCK_IMPORTS__.set('cross-spawn', { default: crossSpawn });

let stdinSpy = spyOn(process.stdin, 'pipe').and.resolveTo('some response');

await expectAsync(exec(['--', 'foobar'])).toBeRejected();

// proves the loader mock actually applied (see §9.1 of the migration plan)
expect(crossSpawn).toHaveBeenCalled();
expect(stdinSpy).toHaveBeenCalled();
console.log(logger.stderr);
expect(logger.stderr).toEqual(jasmine.arrayContaining([
'[percy] Detected error for percy build',
'[percy] Failure: Snapshot command was not called',
Expand Down Expand Up @@ -386,7 +402,7 @@ describe('percy exec', () => {
});

it('provides the child process with a percy server address env var', async () => {
let args = ['--no-warnings', '--input-type=module', '--loader=../../scripts/loader.js'];
let args = ['--no-warnings', '--input-type=module', '--import=../../scripts/loader-register.js'];

await exec(['--port=4567', '--', 'node', ...args, '--eval', [
'import { request } from "../cli-command/src/utils.js";',
Expand Down
5 changes: 4 additions & 1 deletion packages/client/test/unit/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ describe('proxy', () => {
const url = 'http://example.com';
const options = {};
process.env.PERCY_PAC_FILE_URL = 'invalid-url';
expect(() => proxyAgentFor(url, options)).toThrowError('Failed to initialize PAC proxy: Invalid URL: invalid-url');
// Matched as a prefix: Node <18 appends the offending input to the
// `Invalid URL` TypeError ("Invalid URL: invalid-url"), Node >=18 does
// not. Asserting the full string pins this test to one Node version.
expect(() => proxyAgentFor(url, options)).toThrowError(/^Failed to initialize PAC proxy: Invalid URL/);
});
});
});
33 changes: 30 additions & 3 deletions packages/config/test/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ const INTERNAL_FILE_REG = new RegExp(
// Used to mock javascript modules
const JS_FILE_REG = /\.(c|m)?js$/;

// Normalize an fs path argument before matching it against the bypass list.
//
// `fs` accepts a path as a string, a Buffer, a `file:` URL, or a file
// descriptor, but every bypass matcher below is written against a string
// (`p.includes('node_modules')`, `p.match(INTERNAL_FILE_REG)`). On a URL object
// those are `undefined`, so the matcher silently returns falsy and the read is
// routed into the in-memory volume instead of being let through.
//
// This matters from Node 22: `module.registerHooks` intercepts `require()` as
// well as `import` (the old `--experimental-loader` did not), and Node reads
// CommonJS sources through the public `fs` using a URL. Without this, the first
// lazy `require()` of a real dependency inside a mockfs block -- cosmiconfig
// requiring js-yaml to parse a config file -- throws ENOENT.
//
// File descriptors are numbers and are passed through untouched, since the
// descriptor matcher below tests them directly.
function bypassTarget(filepath) {
if (filepath instanceof URL) return url.fileURLToPath(filepath);
if (Buffer.isBuffer(filepath)) return filepath.toString('utf8');
return filepath;
}

// Mock and spy on fs methods using an in-memory filesystem
export async function mockfs({
// set `true` to allow mocking files within `node_modules` (may cause dynamic import issues)
Expand Down Expand Up @@ -76,9 +98,14 @@ export async function mockfs({
let installFakes = (og, fake) => {
for (let k in og) {
if (k in fake && typeof og[k] === 'function' && !FS_CLASSES.includes(k)) {
spyOn(og, k).and.callFake((...args) => bypass.some(p => (
typeof p === 'function' ? p(...args) : (p === args[0])
)) ? og[k].and.originalFn(...args) : fake[k](...args));
spyOn(og, k).and.callFake((...args) => {
let [filepath, ...rest] = args;
let target = bypassTarget(filepath);

return bypass.some(p => (
typeof p === 'function' ? p(target, ...rest) : (p === target)
)) ? og[k].and.originalFn(...args) : fake[k](...args);
});
}
}
};
Expand Down
4 changes: 3 additions & 1 deletion packages/core/post-install.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ try {
} else if (!process.send && fs.existsSync('./src')) {
// In development, fork this script with the development loader and always install
await import('child_process').then(cp => cp.fork('./post-install.js', {
execArgv: ['--no-warnings', '--loader=../../scripts/loader.js'],
// --import (not --loader): the hooks are registered in-process via
// module.registerHooks. See scripts/loader-register.js.
execArgv: ['--no-warnings', '--import=../../scripts/loader-register.js'],
env: { PERCY_POSTINSTALL_BROWSER: true }
}));
}
Expand Down
14 changes: 12 additions & 2 deletions scripts/executable.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ function cleanup {
}

brew install gnu-sed
npm install -g pkg
# vercel/pkg is archived; its final release (5.8.1) ships no Node 22 base
# binary, so it cannot build this CLI once the toolchain moves off Node 14.
# @yao-pkg/pkg is the maintained fork; its pkg-fetch v3.6 provides prebuilt
# Node 22 binaries for linux, macos and win on both x64 and arm64.
npm install -g @yao-pkg/pkg@6.22.0

yarn install
yarn build
Expand Down Expand Up @@ -47,7 +51,13 @@ cp -R ./build/* packages/
# Create executables. (No `-d`/`--debug`: it only adds per-file "included as
# DISCLOSED code / asset content" logging — thousands of lines — without
# changing the output binaries.)
pkg ./packages/cli/bin/run.js
#
# Targets are pinned explicitly. Unpinned, pkg infers them from the host, so a
# macOS arm64 runner would silently start emitting an arm64 `percy-osx` — a
# change to what customers download, which is not this migration's call to make.
# Keep the published matrix at x64 and move only the embedded Node to 22;
# whether to add arm64 assets is a separate release decision.
pkg --targets node22-linux-x64,node22-macos-x64,node22-win-x64 ./packages/cli/bin/run.js

# Rename executables
mv run-linux percy && chmod +x percy
Expand Down
Loading
Loading