blob: 39ec5a7313ce0caad92b2f3eff22ecd362045bf7 [file] [log] [blame]
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @file Functions and state to tie error reporting and console output of
* the browser process and frontend pages together.
*/
/* eslint-disable no-console */
import * as path from 'node:path';
import type * as puppeteer from 'puppeteer-core';
const ALLOWED_ASSERTION_FAILURES = [
// Failure during shutdown. crbug.com/1145969
'Session is unregistering, can\'t dispatch pending call to Debugger.setBlackboxPatterns',
// Failure during shutdown. crbug.com/1199322
'Session is unregistering, can\'t dispatch pending call to DOM.getDocument',
// Expected failures in assertion.test.ts
'expected failure 1',
'expected failure 2',
// A failing fetch isn't itself a real error.
// TODO(https://crbug.com/124534) Remove once those messages are not printed anymore.
'Failed to load resource: the server responded with a status of 404 (Not Found)',
// Every letter "typed" into the console can trigger a preview `Runtime.evaluate` call.
// There is no way for an e2e test to know whether all of them have resolved or if there are
// still pending calls. If the test finishes too early, the JS context is destroyed and pending
// evaluations will fail. We ignore these kinds of errors. Tests have to make sure themselves
// that all assertions and success criteria are met (e.g. autocompletions etc).
// See: https://crbug.com/1192052
'Request Runtime.evaluate failed. {"code":-32602,"message":"uniqueContextId not found"}',
'Session is unregistering, can\'t dispatch pending call to Runtime.evaluate', // same as above
'uniqueContextId not found',
'Request Storage.getStorageKey failed. {"code":-32602,"message":"Frame tree node for given frame not found"}',
// Some left-over a11y calls show up in the logs.
'Request Accessibility.getChildAXNodes failed. {"code":-32602,"message":"Invalid ID"}',
'Unable to create texture',
'Not allowed to load local resource: devtools://theme/colors.css',
// neterror.js started serving sourcemaps and we're requesting it unnecessarily.
'Request Network.loadNetworkResource failed. {"code":-32602,"message":"Unsupported URL scheme"}',
'Fetch API cannot load chrome-error://chromewebdata/neterror.rollup.js.map. URL scheme "chrome-error" is not supported.',
'Request Storage.getAffectedUrlsForThirdPartyCookieMetadata failed.',
'Hash of blocked script',
];
const FILTERED_LOGS = ['Autofocus processing was blocked because a document already has a focused element'];
const logLevels = {
log: 'I',
info: 'I',
warning: 'I',
error: 'E',
exception: 'E',
assert: 'E',
};
let stdout = '', stderr = '';
let unhandledRejectionSet = false;
export function setupBrowserProcessIO(browser: puppeteer.Browser): void {
const browserProcess = browser.process();
if (!browserProcess) {
throw new Error('browserProcess is unexpectedly not defined.');
}
if (browserProcess.stderr) {
browserProcess.stderr.setEncoding('utf8');
browserProcess.stderr.on('data', data => {
stderr += data;
});
}
if (browserProcess.stdout) {
browserProcess.stdout.setEncoding('utf8');
browserProcess.stdout.on('data', data => {
stdout += data;
});
}
if (!unhandledRejectionSet) {
browserProcess.on('unhandledRejection', error => {
throw new Error(`Unhandled rejection in Frontend: ${error}`);
});
unhandledRejectionSet = true;
}
}
export function installPageErrorHandlers(page: puppeteer.Page): void {
page.on('error', error => {
console.log('STDOUT:');
console.log(stdout);
console.log();
console.log('STDERR:');
console.log(stderr);
console.log();
throw new Error(`Error in Frontend: ${error}`);
});
page.on('pageerror', error => {
if (!(error instanceof Error)) {
throw new Error(`Page error in Frontend: ${error}`);
}
if (error.message.includes(path.join('ui', 'components', 'docs'))) {
uiComponentDocErrors.push(error);
}
const message = error.stack ?? error.message;
if (isExpectedError(error)) {
expectedErrors.push(message);
console.log('(expected) ' + message);
} else {
fatalErrors.push(message);
console.error(message);
}
throw new Error(`Page error in Frontend: ${error}`);
});
page.on('console', async msg => {
const logLevel = logLevels[msg.type() as keyof typeof logLevels];
if (logLevel) {
if (logLevel === 'E') {
let message = `${logLevel}> `;
if (msg.text() === 'JSHandle@error') {
const errorHandle = msg.args()[0] as puppeteer.JSHandle<Error>;
message += await errorHandle.evaluate(error => {
return error.stack;
});
await errorHandle.dispose();
} else {
message += msg.text();
for (const frame of msg.stackTrace()) {
message += '\n' + formatStackFrame(frame);
}
}
if (isExpectedError(msg)) {
expectedErrors.push(message);
console.log('(expected) ' + message);
} else {
fatalErrors.push(message);
console.error(message);
}
} else if (!FILTERED_LOGS.some(log => msg.text().includes(log))) {
console.log(`${logLevel}> ${formatStackFrame(msg.location())}: ${msg.text()}`);
}
}
});
}
function isExpectedError(consoleMessage: puppeteer.ConsoleMessage|Error) {
if (ALLOWED_ASSERTION_FAILURES.some(
f => (consoleMessage instanceof Error ? consoleMessage.message : consoleMessage.text()).includes(f))) {
return true;
}
for (const expectation of pendingErrorExpectations) {
if (expectation.check(consoleMessage)) {
pendingErrorExpectations.delete(expectation);
return true;
}
}
return false;
}
export class ErrorExpectation {
#caught: puppeteer.ConsoleMessage|Error|undefined;
readonly #msg: string|RegExp;
constructor(msg: string|RegExp) {
this.#msg = msg;
pendingErrorExpectations.add(this);
}
drop() {
pendingErrorExpectations.delete(this);
return this.#caught;
}
get caught() {
return this.#caught;
}
check(consoleMessage: puppeteer.ConsoleMessage|Error) {
const text = consoleMessage instanceof Error ? consoleMessage.message : consoleMessage.text();
let match = (this.#msg instanceof RegExp) ? Boolean(text.match(this.#msg)) : text.includes(this.#msg);
// When console.assert(condition) fails (no second arg), the only message is
// "console.assert". Check the stack trace for those cases. Don't do this
// generally as checking the stack trace should be discouraged.
if (!match && text === 'console.assert') {
const stack = consoleMessage instanceof Error ? consoleMessage.stack ?? '' :
consoleMessage.stackTrace().map(l => l.url ?? '').join('\n');
match = (this.#msg instanceof RegExp) ? Boolean(stack.match(this.#msg)) : stack.includes(this.#msg);
}
if (match) {
this.#caught = consoleMessage;
}
return match;
}
}
export function expectError(msg: string|RegExp) {
return new ErrorExpectation(msg);
}
function formatStackFrame(stackFrame: puppeteer.ConsoleMessageLocation): string {
if (!stackFrame?.url) {
return '<unknown>';
}
const filename = stackFrame.url.replace(/^.*\//, '');
return `${filename}:${stackFrame.lineNumber}:${stackFrame.columnNumber}`;
}
export function dumpCollectedErrors(): void {
if (!(expectedErrors.length + fatalErrors.length)) {
return;
}
console.log('Expected errors: ' + expectedErrors.length);
console.log(' Fatal errors: ' + fatalErrors.length);
if (uiComponentDocErrors.length) {
console.log(
'\nErrors from component examples during test run:\n', uiComponentDocErrors.map(e => e.message).join('\n '));
}
const allFatalErrors = fatalErrors.join('\n');
expectedErrors = [];
fatalErrors = [];
if (allFatalErrors) {
throw new Error('Fatal errors logged:\n' + allFatalErrors);
}
}
const pendingErrorExpectations = new Set<ErrorExpectation>();
export let fatalErrors: string[] = [];
export let expectedErrors: string[] = [];
/**
* Gathered separately so we can surface them during screenshot tests to help
* give an idea of failures, rather than having to guess purely based on the
* screenshot.
**/
export const uiComponentDocErrors: Error[] = [];