Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 3
Times we have broken CI: 4
Times Windows has broken CI: 99+
9 changes: 6 additions & 3 deletions packages/core/src/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +181 to +185
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how do you got it this, can you attach a reference here ?

};

// export the namespace by default
Expand Down
137 changes: 121 additions & 16 deletions packages/core/src/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)) {
Expand Down
107 changes: 106 additions & 1 deletion packages/core/test/discovery.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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('</head>', '<link rel="icon" href="/favicon.ico"></head>');

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`<!DOCTYPE html><html><head></head><body><script>
new EventSource('/event-stream').onmessage = event => {
Expand Down Expand Up @@ -2352,7 +2395,7 @@ describe('Discovery', () => {

it('logs unhandled response errors gracefully', async () => {
let err = new Error('some unhandled request error');
await triggerSessionEventError('Network.getResponseBody', err);
await triggerSessionEventError('Fetch.getResponseBody', err);

await percy.snapshot({
name: 'test snapshot',
Expand All @@ -2370,6 +2413,68 @@ describe('Discovery', () => {
`[percy:core:discovery] ${err.stack}`
]));
});

it('logs gracefully when direct font request fails', async () => {
server.reply('/style.css', () => [200, 'text/css', [
'@font-face { font-family: "test"; src: url("/font.woff") format("woff"); }',
'body { font-family: "test", "sans-serif"; }'
].join('')]);

// First hit (browser): octet-stream forces font fallback path.
// Second hit (makeDirectRequest): 400 makes the direct fetch throw without retrying.
let callCount = 0;
server.reply('/font.woff', () => {
if (++callCount === 1) return [200, 'application/octet-stream', '<font>'];
return [400, 'text/plain', 'bad request'];
});

await percy.snapshot({
name: 'font error snapshot',
url: 'http://localhost:8000',
domSnapshot: testDOM
});

await percy.idle();

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching('Encountered an error processing resource: http://localhost:8000/font.woff')
]));
});

it('continues responses gracefully when the request is untracked', async () => {
let snap = percy.snapshot({
name: 'untracked snapshot',
url: 'http://localhost:8000',
domSnapshot: testDOM
});

await waitFor(() => percy.browser.sessions.size > 0);
let [session] = percy.browser.sessions.values();
let sentMethods = [];
let originalSend = session.send.bind(session);
spyOn(session, 'send').and.callFake((method, params) => {
sentMethods.push({ method, params });
return originalSend(method, params);
});

// Emit a response-stage Fetch.requestPaused for a request that was never
// tracked at the request stage — exercises the defensive null-request
// branch in _handleResponsePaused.
session.emit('Fetch.requestPaused', {
networkId: 'untracked-network-id',
requestId: 'untracked-intercept-id',
responseStatusCode: 200,
responseHeaders: [],
request: { url: 'http://example.com/orphan' }
});

await snap;

expect(sentMethods.some(c =>
c.method === 'Fetch.continueResponse' &&
c.params?.requestId === 'untracked-intercept-id'
)).toBe(true);
});
});

describe('with remote resources', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/test/helpers/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ export function createTestServer({ default: defaultReply, ...replies }, port = 8
server.route(async (req, res, next) => {
let pathname = req.url.pathname;
if (req.url.search) pathname += req.url.search;
server.requests.push(req.body ? [pathname, req.body, req.headers] : [pathname, req.headers]);
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]);
return reply ? await reply(req, res) : next();
});

Expand Down
Loading
Loading