diff --git a/.github/.cache-key b/.github/.cache-key index b25300ecf..d05221f2f 100644 --- a/.github/.cache-key +++ b/.github/.cache-key @@ -6,5 +6,5 @@ ; ; / \ _____________/_ __ \_____________ -Times we have broken CI: 3 +Times we have broken CI: 4 Times Windows has broken CI: 99+ diff --git a/packages/core/src/browser.js b/packages/core/src/browser.js index 74f3eade3..ad174b757 100644 --- a/packages/core/src/browser.js +++ b/packages/core/src/browser.js @@ -20,8 +20,11 @@ export class Browser extends EventEmitter { #lastid = 0; args = [ - // disable the translate popup and optimization downloads - '--disable-features=Translate,OptimizationGuideModelDownloading', + // Disable Chrome features that break asset discovery in v143 new-headless: + // site/origin isolation (so cross-origin events stay on the page session), + // HTTPS-first auto-upgrade (would block HTTP discovery), and Local Network + // Access permission checks (would block sub-resources to localhost/RFC1918). + '--disable-features=Translate,OptimizationGuideModelDownloading,IsolateOrigins,site-per-process,HttpsFirstBalancedModeAutoEnable,LocalNetworkAccessChecks', // disable several subsystems which run network requests in the background '--disable-background-networking', // disable task throttling of timer tasks from background pages @@ -278,7 +281,7 @@ export class Browser extends EventEmitter { let handleExitClose = () => handleError(); let handleError = error => cleanup(() => reject(new Error( - `Failed to launch browser. ${error?.message ?? ''}\n${stderr}'\n\n` + `Failed to launch browser. ${error && error.message ? error.message : ''}\n${stderr}'\n\n` ))); let cleanup = callback => { diff --git a/packages/core/src/install.js b/packages/core/src/install.js index ac87c89bd..d5f3ff391 100644 --- a/packages/core/src/install.js +++ b/packages/core/src/install.js @@ -175,13 +175,14 @@ export function chromium({ }); } -// 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' }; // export the namespace by default diff --git a/packages/core/src/network.js b/packages/core/src/network.js index dba250149..0d9fcf28d 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -41,7 +41,7 @@ export class Network { this.captureMockedServiceWorker = options.captureMockedServiceWorker ?? false; this.userAgent = options.userAgent ?? // by default, emulate a non-headless browser - page.session.browser.version.userAgent.replace('Headless', ''); + page.session.browser?.version?.userAgent?.replace('Headless', ''); this.fontDomains = options.fontDomains || []; this.intercept = options.intercept; this.meta = options.meta; @@ -205,6 +205,13 @@ export class Network { _handleRequestPaused = async (session, event) => { let { networkId: requestId, requestId: interceptId, resourceType } = event; + // 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 await this.#requestsLifeCycleHandler.get(requestId).requestWillBeSent; let pending = this.#pending.get(requestId); @@ -217,6 +224,95 @@ export class Network { await this._handleRequest(session, { ...pending, resourceType, interceptId }); } + // Reads the body via Fetch.getResponseBody, saves the resource, and forgets + // the request — so the lifecycle is complete before Chrome dispatches + // Network.loadingFinished. This handles Chrome 143's split-session worker + // scripts (Network events fire on the worker session under a different + // requestId) and aborts oversized or malformed Content-Length responses + // before the body streams. + _handleResponsePaused = async (session, event) => { + let { networkId: requestId, requestId: interceptId, responseHeaders, responseStatusCode, responseErrorReason } = event; + let request = this.#requests.get(requestId); + let url = request ? originURL(request) : event.request?.url; + let headersObj = headersArrayToObject(responseHeaders); + let mimeType = headersObj['Content-Type'] || headersObj['content-type'] || ''; + let { tooLarge, malformed, rawValue } = inspectContentLength(headersObj); + + // Errored response: continue so Chrome's natural Network.loadingFailed + // propagates the original errorText to _handleLoadingFailed. + if (responseErrorReason) { + return this._continueResponse(session, interceptId, url); + } + + // Oversized or malformed Content-Length: abort before body streams. + 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); + if (request) { + this._forgetRequest(request); + this.#requestsLifeCycleHandler.get(requestId).resolveResponseReceived(); + } + try { + await this.send(session, 'Fetch.failRequest', { requestId: interceptId, errorReason: 'Aborted' }); + } catch (error) { + /* istanbul ignore next: race with abort/close */ + this.log.debug(`Failed to abort oversized response for ${url}: ${error.message}`); + } + return; + } + + // Redirect: skip — _handleRequest builds the redirect chain on the next request stage. + if (responseStatusCode >= 300 && responseStatusCode < 400) { + return this._continueResponse(session, interceptId, url); + } + + // Streaming responses (SSE) where the body never completes — would hang Fetch.getResponseBody. + if (mimeType.includes('text/event-stream')) { + return this._continueResponse(session, interceptId, url); + } + + // Normal response: read body now, save the resource, forget the request. + if (request) { + let meta = { ...this.meta, url }; + try { + let result = await this.send(session, 'Fetch.getResponseBody', { requestId: interceptId }); + let bodyBuffer = Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf-8'); + + request.response = { + url: event.request?.url, + status: responseStatusCode, + statusText: event.responseStatusText, + mimeType, + headers: headersObj, + buffer: async () => bodyBuffer + }; + this.#requestsLifeCycleHandler.get(requestId).resolveResponseReceived(); + await saveResponseResource(this, request, session); + } catch (error) { + this.log.debug(`Encountered an error processing resource: ${url}`, meta); + this.log.debug(error, meta); + } + this._forgetRequest(request); + } + + await 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) { + /* istanbul ignore next: race with abort/close */ + 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; // otherwise set it to be pending until it is paused. _handleRequestWillBeSent = async event => { @@ -434,6 +530,24 @@ function originURL(request) { return normalizeURL((request.redirectChain[0] || request).url); } +// 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); + 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 // Only validates domains that returned 200 status async function validateDomainForAllowlist(network, hostname, url, statusCode) { @@ -512,8 +626,10 @@ async function sendResponseResource(network, request, session) { .map(([k, v]) => ({ name: k.toLowerCase(), value: String(v) })) }); } 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 }); } } catch (error) { @@ -558,7 +674,7 @@ async function makeDirectRequest(network, request, session) { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', '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', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-user': '?1', @@ -590,19 +706,8 @@ async function saveResponseResource(network, request, session) { url, responseStatus: response?.status }; - // 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?.[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); if (!resource || (!resource.root && !resource.provided && disableCache)) { diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 898bc8b42..8e5c3d54a 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -1,4 +1,5 @@ import { sha256hash } from '@percy/client/utils'; +import { waitFor } from '@percy/core/utils'; import { logger, api, setupTest, createTestServer, dedent, mockRequests } from './helpers/index.js'; import Percy from '@percy/core'; import { RESOURCE_CACHE_KEY } from '../src/discovery.js'; @@ -457,6 +458,48 @@ describe('Discovery', () => { ])); }); + it('captures favicon when the server provides one', async () => { + server.reply('/favicon.ico', () => [200, 'image/x-icon', pixel]); + let faviconDOM = testDOM.replace('', ''); + + await percy.snapshot({ + name: 'favicon snapshot', + url: 'http://localhost:8000', + domSnapshot: faviconDOM + }); + + await percy.idle(); + + expect(captured[0]).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/favicon.ico' + }) + }) + ])); + }); + + it('captures auto-fetched favicon when the page does not declare one', async () => { + percy.set({ discovery: { networkIdleTimeout: 1000 } }); + server.reply('/favicon.ico', () => [200, 'image/x-icon', pixel]); + + await percy.snapshot({ + name: 'auto-fetch favicon snapshot', + url: 'http://localhost:8000', + domSnapshot: testDOM + }); + + await percy.idle(); + + expect(captured[0]).toEqual(jasmine.arrayContaining([ + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://localhost:8000/favicon.ico' + }) + }) + ])); + }); + it('does not capture event-stream requests', async () => { let eventStreamDOM = dedent`