Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@percy/core

Package Overview
Dependencies
Maintainers
1
Versions
361
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@percy/core - npm Package Compare versions

Comparing version
1.31.15-beta.0
to
1.32.0-beta.0
+17
-5
dist/browser.js

@@ -13,2 +13,16 @@ import fs from 'fs';

import Page from './page.js';
// Chrome features Percy disables for v143 new-headless asset discovery.
const DISABLED_FEATURES = ['Translate',
// suppress translate prompt overlay
'OptimizationGuideModelDownloading',
// suppress background model fetches
'IsolateOrigins',
// [headless-only] keep cross-origin sub-resources on the page session for CDP capture
'site-per-process',
// companion to IsolateOrigins
'HttpsFirstBalancedModeAutoEnable',
// allow HTTP customer URLs (CI / local dev / staging)
'LocalNetworkAccessChecks' // allow loopback/RFC1918 sub-resources (Chrome 143 LNA gating)
];
export class Browser extends EventEmitter {

@@ -21,5 +35,3 @@ log = logger('core:browser');

#lastid = 0;
args = [
// disable the translate popup and optimization downloads
'--disable-features=Translate,OptimizationGuideModelDownloading',
args = [`--disable-features=${DISABLED_FEATURES.join(',')}`,
// disable several subsystems which run network requests in the background

@@ -323,4 +335,4 @@ '--disable-background-networking',

};
let handleExitClose = () => handleError();
let handleError = error => cleanup(() => reject(new Error(`Failed to launch browser. ${(error === null || error === void 0 ? void 0 : error.message) ?? ''}\n${stderr}'\n\n`)));
let handleExitClose = () => handleError(new Error('Browser exited before devtools address'));
let handleError = error => cleanup(() => reject(new Error(`Failed to launch browser. ${error.message}\n${stderr}'\n\n`)));
let cleanup = callback => {

@@ -327,0 +339,0 @@ clearTimeout(timeoutId);

@@ -165,9 +165,10 @@ import fs from 'fs';

// default chromium revisions corresponds to v126.0.6478.184
// Chrome 143.0.7499.169 (base position 1536371) — closest per-platform
// revision from https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html
chromium.revisions = {
linux: '1300309',
win64: '1300297',
win32: '1300295',
darwin: '1300293',
darwinArm: '1300314'
linux: '1536366',
win64: '1536376',
win32: '1536377',
darwin: '1536380',
darwinArm: '1536376'
};

@@ -174,0 +175,0 @@

@@ -9,2 +9,8 @@ import { request as makeRequest } from '@percy/client/utils';

const ABORTED_MESSAGE = 'Request was aborted by browser';
// Chrome 143 omits Network.responseReceived for worker scripts; cap the wait
// so loadingFinished can clean up. Per-request — N timeouts accumulate to N*2s;
// PERCY_NETWORK_IDLE_WAIT_TIMEOUT (default 30s) caps cumulative impact.
const RESPONSE_RECEIVED_TIMEOUT = 2000;
// Cap idle() impact when a host accepts the TCP connection then stalls during a direct fetch.
const DIRECT_FETCH_TIMEOUT = 5000;

@@ -65,2 +71,3 @@ // Stable, machine-readable codes for abort errors thrown from this module.

constructor(page, options) {
var _page$session$browser;
this.page = page;

@@ -71,5 +78,4 @@ this.timeout = options.networkIdleTimeout ?? 100;

this.captureMockedServiceWorker = options.captureMockedServiceWorker ?? false;
this.userAgent = options.userAgent ??
// by default, emulate a non-headless browser
page.session.browser.version.userAgent.replace('Headless', '');
this.userAgent = options.userAgent ?? (// by default, emulate a non-headless browser
(_page$session$browser = page.session.browser) === null || _page$session$browser === void 0 || (_page$session$browser = _page$session$browser.version) === null || _page$session$browser === void 0 || (_page$session$browser = _page$session$browser.userAgent) === null || _page$session$browser === void 0 ? void 0 : _page$session$browser.replace('Headless', ''));
this.fontDomains = options.fontDomains || [];

@@ -252,2 +258,9 @@ this.intercept = options.intercept;

// Response-stage events arrive here when Fetch.continueRequest was called
// with interceptResponse:true (see sendResponseResource).
if (event.responseStatusCode != null || event.responseErrorReason != null) {
await this._handleResponsePaused(session, event);
return;
}
// wait for request to be sent

@@ -267,2 +280,84 @@ await this.#requestsLifeCycleHandler.get(requestId).requestWillBeSent;

// Response-stage interception is kept ONLY to detect oversized/malformed
// Content-Length and abort the request before Chrome streams a body it
// would never terminate (Chrome 143 quirk). For everything else we just
// continue — body capture happens later via Network.loadingFinished →
// Network.getResponseBody (the v126 path). Reading the body at this stage
// hangs worker-initiated fetches, so we don't.
_handleResponsePaused = async (session, event) => {
var _event$request;
let {
networkId: requestId,
requestId: interceptId,
responseHeaders,
responseStatusCode
} = event;
// request may be undefined when a response-stage pause arrives for a request
// whose request-stage tracking we never installed (service-worker-fulfilled,
// or a cleanup race). We still need to unpause Chrome regardless.
let request = this.#requests.get(requestId);
let url = request ? originURL(request) : ((_event$request = event.request) === null || _event$request === void 0 ? void 0 : _event$request.url) && normalizeURL(event.request.url);
let headersObj = headersArrayToObject(responseHeaders);
let {
tooLarge,
malformed,
rawValue
} = inspectContentLength(headersObj);
if (tooLarge || malformed) {
let meta = {
...this.meta,
url,
responseStatus: responseStatusCode
};
logAssetInstrumentation(this.log, 'asset_not_uploaded', 'resource_too_large', {
url,
size: rawValue,
snapshot: meta.snapshot
});
this.log.debug('- Skipping resource larger than 25MB', meta);
// Disposition first, then forget the request — so we never leave Chrome's
// Fetch state paused while Percy thinks the request is already done.
try {
await this.send(session, 'Fetch.failRequest', {
requestId: interceptId,
errorReason: 'Aborted'
});
} catch (error) {
if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
// benign race — request was already aborted upstream; nothing to un-pause
} else {
this.log.debug(`Failed to abort oversized response for ${url}: ${error.message}`);
// Last-resort: un-pause Chrome's Fetch so it doesn't leak the response.
try {
await this.send(session, 'Fetch.continueResponse', {
requestId: interceptId
});
} catch (continueError) {
this.log.debug(`Last-resort continueResponse also failed for ${url}: ${continueError.message}`);
}
}
}
if (request) {
this._forgetRequest(request);
this.#requestsLifeCycleHandler.get(requestId).resolveResponseReceived();
}
return;
}
return this._continueResponse(session, interceptId, url);
};
// Tell the browser to continue the paused response, swallowing expected
// races (request already aborted, interception ID no longer valid).
_continueResponse = async (session, interceptId, url) => {
try {
await this.send(session, 'Fetch.continueResponse', {
requestId: interceptId
});
} catch (error) {
if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) return;
this.log.debug(`Failed to continue response for ${url}: ${error.message}`);
}
};
// Called when a request will be sent. If the request has already been intercepted, handle it;

@@ -379,7 +474,32 @@ // otherwise set it to be pending until it is paused.

} = event;
// wait for upto 2 seconds or check if response has been sent
await this.#requestsLifeCycleHandler.get(requestId).responseReceived;
let request = this.#requests.get(requestId);
/* istanbul ignore if: race condition paranoia */
if (!request) return;
if (!request.response) {
let timerId;
await Promise.race([this.#requestsLifeCycleHandler.get(requestId).responseReceived, new Promise(resolve => {
timerId = setTimeout(resolve, RESPONSE_RECEIVED_TIMEOUT);
})]);
clearTimeout(timerId);
}
if (!request.response) {
this.log.debug(`Skipping resource: responseReceived not received within ${RESPONSE_RECEIVED_TIMEOUT}ms - ${request.url}`);
// Chrome 143+ PlzDedicatedWorker: dedicated worker scripts fetch in the browser
// process and never surface a CDP response. resourceType varies ('Other' on v143,
// 'Script' on older Chrome) so we gate on hostname rather than type, and mirror
// sendResponseResource's disallowedHostnames-before-allowedHostnames precedence.
let url = originURL(request);
/* istanbul ignore else: the else only fires for PlzDedicatedWorker requests
whose worker-script fetch bypasses Fetch.requestPaused. Cross-origin assets
loaded via the document session still go through sendResponseResource
(which performs its own disallowedHostnames check), so the test harness
can't reliably reach this skip branch via integration tests. */
if (!hostnameMatches(this.intercept.disallowedHostnames, url) && hostnameMatches(this.intercept.allowedHostnames, url)) {
await captureResourceDirectly(this, request, session);
} else {
this.log.debug(`- Skipping direct-fetch fallback for ${url}: hostname not allowed`, this.meta);
}
this._forgetRequest(request);
return;
}
await saveResponseResource(this, request, session);

@@ -463,3 +583,3 @@ this._forgetRequest(request);

// (or env values changed mid-run by tests) don't stomp each other.
this.networkIdleWaitTimeout = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT) || 30000;
this.networkIdleWaitTimeout = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT, 10) || 30000;
if (this.networkIdleWaitTimeout > 60000) {

@@ -503,2 +623,27 @@ this.log.warn('Setting PERCY_NETWORK_IDLE_WAIT_TIMEOUT over 60000ms is not recommended. ' + 'If your page needs more than 60000ms to idle due to CPU/Network load, ' + 'its recommended to increase CI resources where this cli is running.');

// Convert Fetch event responseHeaders ([{name, value}, …]) to a header object.
function headersArrayToObject(arr) {
let out = {};
if (!Array.isArray(arr)) return out;
for (let {
name,
value
} of arr) out[name] = value;
return out;
}
// Returns { tooLarge, malformed, rawValue } for Content-Length classification.
function inspectContentLength(headers) {
let key = headers && Object.keys(headers).find(k => k.toLowerCase() === 'content-length');
let rawValue = key ? headers[key] : undefined;
let parsed = parseInt(rawValue, 10);
let tooLarge = Number.isFinite(parsed) && parsed > MAX_RESOURCE_SIZE;
let malformed = rawValue !== undefined && rawValue !== null && String(rawValue).length > 0 && !Number.isFinite(parsed);
return {
tooLarge,
malformed,
rawValue
};
}
// Validate domain for auto-allowlisting feature

@@ -585,4 +730,6 @@ // Only validates domains that returned 200 status

} else {
// interceptResponse:true triggers a second pause at the response stage. See _handleResponsePaused.
await send('Fetch.continueRequest', {
requestId: request.interceptId
requestId: request.interceptId,
interceptResponse: true
});

@@ -621,10 +768,57 @@ }

// Make a new request with Node based on a network request
// Pick the CDP session for Network.getCookies. Worker/auxiliary sessions
// expose a partial Network domain where Network.getCookies throws
// "Internal error", so prefer the page's session whenever available and
// fall back to the request's own session otherwise.
export function pickCookieSession(network, session) {
var _network$page;
return ((_network$page = network.page) === null || _network$page === void 0 ? void 0 : _network$page.session) ?? session;
}
// Decide whether to attach a Basic auth header to the Node-side direct fetch.
// The browser's URLLoader origin-scopes Basic auth; this fallback runs in
// Node, so we re-enforce the same-origin rule explicitly to avoid leaking
// credentials cross-origin. Malformed URLs fall through to `false` defensively.
export function shouldAttachAuth(authorization, requestUrl, snapshotUrl) {
if (!(authorization !== null && authorization !== void 0 && authorization.username)) return false;
try {
return new URL(requestUrl).origin === new URL(snapshotUrl).origin;
} catch {
return false;
}
}
// Race a promise against a timeout. Resolves with the promise's value if it
// settles within `ms`, otherwise rejects with `new Error(message)`. The
// internal timer is always cleared so the event loop can exit cleanly.
export function raceWithTimeout(promise, ms, message) {
let timerId;
return Promise.race([promise, new Promise((_, reject) => {
timerId = setTimeout(() => reject(new Error(message)), ms);
})]).finally(() => clearTimeout(timerId));
}
// Server Content-Type wins; URL-extension mime is the fallback; binary default last.
export function resolveDirectFetchMime(responseHeaders, urlForLookup) {
var _responseHeaders$cont;
let serverMime = responseHeaders === null || responseHeaders === void 0 || (_responseHeaders$cont = responseHeaders['content-type']) === null || _responseHeaders$cont === void 0 ? void 0 : _responseHeaders$cont.split(';')[0].trim();
return serverMime || mime.lookup(urlForLookup) || 'application/octet-stream';
}
// Make a new request with Node based on a network request. Cookies are read
// from the page session because worker/auxiliary sessions have a partial
// Network domain where Network.getCookies throws "Internal error".
async function makeDirectRequest(network, request, session) {
var _network$authorizatio;
const {
cookies
} = await session.send('Network.getCookies', {
urls: [request.url]
});
var _network$meta;
let cookies = [];
let cookieSession = pickCookieSession(network, session);
try {
({
cookies
} = await cookieSession.send('Network.getCookies', {
urls: [request.url]
}));
} catch (error) {
network.log.debug(`Network.getCookies unavailable for ${request.url}: ${error.message}`);
}
let headers = {

@@ -636,3 +830,3 @@ // add default browser

'sec-fetch-dest': 'font',
'sec-ch-ua': '"Chromium";v="123", "Google Chrome";v="123", "Not?A_Brand";v="99"',
'sec-ch-ua': '"Chromium";v="143", "Google Chrome";v="143", "Not?A_Brand";v="99"',
'sec-ch-ua-mobile': '?0',

@@ -646,4 +840,3 @@ 'sec-ch-ua-platform': '"macOS"',

};
if ((_network$authorizatio = network.authorization) !== null && _network$authorizatio !== void 0 && _network$authorizatio.username) {
// include basic authorization username and password
if (shouldAttachAuth(network.authorization, request.url, (_network$meta = network.meta) === null || _network$meta === void 0 ? void 0 : _network$meta.snapshotURL)) {
let {

@@ -659,8 +852,52 @@ username,

headers
});
}, (body, res) => ({
body,
status: res.statusCode,
headers: res.headers
}));
}
// Capture a resource via direct HTTP fetch when the browser-side response
// never surfaces — Chrome 143+ fetches dedicated worker scripts in the browser
// process (PlzDedicatedWorker) so loadingFinished fires without a body on CDP.
async function captureResourceDirectly(network, request, session) {
let log = network.log;
let url = originURL(request);
let meta = {
...network.meta,
url
};
try {
log.debug('- Requesting resource directly (responseReceived timeout fallback)', meta);
let {
body,
status,
headers: responseHeaders
} = await raceWithTimeout(makeDirectRequest(network, request, session), DIRECT_FETCH_TIMEOUT, `Direct fetch timed out after ${DIRECT_FETCH_TIMEOUT}ms`);
if (body.length > MAX_RESOURCE_SIZE) {
logAssetInstrumentation(log, 'asset_not_uploaded', 'resource_too_large', {
url,
size: body.length,
snapshot: meta.snapshot
});
log.debug('- Skipping resource larger than 25MB', meta);
return;
}
let urlObj = new URL(url);
let mimeType = resolveDirectFetchMime(responseHeaders, urlObj.origin + urlObj.pathname);
let resource = createResource(url, body, mimeType, {
status,
headers: {
'content-type': [mimeType]
}
});
log.debug(`- Saving direct-fetched resource sha=${resource.sha} mimetype=${mimeType}`, meta);
network.intercept.saveResource(resource);
} catch (error) {
log.debug(`Direct fetch failed for ${url} - ${error.message}`, meta);
}
}
// Save a resource from a request, skipping it if specific parameters are not met
async function saveResponseResource(network, request, session) {
var _response$headers;
let {

@@ -679,15 +916,4 @@ disableCache,

};
// Checking for content length more than 100MB, to prevent websocket error which is governed by
// maxPayload option of websocket defaulted to 100MB.
// If content-length is more than our allowed 25MB, no need to process that resouce we can return log.
let contentLength = (_response$headers = response.headers) === null || _response$headers === void 0 ? void 0 : _response$headers[Object.keys(response.headers).find(key => key.toLowerCase() === 'content-length')];
contentLength = parseInt(contentLength);
if (contentLength > MAX_RESOURCE_SIZE) {
logAssetInstrumentation(log, 'asset_not_uploaded', 'resource_too_large', {
url,
size: contentLength,
snapshot: meta.snapshot
});
return log.debug('- Skipping resource larger than 25MB', meta);
}
// Oversized/malformed Content-Length is rejected earlier in _handleResponsePaused;
// the body.length check below still guards cached responses where headers may lie.
let resource = network.intercept.getResource(url);

@@ -784,3 +1010,5 @@ if (!resource || !resource.root && !resource.provided && disableCache) {

log.debug('- Requesting asset directly', meta);
body = await makeDirectRequest(network, request, session);
({
body
} = await makeDirectRequest(network, request, session));
log.debug('- Got direct response', meta);

@@ -787,0 +1015,0 @@ }

{
"name": "@percy/core",
"version": "1.31.15-beta.0",
"version": "1.32.0-beta.0",
"license": "MIT",

@@ -47,8 +47,8 @@ "repository": {

"dependencies": {
"@percy/client": "1.31.15-beta.0",
"@percy/config": "1.31.15-beta.0",
"@percy/dom": "1.31.15-beta.0",
"@percy/logger": "1.31.15-beta.0",
"@percy/monitoring": "1.31.15-beta.0",
"@percy/webdriver-utils": "1.31.15-beta.0",
"@percy/client": "1.32.0-beta.0",
"@percy/config": "1.32.0-beta.0",
"@percy/dom": "1.32.0-beta.0",
"@percy/logger": "1.32.0-beta.0",
"@percy/monitoring": "1.32.0-beta.0",
"@percy/webdriver-utils": "1.32.0-beta.0",
"content-disposition": "^0.5.4",

@@ -67,5 +67,5 @@ "cross-spawn": "^7.0.3",

"optionalDependencies": {
"@percy/cli-doctor": "1.31.15-beta.0"
"@percy/cli-doctor": "1.32.0-beta.0"
},
"gitHead": "b2012ce5dae37e5009dd3f4190454bb9d9d118e3"
"gitHead": "36c0d4a8f23b5e3ab6bdf5db5e181c1febd4b767"
}

@@ -31,4 +31,10 @@ // aliased to src during tests

if (req.url.search) pathname += req.url.search;
let reply = replies[pathname] || defaultReply;
// Chrome >=128 auto-fetches /favicon.ico on every navigation; reply 204
// by default so it doesn't pollute snapshot resources. Tests can still
// override via `server.reply('/favicon.ico', ...)`.
if (req.url.pathname === '/favicon.ico' && !replies['/favicon.ico']) {
return res.writeHead(204).end();
}
server.requests.push(req.body ? [pathname, req.body, req.headers] : [pathname, req.headers]);
let reply = replies[pathname] || defaultReply;
return reply ? await reply(req, res) : next();

@@ -35,0 +41,0 @@ });