Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(gather-runner): treat NO_FCP as a pageLoadError #8340

Merged
merged 16 commits into from
May 28, 2019
Merged
38 changes: 26 additions & 12 deletions lighthouse-cli/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,25 +65,25 @@ function getDebuggableChrome(flags) {
}

/** @return {never} */
function showConnectionError() {
function printConnectionErrorAndExit() {
console.error('Unable to connect to Chrome');
return process.exit(_RUNTIME_ERROR_CODE);
}

/** @return {never} */
function showProtocolTimeoutError() {
function printProtocolTimeoutErrorAndExit() {
console.error('Debugger protocol timed out while connecting to Chrome.');
return process.exit(_PROTOCOL_TIMEOUT_EXIT_CODE);
}

/** @param {LighthouseError} err @return {never} */
function showPageHungError(err) {
function printPageHungErrorAndExit(err) {
console.error('Page hung:', err.friendlyMessage);
return process.exit(_PAGE_HUNG_EXIT_CODE);
}

/** @param {LighthouseError} err @return {never} */
function showInsecureDocumentRequestError(err) {
function printInsecureDocumentRequestErrorAndExit(err) {
console.error('Insecure document request:', err.friendlyMessage);
return process.exit(_INSECURE_DOCUMENT_REQUEST_EXIT_CODE);
}
Expand All @@ -92,7 +92,7 @@ function showInsecureDocumentRequestError(err) {
* @param {LighthouseError} err
* @return {never}
*/
function showRuntimeError(err) {
function printRuntimeErrorAndExit(err) {
console.error('Runtime error encountered:', err.friendlyMessage || err.message);
if (err.stack) {
console.error(err.stack);
Expand All @@ -104,17 +104,17 @@ function showRuntimeError(err) {
* @param {LighthouseError} err
* @return {never}
*/
function handleError(err) {
function printErrorAndExit(err) {
if (err.code === 'ECONNREFUSED') {
return showConnectionError();
return printConnectionErrorAndExit();
} else if (err.code === 'CRI_TIMEOUT') {
return showProtocolTimeoutError();
return printProtocolTimeoutErrorAndExit();
} else if (err.code === 'PAGE_HUNG') {
return showPageHungError(err);
return printPageHungErrorAndExit(err);
} else if (err.code === 'INSECURE_DOCUMENT_REQUEST') {
return showInsecureDocumentRequestError(err);
return printInsecureDocumentRequestErrorAndExit(err);
} else {
return showRuntimeError(err);
return printRuntimeErrorAndExit(err);
}
}

Expand Down Expand Up @@ -218,10 +218,24 @@ async function runLighthouse(url, flags, config) {
await potentiallyKillChrome(launchedChrome);
process.removeListener('unhandledRejection', handleTheUnhandled);

// Runtime errors indicate something was *very* wrong with the page result.
// We don't want the user to have to parse the report to figure it out, so we'll still exit
// with an error code after we saved the results.
if (runnerResult && runnerResult.lhr.runtimeError) {
patrickhulce marked this conversation as resolved.
Show resolved Hide resolved
const {runtimeError} = runnerResult.lhr;
return printErrorAndExit({
name: 'LHError',
friendlyMessage: runtimeError.message,
Copy link
Member

Choose a reason for hiding this comment

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

do we want to include that we've saved the results, as well? I guess we have the normal save log entries, but it could be helpful?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

hm, where are you thinking of adding it?

I'm ideally thinking it might be like blah blah usual error message, and your assets have still be saved to disk or something, but mucking with the error message seems like more trouble than its worth

lhrRuntimeError: true,
code: runtimeError.code,
message: runtimeError.message,
});
}

return runnerResult;
} catch (err) {
await potentiallyKillChrome(launchedChrome).catch(() => {});
handleError(err);
return printErrorAndExit(err);
}
}

Expand Down
29 changes: 21 additions & 8 deletions lighthouse-cli/test/smokehouse/smokehouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ function resolveLocalOrCwd(payloadPath) {
return resolved;
}

/**
* Determines if the Lighthouse run ended in an unexpected fatal result.
* @param {number} exitCode
* @param {string} outputPath
*/
function isUnexpectedFatalResult(exitCode, outputPath) {
return exitCode !== 0
// These runtime errors are currently fatal but "expected" runtime errors we are asserting against.
&& exitCode !== PAGE_HUNG_EXIT_CODE
&& exitCode !== INSECURE_DOCUMENT_REQUEST_EXIT_CODE
// On runtime errors we exit with a error status code, but still output a report.
// If the report exists, it wasn't a fatal LH error we need to abort on, it's one we're asserting :)
&& !fs.existsSync(outputPath);
}

/**
* Launch Chrome and do a full Lighthouse run.
* @param {string} url
Expand Down Expand Up @@ -85,22 +100,20 @@ function runLighthouse(url, configPath, isDebug) {
runResults = spawnSync(command, args, {encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit']});
} while (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE && runCount <= RETRIES);

if (isDebug) {
console.log(`STDOUT: ${runResults.stdout}`);
console.error(`STDERR: ${runResults.stderr}`);
}

if (runResults.status === PROTOCOL_TIMEOUT_EXIT_CODE) {
console.error(`Lighthouse debugger connection timed out ${RETRIES} times. Giving up.`);
process.exit(1);
} else if (runResults.status !== 0
&& runResults.status !== PAGE_HUNG_EXIT_CODE
&& runResults.status !== INSECURE_DOCUMENT_REQUEST_EXIT_CODE) {
} else if (isUnexpectedFatalResult(runResults.status, outputPath)) {
console.error(`Lighthouse run failed with exit code ${runResults.status}. stderr to follow:`);
console.error(runResults.stderr);
process.exit(runResults.status);
}

if (isDebug) {
console.log(`STDOUT: ${runResults.stdout}`);
console.error(`STDERR: ${runResults.stderr}`);
}

let errorCode;
let lhr = {requestedUrl: url, finalUrl: url, audits: {}};
if (runResults.status === PAGE_HUNG_EXIT_CODE) {
Expand Down
49 changes: 35 additions & 14 deletions lighthouse-core/gather/gather-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ class GatherRunner {
* Loads options.url with specified options. If the main document URL
* redirects, options.url will be updated accordingly. As such, options.url
* will always represent the post-redirected URL. options.requestedUrl is the
* pre-redirect starting URL.
* pre-redirect starting URL. If the navigation errors with "expected" errors such as
* NO_FCP, a `navigationError` is returned.
* @param {Driver} driver
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<void>}
* @return {Promise<{navigationError?: LH.LighthouseError}>}
patrickhulce marked this conversation as resolved.
Show resolved Hide resolved
*/
static async loadPage(driver, passContext) {
const gatherers = passContext.passConfig.gatherers;
Expand All @@ -61,15 +62,25 @@ class GatherRunner {
args: [gatherers.map(g => g.instance.name).join(', ')],
};
log.time(status);
try {
const finalUrl = await driver.gotoURL(passContext.url, {
waitForFCP: passContext.passConfig.recordTrace,
waitForLoad: true,
passContext,
});
passContext.url = finalUrl;
} catch (err) {
// If it's one of our loading-based LHErrors, we'll treat it as a page load error.
if (err.code === 'NO_FCP') {
return {navigationError: err};
}

const finalUrl = await driver.gotoURL(passContext.url, {
waitForFCP: passContext.passConfig.recordTrace,
waitForLoad: true,
passContext,
});
passContext.url = finalUrl;
throw err;
} finally {
log.timeEnd(status);
}

log.timeEnd(status);
return {};
}

/**
Expand Down Expand Up @@ -122,7 +133,7 @@ class GatherRunner {
* Returns an error if the original network request failed or wasn't found.
* @param {string} url The URL of the original requested page.
* @param {Array<LH.Artifacts.NetworkRequest>} networkRecords
* @return {LHError|undefined}
* @return {LH.LighthouseError|undefined}
*/
static getNetworkError(url, networkRecords) {
const mainRecord = networkRecords.find(record => {
Expand Down Expand Up @@ -161,14 +172,24 @@ class GatherRunner {
* main document request failure, a security issue, etc.
* @param {LH.Gatherer.PassContext} passContext
* @param {LH.Gatherer.LoadData} loadData
* @param {LighthouseError|undefined} navigationError
* @return {LighthouseError|undefined}
*/
static getPageLoadError(passContext, loadData) {
static getPageLoadError(passContext, loadData, navigationError) {
const networkError = GatherRunner.getNetworkError(passContext.url, loadData.networkRecords);

// If the driver was offline, the load will fail without offline support. Ignore this case.
if (!passContext.driver.online) return;

return networkError;
// Network errors are usually the most specific and provide the best reason for why the page failed to load.
// Prefer networkError over navigationError.
// Example: `DNS_FAILURE` is better than `NO_FCP`.
if (networkError) return networkError;

// Navigation errors are rather generic and express some failure of the page to render properly.
// Use `navigationError` as the last resort.
// Example: `NO_FCP`, the page never painted content for some unknown reason.
return navigationError;
}

/**
Expand Down Expand Up @@ -576,7 +597,7 @@ class GatherRunner {

// Navigate, start recording, and run `pass()` on gatherers.
await GatherRunner.beginRecording(passContext);
await GatherRunner.loadPage(driver, passContext);
const {navigationError: possibleNavError} = await GatherRunner.loadPage(driver, passContext);
await GatherRunner.pass(passContext, gathererResults);
const loadData = await GatherRunner.endRecording(passContext);

Expand All @@ -589,7 +610,7 @@ class GatherRunner {
if (loadData.trace) baseArtifacts.traces[passConfig.passName] = loadData.trace;

// If there were any load errors, treat all gatherers as if they errored.
const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData);
const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData, possibleNavError);
if (pageLoadError) {
log.error('GatherRunner', pageLoadError.friendlyMessage, passContext.url);
passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
Expand Down
91 changes: 90 additions & 1 deletion lighthouse-core/test/gather/gather-runner-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const assert = require('assert');
const Config = require('../../config/config');
const unresolvedPerfLog = require('./../fixtures/unresolved-perflog.json');
const NetworkRequest = require('../../lib/network-request.js');
const LHError = require('../../lib/lh-error.js');
const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');

jest.mock('../../lib/stack-collector.js', () => () => Promise.resolve([]));

Expand Down Expand Up @@ -100,7 +102,7 @@ describe('GatherRunner', function() {
};

const passContext = {
requestedUrl: url1,
url: url1,
settings: {},
passConfig: {
gatherers: [],
Expand All @@ -112,6 +114,26 @@ describe('GatherRunner', function() {
});
});

it('loads a page and returns a pageLoadError', async () => {
const url = 'https://example.com';
const error = new LHError(LHError.errors.NO_FCP);
const driver = {
gotoURL() {
return Promise.reject(error);
},
};

const passContext = {
url,
settings: {},
passConfig: {gatherers: []},
};

const {navigationError} = await GatherRunner.loadPage(driver, passContext);
expect(navigationError).toEqual(error);
expect(passContext.url).toEqual(url);
});

it('collects benchmark as an artifact', async () => {
const url = 'https://example.com';
const driver = fakeDriver;
Expand Down Expand Up @@ -396,6 +418,73 @@ describe('GatherRunner', function() {
assert.equal(tests.calledCleanBrowserCaches, true);
});

it('fails artifacts with network errors', async () => {
const requestedUrl = 'https://example.com';
// This page load error should be overriden by NO_DOCUMENT_REQUEST for being more specific
const navigationError = new LHError(LHError.errors.NO_FCP);
const driver = Object.assign({}, fakeDriver, {
online: true,
gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
});

const passConfig = {
gatherers: [
{instance: new TestGatherer()},
],
};

const settings = {};

const passContext = {
url: requestedUrl,
driver,
passConfig,
settings,
LighthouseRunWarnings: [],
baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
};

const {artifacts} = await GatherRunner.runPass(passContext);
expect(passContext.LighthouseRunWarnings).toHaveLength(1);
expect(artifacts.TestGatherer).toBeInstanceOf(Error);
expect(artifacts.TestGatherer.code).toEqual('NO_DOCUMENT_REQUEST');
});

it('fails artifacts with navigation errors', async () => {
const requestedUrl = 'https://example.com';
// This time, NO_FCP should win because it's the only error left.
const navigationError = new LHError(LHError.errors.NO_FCP);
const driver = Object.assign({}, fakeDriver, {
online: true,
gotoURL: url => url.includes('blank') ? null : Promise.reject(navigationError),
endDevtoolsLog() {
return networkRecordsToDevtoolsLog([{url: requestedUrl}]);
},
});

const passConfig = {
gatherers: [
{instance: new TestGatherer()},
],
};

const settings = {};

const passContext = {
url: requestedUrl,
driver,
passConfig,
settings,
LighthouseRunWarnings: [],
baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
};

const {artifacts} = await GatherRunner.runPass(passContext);
expect(passContext.LighthouseRunWarnings).toHaveLength(1);
expect(artifacts.TestGatherer).toBeInstanceOf(Error);
expect(artifacts.TestGatherer.code).toEqual('NO_FCP');
});

it('does not clear origin storage with flag --disable-storage-reset', () => {
const asyncFunc = () => Promise.resolve();
const tests = {
Expand Down
2 changes: 2 additions & 0 deletions types/lhr.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import LHError = require('../lighthouse-core/lib/lh-error.js');

declare global {
module LH {
export type LighthouseError = LHError;

export type I18NMessageEntry = string | {path: string, values: any};

export interface I18NMessages {
Expand Down