@percy/core
Advanced tools
+17
-5
@@ -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); |
+7
-6
@@ -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 @@ |
+261
-33
@@ -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 @@ } |
+9
-9
| { | ||
| "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 @@ }); |
Potential vulnerability
Supply chain riskInitial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
543500
2.1%8618
2.82%22
4.76%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated