From 54b731b9a02ed8ea1d4a0d99f70f3987cbf87deb Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Wed, 2 Nov 2022 14:24:01 -0700 Subject: [PATCH 1/8] core: expose error stack on errored audits --- core/audits/audit.js | 5 ++- core/gather/driver/execution-context.js | 25 +++++++---- core/lib/lh-error.js | 28 ++++++++----- core/runner.js | 6 ++- core/scripts/cleanup-LHR-for-diff.js | 42 ++++++++++++------- core/scripts/update-flow-fixtures.js | 4 ++ .../reports/sample-flow-result.json | 3 +- core/test/lib/asset-saver-test.js | 3 +- core/test/runner-test.js | 31 ++++++++++++++ tsconfig-base.json | 2 +- types/audit.d.ts | 2 + types/lhr/audit-result.d.ts | 2 + 12 files changed, 115 insertions(+), 38 deletions(-) diff --git a/core/audits/audit.js b/core/audits/audit.js index a4fdafceee98..e6b0784c9619 100644 --- a/core/audits/audit.js +++ b/core/audits/audit.js @@ -321,12 +321,14 @@ class Audit { /** * @param {typeof Audit} audit * @param {string | LH.IcuMessage} errorMessage + * @param {string=} errorStack * @return {LH.RawIcu} */ - static generateErrorAuditResult(audit, errorMessage) { + static generateErrorAuditResult(audit, errorMessage, errorStack) { return Audit.generateAuditResult(audit, { score: null, errorMessage, + errorStack, }); } @@ -380,6 +382,7 @@ class Audit { displayValue: product.displayValue, explanation: product.explanation, errorMessage: product.errorMessage, + errorStack: product.errorStack, warnings: product.warnings, details: product.details, diff --git a/core/gather/driver/execution-context.js b/core/gather/driver/execution-context.js index dd51d8781ad5..404c5979541b 100644 --- a/core/gather/driver/execution-context.js +++ b/core/gather/driver/execution-context.js @@ -124,14 +124,25 @@ class ExecutionContext { this._session.setNextProtocolTimeout(timeout); const response = await this._session.sendCommand('Runtime.evaluate', evaluationParams); - if (response.exceptionDetails) { - // An error occurred before we could even create a Promise, should be *very* rare. - // Also occurs when the expression is not valid JavaScript. - const errorMessage = response.exceptionDetails.exception ? - response.exceptionDetails.exception.description : - response.exceptionDetails.text; - return Promise.reject(new Error(`Evaluation exception: ${errorMessage}`)); + + // An error occurred before we could even create a Promise, should be *very* rare. + // Also occurs when the expression is not valid JavaScript. + const ex = response.exceptionDetails; + if (ex) { + const message = ex.exception?.description || ex.text; + const evaluationError = new Error(`Runtime.evaluate exception: ${message}`); + if (ex.exception?.description && ex.stackTrace) { + // The description contains the stack trace formatted as expected, if present. + evaluationError.stack = ex.exception.description; + } else { + // Otherwise, for syntax errors there is no stack trace, but we can add information about the + // line/col of the parsing error instead. + evaluationError.stack = + `${message}\n at :${ex.lineNumber}:${ex.columnNumber}`; + } + return Promise.reject(evaluationError); } + // Protocol should always return a 'result' object, but it is sometimes undefined. See #6026. if (response.result === undefined) { return Promise.reject( diff --git a/core/lib/lh-error.js b/core/lib/lh-error.js index fff6226d9bbf..7805d1b2950c 100644 --- a/core/lib/lh-error.js +++ b/core/lib/lh-error.js @@ -107,17 +107,18 @@ const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); const LHERROR_SENTINEL = '__LighthouseErrorSentinel'; const ERROR_SENTINEL = '__ErrorSentinel'; /** - * @typedef {{sentinel: '__LighthouseErrorSentinel', code: string, stack?: string, [p: string]: string|undefined}} SerializedLighthouseError - * @typedef {{sentinel: '__ErrorSentinel', message: string, code?: string, stack?: string}} SerializedBaseError + * @typedef {{sentinel: '__LighthouseErrorSentinel', code: string, stack?: string, cause?: SerializedBaseError|SerializedLighthouseError, properties?: {[p: string]: string|undefined}}} SerializedLighthouseError + * @typedef {{sentinel: '__ErrorSentinel', message: string, code?: string, stack?: string, cause?: SerializedBaseError|SerializedLighthouseError}} SerializedBaseError */ class LighthouseError extends Error { /** * @param {LighthouseErrorDefinition} errorDefinition * @param {Record=} properties + * @param {Error=} cause */ - constructor(errorDefinition, properties) { - super(errorDefinition.code); + constructor(errorDefinition, properties, cause) { + super(errorDefinition.code, {cause}); this.name = 'LighthouseError'; this.code = errorDefinition.code; // Add additional properties to be ICU replacements in the error string. @@ -163,19 +164,20 @@ class LighthouseError extends Error { if (err instanceof LighthouseError) { // Remove class props so that remaining values were what was passed in as `properties`. // eslint-disable-next-line no-unused-vars - const {name, code, message, friendlyMessage, lhrRuntimeError, stack, ...properties} = err; + const {name, code, message, friendlyMessage, lhrRuntimeError, stack, cause, ...properties} = err; return { sentinel: LHERROR_SENTINEL, code, stack, - ...properties, + cause: /** @type {any} */ (cause), + properties: /** @type {{ [p: string]: string | undefined }} */ (properties), }; } // Unexpected errors won't be LighthouseErrors, but we want them serialized as well. if (err instanceof Error) { - const {message, stack} = err; + const {message, stack, cause} = err; // @ts-expect-error - code can be helpful for e.g. node errors, so preserve it if it's present. const code = err.code; return { @@ -183,6 +185,7 @@ class LighthouseError extends Error { message, code, stack, + cause: /** @type {any} */ (cause), }; } @@ -203,17 +206,20 @@ class LighthouseError extends Error { if (possibleError.sentinel === LHERROR_SENTINEL) { // Include sentinel in destructuring so it doesn't end up in `properties`. // eslint-disable-next-line no-unused-vars - const {sentinel, code, stack, ...properties} = /** @type {SerializedLighthouseError} */ (possibleError); + const {code, stack, cause, properties} = /** @type {SerializedLighthouseError} */ (possibleError); + const causeRevived = cause ? LighthouseError.parseReviver(key, cause) : undefined; const errorDefinition = LighthouseError.errors[/** @type {keyof typeof ERRORS} */ (code)]; - const lhError = new LighthouseError(errorDefinition, properties); + const lhError = new LighthouseError(errorDefinition, properties, causeRevived); lhError.stack = stack; return lhError; } if (possibleError.sentinel === ERROR_SENTINEL) { - const {message, code, stack} = /** @type {SerializedBaseError} */ (possibleError); - const error = new Error(message); + const {message, code, stack, cause} = /** @type {SerializedBaseError} */ (possibleError); + const causeRevived = cause ? LighthouseError.parseReviver(key, cause) : undefined; + const opts = causeRevived ? {cause: causeRevived} : undefined; + const error = new Error(message, opts); Object.assign(error, {code, stack}); return error; } diff --git a/core/runner.js b/core/runner.js index 8116b63ebe8a..2eccce53d879 100644 --- a/core/runner.js +++ b/core/runner.js @@ -383,7 +383,7 @@ class Runner { // Create a friendlier display error and mark it as expected to avoid duplicates in Sentry const error = new LighthouseError(LighthouseError.errors.ERRORED_REQUIRED_ARTIFACT, - {artifactName, errorMessage: artifactError.message}); + {artifactName, errorMessage: artifactError.message}, artifactError); // @ts-expect-error Non-standard property added to Error error.expected = true; throw error; @@ -422,7 +422,9 @@ class Runner { Sentry.captureException(err, {tags: {audit: audit.meta.id}, level: 'error'}); // Errors become error audit result. const errorMessage = err.friendlyMessage ? err.friendlyMessage : err.message; - auditResult = Audit.generateErrorAuditResult(audit, errorMessage); + // Prefer the stack trace closest to the error. + const stack = err.cause?.stack ?? err.stack; + auditResult = Audit.generateErrorAuditResult(audit, errorMessage, stack); } log.timeEnd(status); diff --git a/core/scripts/cleanup-LHR-for-diff.js b/core/scripts/cleanup-LHR-for-diff.js index 6dd0d8fc3024..7914ca9b5685 100755 --- a/core/scripts/cleanup-LHR-for-diff.js +++ b/core/scripts/cleanup-LHR-for-diff.js @@ -9,22 +9,17 @@ /** @fileoverview Read in a LHR JSON file, remove whatever shouldn't be compared, write it back. */ import {readFileSync, writeFileSync} from 'fs'; +import url from 'url'; -const filename = process.argv[2]; -const extraFlag = process.argv[3]; -if (!filename) throw new Error('No filename provided.'); +import esMain from 'es-main'; -const data = readFileSync(filename, 'utf8'); -writeFileSync(filename, cleanAndFormatLHR(data), 'utf8'); +import {LH_ROOT} from '../../root.js'; /** - * @param {string} lhrString - * @return {string} + * @param {LH.Result} lhr + * @param {{skipDescription?: boolean}=} opts */ -function cleanAndFormatLHR(lhrString) { - /** @type {LH.Result} */ - const lhr = JSON.parse(lhrString); - +function cleanAndFormatLHR(lhr, opts = {}) { // TODO: Resolve the below so we don't need to force it to a boolean value: // 1) The string|boolean story for proto // 2) CI gets a absolute path during yarn diff:sample-json @@ -40,11 +35,30 @@ function cleanAndFormatLHR(lhrString) { entry.startTime = 0; // Not realsitic, but avoids a lot of diff churn }); - if (extraFlag !== '--only-remove-timing') { - for (const auditResult of Object.values(lhr.audits)) { + const baseCallFrameUrl = url.pathToFileURL(LH_ROOT); + + for (const auditResult of Object.values(lhr.audits)) { + if (!opts.skipDescription) { auditResult.description = '**Excluded from diff**'; } + if (auditResult.errorStack) { + auditResult.errorStack = auditResult.errorStack.replaceAll(baseCallFrameUrl.href, ''); + } } +} + +if (esMain(import.meta)) { + const filename = process.argv[2]; + const extraFlag = process.argv[3]; + if (!filename) throw new Error('No filename provided.'); + + const lhr = JSON.parse(readFileSync(filename, 'utf8')); + cleanAndFormatLHR(lhr, { + skipDescription: extraFlag === '--only-remove-timing', + }); // Ensure we have a final newline to conform to .editorconfig - return `${JSON.stringify(lhr, null, 2)}\n`; + const cleaned = `${JSON.stringify(lhr, null, 2)}\n`; + writeFileSync(filename, cleaned, 'utf8'); } + +export {cleanAndFormatLHR}; diff --git a/core/scripts/update-flow-fixtures.js b/core/scripts/update-flow-fixtures.js index 9a38cc2a440c..54c88811c7f3 100644 --- a/core/scripts/update-flow-fixtures.js +++ b/core/scripts/update-flow-fixtures.js @@ -19,6 +19,7 @@ import log from 'lighthouse-logger'; import {LH_ROOT} from '../../root.js'; import * as api from '../index.js'; import * as assetSaver from '../lib/asset-saver.js'; +import { cleanAndFormatLHR } from './cleanup-LHR-for-diff.js'; /* eslint-disable max-len */ const ARTIFACTS_PATH = `${LH_ROOT}/core/test/fixtures/fraggle-rock/artifacts/`; @@ -151,6 +152,9 @@ async function generateFlowResult() { // Normalize some data so it doesn't change on every update. for (const {lhr} of flowResult.steps) { + cleanAndFormatLHR(lhr, {skipDescription: true}); + // TODO: should remove this next line if cleanAndFormatLHR is refactored + lhr.configSettings.auditMode = false; assetSaver.normalizeTimingEntries(lhr.timing.entries); lhr.timing.total = lhr.timing.entries.length; } diff --git a/core/test/fixtures/fraggle-rock/reports/sample-flow-result.json b/core/test/fixtures/fraggle-rock/reports/sample-flow-result.json index 1e31555bbcbb..c3f5ff27326a 100644 --- a/core/test/fixtures/fraggle-rock/reports/sample-flow-result.json +++ b/core/test/fixtures/fraggle-rock/reports/sample-flow-result.json @@ -9106,7 +9106,8 @@ "description": "This is the thread-blocking work occurring during the Interaction to Next Paint measurement. [Learn more about the Interaction to Next Paint metric](https://web.dev/inp/).", "score": null, "scoreDisplayMode": "error", - "errorMessage": "This version of Chrome is too old to support 'detailed EventTiming trace events'. Use a newer version to see full results." + "errorMessage": "This version of Chrome is too old to support 'detailed EventTiming trace events'. Use a newer version to see full results.", + "errorStack": "LighthouseError: UNSUPPORTED_OLD_CHROME\n at Function.audit (/core/audits/work-during-interaction.js:239:13)\n at async Function._runAudit (/core/runner.js:412:23)\n at async Function._runAudits (/core/runner.js:324:27)\n at async Function.audit (/core/runner.js:60:32)\n at async auditGatherSteps (/core/user-flow.js:333:20)\n at async Module.auditFlowArtifacts (/core/api.js:70:10)\n at async generateFlowResult (/core/scripts/update-flow-fixtures.js:146:22)\n at async /core/scripts/update-flow-fixtures.js:168:1" } }, "configSettings": { diff --git a/core/test/lib/asset-saver-test.js b/core/test/lib/asset-saver-test.js index 88052b20ae59..467fd873563c 100644 --- a/core/test/lib/asset-saver-test.js +++ b/core/test/lib/asset-saver-test.js @@ -370,7 +370,7 @@ describe('asset-saver helper', () => { // Use an LighthouseError that has an ICU replacement. const protocolMethod = 'Page.getFastness'; const lhError = new LighthouseError( - LighthouseError.errors.PROTOCOL_TIMEOUT, {protocolMethod}); + LighthouseError.errors.PROTOCOL_TIMEOUT, {protocolMethod}, new Error()); const artifacts = { traces: {}, @@ -385,6 +385,7 @@ describe('asset-saver helper', () => { expect(roundTripArtifacts.ScriptElements).toBeInstanceOf(LighthouseError); expect(roundTripArtifacts.ScriptElements.code).toEqual('PROTOCOL_TIMEOUT'); expect(roundTripArtifacts.ScriptElements.protocolMethod).toEqual(protocolMethod); + expect(roundTripArtifacts.ScriptElements.cause).toBeInstanceOf(Error); expect(roundTripArtifacts.ScriptElements.stack).toMatch( /^LighthouseError: PROTOCOL_TIMEOUT.*test[\\/]lib[\\/]asset-saver-test\.js/s); expect(roundTripArtifacts.ScriptElements.friendlyMessage) diff --git a/core/test/runner-test.js b/core/test/runner-test.js index c7ef1eba6aa6..bccf5f3476ee 100644 --- a/core/test/runner-test.js +++ b/core/test/runner-test.js @@ -582,6 +582,37 @@ describe('Runner', () => { assert.strictEqual(auditResult.score, null); assert.strictEqual(auditResult.scoreDisplayMode, 'error'); assert.ok(auditResult.errorMessage.includes(errorMessage)); + assert.ok(auditResult.errorStack.includes('at Function.audit')); + }); + }); + + it('produces an error audit result that prefers cause stack', async () => { + const errorMessage = 'Audit yourself'; + const config = await Config.fromJson({ + settings: { + auditMode: moduleDir + '/fixtures/artifacts/empty-artifacts/', + }, + audits: [ + class ThrowyAudit extends Audit { + static get meta() { + return testAuditMeta; + } + static audit() { + throw new Error(errorMessage, {cause: ThrowyAudit.aFn()}); + } + static aFn() { + return new Error(); + } + }, + ], + }); + + return runGatherAndAudit({}, {config}).then(results => { + const auditResult = results.lhr.audits['throwy-audit']; + assert.strictEqual(auditResult.score, null); + assert.strictEqual(auditResult.scoreDisplayMode, 'error'); + assert.ok(auditResult.errorMessage.includes(errorMessage)); + assert.ok(auditResult.errorStack.includes('at Function.aFn')); }); }); }); diff --git a/tsconfig-base.json b/tsconfig-base.json index e61e358647a3..9e90928e1355 100644 --- a/tsconfig-base.json +++ b/tsconfig-base.json @@ -11,7 +11,7 @@ "outDir": ".tmp/tsbuildinfo/", "rootDir": ".", - "target": "es2020", + "target": "es2022", "module": "es2022", "moduleResolution": "node", "esModuleInterop": true, diff --git a/types/audit.d.ts b/types/audit.d.ts index 57402044e529..0bb103c0b33e 100644 --- a/types/audit.d.ts +++ b/types/audit.d.ts @@ -74,6 +74,8 @@ declare module Audit { explanation?: string | IcuMessage; /** Error message from any exception thrown while running this audit. */ errorMessage?: string | IcuMessage; + /** Error stack from any exception thrown while running this audit. */ + errorStack?: string; warnings?: Array; /** Overrides scoreDisplayMode with notApplicable if set to true */ notApplicable?: boolean; diff --git a/types/lhr/audit-result.d.ts b/types/lhr/audit-result.d.ts index 41f4becca5e3..0069092092a8 100644 --- a/types/lhr/audit-result.d.ts +++ b/types/lhr/audit-result.d.ts @@ -31,6 +31,8 @@ export interface Result { explanation?: string; /** Error message from any exception thrown while running this audit. */ errorMessage?: string; + /** Error stack from any exception thrown while running this audit. */ + errorStack?: string; warnings?: string[]; /** The scored value of the audit, provided in the range `0-1`, or null if `scoreDisplayMode` indicates not scored. */ score: number|null; From 42724b500885a98b1acacfb2073dbc2b4ecd746c Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Tue, 3 Jan 2023 13:43:23 -0800 Subject: [PATCH 2/8] pr updates --- core/audits/audit.js | 2 +- core/lib/lh-error.js | 20 +++++++++----------- core/scripts/update-flow-fixtures.js | 2 +- core/test/lib/asset-saver-test.js | 3 ++- core/test/runner-test.js | 4 ++-- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/core/audits/audit.js b/core/audits/audit.js index e6b0784c9619..1c60aa2a96f2 100644 --- a/core/audits/audit.js +++ b/core/audits/audit.js @@ -346,7 +346,7 @@ class Audit { let scoreDisplayMode = audit.meta.scoreDisplayMode || Audit.SCORING_MODES.BINARY; // But override if product contents require it. - if (product.errorMessage) { + if (product.errorMessage !== undefined) { // Error result. scoreDisplayMode = Audit.SCORING_MODES.ERROR; } else if (product.notApplicable) { diff --git a/core/lib/lh-error.js b/core/lib/lh-error.js index 7805d1b2950c..adcb949fb82d 100644 --- a/core/lib/lh-error.js +++ b/core/lib/lh-error.js @@ -107,18 +107,18 @@ const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings); const LHERROR_SENTINEL = '__LighthouseErrorSentinel'; const ERROR_SENTINEL = '__ErrorSentinel'; /** - * @typedef {{sentinel: '__LighthouseErrorSentinel', code: string, stack?: string, cause?: SerializedBaseError|SerializedLighthouseError, properties?: {[p: string]: string|undefined}}} SerializedLighthouseError - * @typedef {{sentinel: '__ErrorSentinel', message: string, code?: string, stack?: string, cause?: SerializedBaseError|SerializedLighthouseError}} SerializedBaseError + * @typedef {{sentinel: '__LighthouseErrorSentinel', code: string, stack?: string, cause?: unknown, properties?: {[p: string]: string|undefined}}} SerializedLighthouseError + * @typedef {{sentinel: '__ErrorSentinel', message: string, code?: string, stack?: string, cause?: unknown}} SerializedBaseError */ class LighthouseError extends Error { /** * @param {LighthouseErrorDefinition} errorDefinition * @param {Record=} properties - * @param {Error=} cause + * @param {ErrorOptions=} options */ - constructor(errorDefinition, properties, cause) { - super(errorDefinition.code, {cause}); + constructor(errorDefinition, properties, options) { + super(errorDefinition.code, options); this.name = 'LighthouseError'; this.code = errorDefinition.code; // Add additional properties to be ICU replacements in the error string. @@ -170,7 +170,7 @@ class LighthouseError extends Error { sentinel: LHERROR_SENTINEL, code, stack, - cause: /** @type {any} */ (cause), + cause, properties: /** @type {{ [p: string]: string | undefined }} */ (properties), }; } @@ -185,7 +185,7 @@ class LighthouseError extends Error { message, code, stack, - cause: /** @type {any} */ (cause), + cause, }; } @@ -207,9 +207,8 @@ class LighthouseError extends Error { // Include sentinel in destructuring so it doesn't end up in `properties`. // eslint-disable-next-line no-unused-vars const {code, stack, cause, properties} = /** @type {SerializedLighthouseError} */ (possibleError); - const causeRevived = cause ? LighthouseError.parseReviver(key, cause) : undefined; const errorDefinition = LighthouseError.errors[/** @type {keyof typeof ERRORS} */ (code)]; - const lhError = new LighthouseError(errorDefinition, properties, causeRevived); + const lhError = new LighthouseError(errorDefinition, properties, {cause}); lhError.stack = stack; return lhError; @@ -217,8 +216,7 @@ class LighthouseError extends Error { if (possibleError.sentinel === ERROR_SENTINEL) { const {message, code, stack, cause} = /** @type {SerializedBaseError} */ (possibleError); - const causeRevived = cause ? LighthouseError.parseReviver(key, cause) : undefined; - const opts = causeRevived ? {cause: causeRevived} : undefined; + const opts = cause ? {cause} : undefined; const error = new Error(message, opts); Object.assign(error, {code, stack}); return error; diff --git a/core/scripts/update-flow-fixtures.js b/core/scripts/update-flow-fixtures.js index 54c88811c7f3..448113ef2023 100644 --- a/core/scripts/update-flow-fixtures.js +++ b/core/scripts/update-flow-fixtures.js @@ -19,7 +19,7 @@ import log from 'lighthouse-logger'; import {LH_ROOT} from '../../root.js'; import * as api from '../index.js'; import * as assetSaver from '../lib/asset-saver.js'; -import { cleanAndFormatLHR } from './cleanup-LHR-for-diff.js'; +import {cleanAndFormatLHR} from './cleanup-LHR-for-diff.js'; /* eslint-disable max-len */ const ARTIFACTS_PATH = `${LH_ROOT}/core/test/fixtures/fraggle-rock/artifacts/`; diff --git a/core/test/lib/asset-saver-test.js b/core/test/lib/asset-saver-test.js index 467fd873563c..c1b0af6406b8 100644 --- a/core/test/lib/asset-saver-test.js +++ b/core/test/lib/asset-saver-test.js @@ -370,7 +370,7 @@ describe('asset-saver helper', () => { // Use an LighthouseError that has an ICU replacement. const protocolMethod = 'Page.getFastness'; const lhError = new LighthouseError( - LighthouseError.errors.PROTOCOL_TIMEOUT, {protocolMethod}, new Error()); + LighthouseError.errors.PROTOCOL_TIMEOUT, {protocolMethod}, new Error('the cause')); const artifacts = { traces: {}, @@ -386,6 +386,7 @@ describe('asset-saver helper', () => { expect(roundTripArtifacts.ScriptElements.code).toEqual('PROTOCOL_TIMEOUT'); expect(roundTripArtifacts.ScriptElements.protocolMethod).toEqual(protocolMethod); expect(roundTripArtifacts.ScriptElements.cause).toBeInstanceOf(Error); + expect(roundTripArtifacts.ScriptElements.cause.message).toEqual('the cause'); expect(roundTripArtifacts.ScriptElements.stack).toMatch( /^LighthouseError: PROTOCOL_TIMEOUT.*test[\\/]lib[\\/]asset-saver-test\.js/s); expect(roundTripArtifacts.ScriptElements.friendlyMessage) diff --git a/core/test/runner-test.js b/core/test/runner-test.js index bccf5f3476ee..e434619b85d7 100644 --- a/core/test/runner-test.js +++ b/core/test/runner-test.js @@ -598,10 +598,10 @@ describe('Runner', () => { return testAuditMeta; } static audit() { - throw new Error(errorMessage, {cause: ThrowyAudit.aFn()}); + this.aFn(); } static aFn() { - return new Error(); + throw new Error(errorMessage); } }, ], From d041926fc723d9674093e7d7fb376f49e50134d1 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Tue, 3 Jan 2023 16:22:43 -0800 Subject: [PATCH 3/8] add test --- core/gather/driver/execution-context.js | 19 +++++------- .../gather/driver/execution-context-test.js | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/core/gather/driver/execution-context.js b/core/gather/driver/execution-context.js index 404c5979541b..419c70ba59f6 100644 --- a/core/gather/driver/execution-context.js +++ b/core/gather/driver/execution-context.js @@ -129,17 +129,14 @@ class ExecutionContext { // Also occurs when the expression is not valid JavaScript. const ex = response.exceptionDetails; if (ex) { - const message = ex.exception?.description || ex.text; - const evaluationError = new Error(`Runtime.evaluate exception: ${message}`); - if (ex.exception?.description && ex.stackTrace) { - // The description contains the stack trace formatted as expected, if present. - evaluationError.stack = ex.exception.description; - } else { - // Otherwise, for syntax errors there is no stack trace, but we can add information about the - // line/col of the parsing error instead. - evaluationError.stack = - `${message}\n at :${ex.lineNumber}:${ex.columnNumber}`; - } + const elidedExpression = expression.replace(/\s+/g, ' ').substring(0, 100); + const messageLines = [ + 'Runtime.evaluate exception', + `Expression: ${elidedExpression}\n---- (elided)`, + !ex.stackTrace ? `Parse error at: ${ex.lineNumber + 1}:${ex.columnNumber + 1}` : null, + ex.exception?.description || ex.text, + ].filter(Boolean); + const evaluationError = new Error(messageLines.join('\n')); return Promise.reject(evaluationError); } diff --git a/core/test/gather/driver/execution-context-test.js b/core/test/gather/driver/execution-context-test.js index 2f6c12bf1711..dc6807a1d77d 100644 --- a/core/test/gather/driver/execution-context-test.js +++ b/core/test/gather/driver/execution-context-test.js @@ -187,6 +187,36 @@ describe('.evaluateAsync', () => { const value = await executionContext.evaluateAsync('"magic"', {useIsolation: true}); expect(value).toEqual('mocked value'); }); + + it('handles runtime evaluation exception', async () => { + /** @type {LH.Crdp.Runtime.ExceptionDetails} */ + const exceptionDetails = { + exceptionId: 1, + text: 'Uncaught', + lineNumber: 7, + columnNumber: 8, + stackTrace: {description: '', callFrames: []}, + exception: { + type: 'object', + subtype: 'error', + className: 'ReferenceError', + description: 'ReferenceError: Prosmise is not defined\n' + + ' at wrapInNativePromise (_lighthouse-eval.js:8:9)\n' + + ' at _lighthouse-eval.js:83:8', + }, + }; + sessionMock.sendCommand = createMockSendCommandFn() + .mockResponse('Page.enable') + .mockResponse('Runtime.enable') + .mockResponse('Page.getResourceTree', {frameTree: {frame: {id: '1337'}}}) + .mockResponse('Page.createIsolatedWorld', {executionContextId: 9001}) + .mockResponse('Runtime.evaluate', {exceptionDetails}); + + const promise = executionContext.evaluateAsync('new Prosmise', {useIsolation: true}); + await expect(promise).rejects.toThrow(/Expression: new Prosmise/); + await expect(promise).rejects.toThrow(/elided/); + await expect(promise).rejects.toThrow(/at wrapInNativePromise/); + }); }); describe('.evaluate', () => { From 5e8293e117858168380621de9f519502f039d0cc Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 4 May 2023 09:27:59 -0700 Subject: [PATCH 4/8] revert changes to cleanup lhr file --- core/lib/asset-saver.js | 17 +++++++++++ core/scripts/cleanup-LHR-for-diff.js | 43 +++++++++++----------------- core/scripts/update-flow-fixtures.js | 5 +--- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/core/lib/asset-saver.js b/core/lib/asset-saver.js index 76a6736c2e91..b3661b5948c6 100644 --- a/core/lib/asset-saver.js +++ b/core/lib/asset-saver.js @@ -7,6 +7,7 @@ import fs from 'fs'; import path from 'path'; import stream from 'stream'; +import url from 'url'; import log from 'lighthouse-logger'; @@ -16,6 +17,7 @@ import {MetricTraceEvents} from './traces/metric-trace-events.js'; import {NetworkAnalysis} from '../computed/network-analysis.js'; import {LoadSimulator} from '../computed/load-simulator.js'; import {LighthouseError} from '../lib/lh-error.js'; +import {LH_ROOT} from '../../root.js'; const optionsFilename = 'options.json'; const artifactsFilename = 'artifacts.json'; @@ -425,6 +427,20 @@ function normalizeTimingEntries(timings) { } } +/** + * @param {LH.Result} lhr + */ +function elideErrorStacks(lhr) { + const baseCallFrameUrl = url.pathToFileURL(LH_ROOT); + for (const auditResult of Object.values(lhr.audits)) { + if (auditResult.errorStack) { + auditResult.errorStack = auditResult.errorStack + .replaceAll(baseCallFrameUrl.href, '') + .replaceAll(/:[^)]+\)/g, ':elided:elided'); + } + } +} + export { saveArtifacts, saveFlowArtifacts, @@ -438,4 +454,5 @@ export { saveLanternNetworkData, stringifyReplacer, normalizeTimingEntries, + elideErrorStacks, }; diff --git a/core/scripts/cleanup-LHR-for-diff.js b/core/scripts/cleanup-LHR-for-diff.js index 7914ca9b5685..168d310706db 100755 --- a/core/scripts/cleanup-LHR-for-diff.js +++ b/core/scripts/cleanup-LHR-for-diff.js @@ -9,17 +9,24 @@ /** @fileoverview Read in a LHR JSON file, remove whatever shouldn't be compared, write it back. */ import {readFileSync, writeFileSync} from 'fs'; -import url from 'url'; -import esMain from 'es-main'; +import {elideErrorStacks} from '../lib/asset-saver.js'; -import {LH_ROOT} from '../../root.js'; +const filename = process.argv[2]; +const extraFlag = process.argv[3]; +if (!filename) throw new Error('No filename provided.'); + +const data = readFileSync(filename, 'utf8'); +writeFileSync(filename, cleanAndFormatLHR(data), 'utf8'); /** - * @param {LH.Result} lhr - * @param {{skipDescription?: boolean}=} opts + * @param {string} lhrString + * @return {string} */ -function cleanAndFormatLHR(lhr, opts = {}) { +function cleanAndFormatLHR(lhrString) { + /** @type {LH.Result} */ + const lhr = JSON.parse(lhrString); + // TODO: Resolve the below so we don't need to force it to a boolean value: // 1) The string|boolean story for proto // 2) CI gets a absolute path during yarn diff:sample-json @@ -35,30 +42,14 @@ function cleanAndFormatLHR(lhr, opts = {}) { entry.startTime = 0; // Not realsitic, but avoids a lot of diff churn }); - const baseCallFrameUrl = url.pathToFileURL(LH_ROOT); - - for (const auditResult of Object.values(lhr.audits)) { - if (!opts.skipDescription) { + if (extraFlag !== '--only-remove-timing') { + for (const auditResult of Object.values(lhr.audits)) { auditResult.description = '**Excluded from diff**'; } - if (auditResult.errorStack) { - auditResult.errorStack = auditResult.errorStack.replaceAll(baseCallFrameUrl.href, ''); - } } -} -if (esMain(import.meta)) { - const filename = process.argv[2]; - const extraFlag = process.argv[3]; - if (!filename) throw new Error('No filename provided.'); + elideErrorStacks(lhr); - const lhr = JSON.parse(readFileSync(filename, 'utf8')); - cleanAndFormatLHR(lhr, { - skipDescription: extraFlag === '--only-remove-timing', - }); // Ensure we have a final newline to conform to .editorconfig - const cleaned = `${JSON.stringify(lhr, null, 2)}\n`; - writeFileSync(filename, cleaned, 'utf8'); + return `${JSON.stringify(lhr, null, 2)}\n`; } - -export {cleanAndFormatLHR}; diff --git a/core/scripts/update-flow-fixtures.js b/core/scripts/update-flow-fixtures.js index 442229700677..3a583e066090 100644 --- a/core/scripts/update-flow-fixtures.js +++ b/core/scripts/update-flow-fixtures.js @@ -19,7 +19,6 @@ import log from 'lighthouse-logger'; import {LH_ROOT} from '../../root.js'; import * as api from '../index.js'; import * as assetSaver from '../lib/asset-saver.js'; -import {cleanAndFormatLHR} from './cleanup-LHR-for-diff.js'; /* eslint-disable max-len */ const ARTIFACTS_PATH = `${LH_ROOT}/core/test/fixtures/fraggle-rock/artifacts/`; @@ -152,10 +151,8 @@ async function generateFlowResult() { // Normalize some data so it doesn't change on every update. for (const {lhr} of flowResult.steps) { - cleanAndFormatLHR(lhr, {skipDescription: true}); - // TODO: should remove this next line if cleanAndFormatLHR is refactored - lhr.configSettings.auditMode = false; assetSaver.normalizeTimingEntries(lhr.timing.entries); + assetSaver.elideErrorStacks(lhr); lhr.timing.total = lhr.timing.entries.length; } From 046bae7f091c06466cffafb32a8d930128fe5dae Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 4 May 2023 09:43:00 -0700 Subject: [PATCH 5/8] fix test bitrot --- core/legacy/config/config.js | 2 +- core/test/gather/driver/execution-context-test.js | 1 + core/test/lib/asset-saver-test.js | 4 +++- core/test/runner-test.js | 4 ++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/legacy/config/config.js b/core/legacy/config/config.js index af55d7c33d96..d5de3e613006 100644 --- a/core/legacy/config/config.js +++ b/core/legacy/config/config.js @@ -205,7 +205,7 @@ class LegacyResolvedConfig { } /** - * @deprecated `Config.fromJson` should be used instead. + * @deprecated `LegacyResolvedConfig.fromJson` should be used instead. * @constructor * @param {LH.Config} config * @param {{settings: LH.Config.Settings, passes: ?LH.Config.Pass[], audits: ?LH.Config.AuditDefn[]}} opts diff --git a/core/test/gather/driver/execution-context-test.js b/core/test/gather/driver/execution-context-test.js index 7d54037140b3..c8e2d5bdc904 100644 --- a/core/test/gather/driver/execution-context-test.js +++ b/core/test/gather/driver/execution-context-test.js @@ -209,6 +209,7 @@ describe('.evaluateAsync', () => { .mockResponse('Page.enable') .mockResponse('Runtime.enable') .mockResponse('Page.getResourceTree', {frameTree: {frame: {id: '1337'}}}) + .mockResponse('Page.getFrameTree', {frameTree: {frame: {id: '1337'}}}) .mockResponse('Page.createIsolatedWorld', {executionContextId: 9001}) .mockResponse('Runtime.evaluate', {exceptionDetails}); diff --git a/core/test/lib/asset-saver-test.js b/core/test/lib/asset-saver-test.js index 8bad973a7ed6..77e0be149795 100644 --- a/core/test/lib/asset-saver-test.js +++ b/core/test/lib/asset-saver-test.js @@ -370,7 +370,9 @@ describe('asset-saver helper', () => { // Use an LighthouseError that has an ICU replacement. const protocolMethod = 'Page.getFastness'; const lhError = new LighthouseError( - LighthouseError.errors.PROTOCOL_TIMEOUT, {protocolMethod}, new Error('the cause')); + LighthouseError.errors.PROTOCOL_TIMEOUT, + {protocolMethod}, + {cause: new Error('the cause')}); const artifacts = { traces: {}, diff --git a/core/test/runner-test.js b/core/test/runner-test.js index 2aebeaf45ddc..1f40a1855e82 100644 --- a/core/test/runner-test.js +++ b/core/test/runner-test.js @@ -610,7 +610,7 @@ describe('Runner', () => { it('produces an error audit result that prefers cause stack', async () => { const errorMessage = 'Audit yourself'; - const config = await Config.fromJson({ + const resolvedConfig = await LegacyResolvedConfig.fromJson({ settings: { auditMode: moduleDir + '/fixtures/artifacts/empty-artifacts/', }, @@ -629,7 +629,7 @@ describe('Runner', () => { ], }); - return runGatherAndAudit({}, {config}).then(results => { + return runGatherAndAudit({}, {resolvedConfig}).then(results => { const auditResult = results.lhr.audits['throwy-audit']; assert.strictEqual(auditResult.score, null); assert.strictEqual(auditResult.scoreDisplayMode, 'error'); From cf92c4c62e76f81b680f101d01cf600575e891e1 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 4 May 2023 10:34:04 -0700 Subject: [PATCH 6/8] fix unit test delta bc of node 18 vs 16 --- core/test/runner-test.js | 4 ++-- package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/core/test/runner-test.js b/core/test/runner-test.js index 1f40a1855e82..55c160673bf3 100644 --- a/core/test/runner-test.js +++ b/core/test/runner-test.js @@ -604,7 +604,7 @@ describe('Runner', () => { assert.strictEqual(auditResult.score, null); assert.strictEqual(auditResult.scoreDisplayMode, 'error'); assert.ok(auditResult.errorMessage.includes(errorMessage)); - assert.ok(auditResult.errorStack.includes('at Function.audit')); + assert.ok(auditResult.errorStack.match(/at [a-zA-Z]*.audit/)); }); }); @@ -634,7 +634,7 @@ describe('Runner', () => { assert.strictEqual(auditResult.score, null); assert.strictEqual(auditResult.scoreDisplayMode, 'error'); assert.ok(auditResult.errorMessage.includes(errorMessage)); - assert.ok(auditResult.errorStack.includes('at Function.aFn')); + assert.ok(auditResult.errorStack.match(/at [a-zA-Z]*.aFn/)); }); }); }); diff --git a/package.json b/package.json index 18d4908add27..b8557079c856 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ "rollup-plugin-terser": "^7.0.2", "tabulator-tables": "^4.9.3", "terser": "^5.3.8", - "testdouble": "^3.16.8", + "testdouble": "^3.17.2", "typed-query-selector": "^2.6.1", "typescript": "^5.0.4", "wait-for-expect": "^3.0.2", diff --git a/yarn.lock b/yarn.lock index 237df1f8a63a..a34af9e9d8a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6134,7 +6134,7 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -quibble@^0.6.14: +quibble@^0.6.17: version "0.6.17" resolved "https://registry.yarnpkg.com/quibble/-/quibble-0.6.17.tgz#1c47d40c4ee670fc1a5a4277ee792ca6eec8f4ca" integrity sha512-uybGnGrx1hAhBCmzmVny+ycKaS5F71+q+iWVzbf8x/HyeEMDGeiQFVjWl1zhi4rwfTHa05+/NIExC4L5YRNPjQ== @@ -6998,13 +6998,13 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -testdouble@^3.16.8: - version "3.16.8" - resolved "https://registry.yarnpkg.com/testdouble/-/testdouble-3.16.8.tgz#9c761031f3693d973c726e31a60ee73cd2871c96" - integrity sha512-jOKYRJ9mfgDxwuUOj84sl9DWiP1+KpHcgnhjlSHC8h1ZxJT3KD1FAAFVqnqmmyrzc/+0DRbI/U5xo1/K3PLi8w== +testdouble@^3.17.2: + version "3.17.2" + resolved "https://registry.yarnpkg.com/testdouble/-/testdouble-3.17.2.tgz#a7d624c2040453580b4a636b3f017bf183a8f487" + integrity sha512-oRrk1DJISNoFr3aaczIqrrhkOUQ26BsXN3SopYT/U0GTvk9hlKPCEbd9R2uxkcufKZgEfo9D1JAB4CJrjHE9cw== dependencies: lodash "^4.17.21" - quibble "^0.6.14" + quibble "^0.6.17" stringify-object-es5 "^2.5.0" theredoc "^1.0.0" From 5a077d857308001a08194d0ba7dafa8d1919c7e4 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 4 May 2023 14:24:16 -0700 Subject: [PATCH 7/8] pr --- core/gather/driver/execution-context.js | 4 ++-- core/lib/asset-saver.js | 8 +++++--- core/runner.js | 2 +- core/scripts/cleanup-LHR-for-diff.js | 4 ++-- core/scripts/update-flow-fixtures.js | 2 +- core/test/lib/asset-saver-test.js | 25 +++++++++++++++++++++++++ 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/core/gather/driver/execution-context.js b/core/gather/driver/execution-context.js index beae316b073a..f5238e517d9a 100644 --- a/core/gather/driver/execution-context.js +++ b/core/gather/driver/execution-context.js @@ -126,10 +126,10 @@ class ExecutionContext { this._session.setNextProtocolTimeout(timeout); const response = await this._session.sendCommand('Runtime.evaluate', evaluationParams); - // An error occurred before we could even create a Promise, should be *very* rare. - // Also occurs when the expression is not valid JavaScript. const ex = response.exceptionDetails; if (ex) { + // An error occurred before we could even create a Promise, should be *very* rare. + // Also occurs when the expression is not valid JavaScript. const elidedExpression = expression.replace(/\s+/g, ' ').substring(0, 100); const messageLines = [ 'Runtime.evaluate exception', diff --git a/core/lib/asset-saver.js b/core/lib/asset-saver.js index b3661b5948c6..5f7c421384b9 100644 --- a/core/lib/asset-saver.js +++ b/core/lib/asset-saver.js @@ -430,13 +430,15 @@ function normalizeTimingEntries(timings) { /** * @param {LH.Result} lhr */ -function elideErrorStacks(lhr) { +function elideAuditErrorStacks(lhr) { const baseCallFrameUrl = url.pathToFileURL(LH_ROOT); for (const auditResult of Object.values(lhr.audits)) { if (auditResult.errorStack) { auditResult.errorStack = auditResult.errorStack + // Make paths relative to the repo root. .replaceAll(baseCallFrameUrl.href, '') - .replaceAll(/:[^)]+\)/g, ':elided:elided'); + // Remove line/col info. + .replaceAll(/:\d+:\d+/g, ''); } } } @@ -454,5 +456,5 @@ export { saveLanternNetworkData, stringifyReplacer, normalizeTimingEntries, - elideErrorStacks, + elideAuditErrorStacks, }; diff --git a/core/runner.js b/core/runner.js index 59a2237b8677..64991d03a2c2 100644 --- a/core/runner.js +++ b/core/runner.js @@ -429,7 +429,7 @@ class Runner { // Create a friendlier display error and mark it as expected to avoid duplicates in Sentry const error = new LighthouseError(LighthouseError.errors.ERRORED_REQUIRED_ARTIFACT, - {artifactName, errorMessage: artifactError.message}, artifactError); + {artifactName, errorMessage: artifactError.message}, {cause: artifactError}); // @ts-expect-error Non-standard property added to Error error.expected = true; throw error; diff --git a/core/scripts/cleanup-LHR-for-diff.js b/core/scripts/cleanup-LHR-for-diff.js index 168d310706db..d1d9f2d55688 100755 --- a/core/scripts/cleanup-LHR-for-diff.js +++ b/core/scripts/cleanup-LHR-for-diff.js @@ -10,7 +10,7 @@ import {readFileSync, writeFileSync} from 'fs'; -import {elideErrorStacks} from '../lib/asset-saver.js'; +import {elideAuditErrorStacks} from '../lib/asset-saver.js'; const filename = process.argv[2]; const extraFlag = process.argv[3]; @@ -48,7 +48,7 @@ function cleanAndFormatLHR(lhrString) { } } - elideErrorStacks(lhr); + elideAuditErrorStacks(lhr); // Ensure we have a final newline to conform to .editorconfig return `${JSON.stringify(lhr, null, 2)}\n`; diff --git a/core/scripts/update-flow-fixtures.js b/core/scripts/update-flow-fixtures.js index 3a583e066090..44d01a72eca3 100644 --- a/core/scripts/update-flow-fixtures.js +++ b/core/scripts/update-flow-fixtures.js @@ -152,7 +152,7 @@ async function generateFlowResult() { // Normalize some data so it doesn't change on every update. for (const {lhr} of flowResult.steps) { assetSaver.normalizeTimingEntries(lhr.timing.entries); - assetSaver.elideErrorStacks(lhr); + assetSaver.elideAuditErrorStacks(lhr); lhr.timing.total = lhr.timing.entries.length; } diff --git a/core/test/lib/asset-saver-test.js b/core/test/lib/asset-saver-test.js index 77e0be149795..3144e05c0669 100644 --- a/core/test/lib/asset-saver-test.js +++ b/core/test/lib/asset-saver-test.js @@ -443,4 +443,29 @@ describe('asset-saver helper', () => { }); }); }); + + describe('elideAuditErrorStacks', () => { + it('elides correctly', async () => { + const lhr = JSON.parse(JSON.stringify(dbwResults)); + lhr.audits['bf-cache'].errorStack = `Error: LighthouseError: ERRORED_REQUIRED_ARTIFACT + at Runner._runAudit (${LH_ROOT}/core/runner.js:431:25) + at Runner._runAudits (${LH_ROOT}/core/runner.js:370:40) + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) + at async Runner.audit (${LH_ROOT}/core/runner.js:62:32) + at async runLighthouse (${LH_ROOT}/cli/run.js:250:8) + at async ${LH_ROOT}/cli/index.js:10:1 + at :1:1`; + assetSaver.elideAuditErrorStacks(lhr); + + // eslint-disable-next-line max-len + expect(lhr.audits['bf-cache'].errorStack).toEqual(`Error: LighthouseError: ERRORED_REQUIRED_ARTIFACT + at Runner._runAudit (/Users/cjamcl/src/lighthouse/core/runner.js) + at Runner._runAudits (/Users/cjamcl/src/lighthouse/core/runner.js) + at process.processTicksAndRejections (node:internal/process/task_queues) + at async Runner.audit (/Users/cjamcl/src/lighthouse/core/runner.js) + at async runLighthouse (/Users/cjamcl/src/lighthouse/cli/run.js) + at async /Users/cjamcl/src/lighthouse/cli/index.js + at `); + }); + }); }); From 314953275854dfd3b555f529a88cc4d677303053 Mon Sep 17 00:00:00 2001 From: Connor Clark Date: Thu, 4 May 2023 14:58:05 -0700 Subject: [PATCH 8/8] oops --- core/lib/asset-saver.js | 2 +- core/test/lib/asset-saver-test.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/lib/asset-saver.js b/core/lib/asset-saver.js index 5f7c421384b9..87ac80dd4c49 100644 --- a/core/lib/asset-saver.js +++ b/core/lib/asset-saver.js @@ -436,7 +436,7 @@ function elideAuditErrorStacks(lhr) { if (auditResult.errorStack) { auditResult.errorStack = auditResult.errorStack // Make paths relative to the repo root. - .replaceAll(baseCallFrameUrl.href, '') + .replaceAll(baseCallFrameUrl.pathname, '') // Remove line/col info. .replaceAll(/:\d+:\d+/g, ''); } diff --git a/core/test/lib/asset-saver-test.js b/core/test/lib/asset-saver-test.js index 3144e05c0669..96cdda27d34a 100644 --- a/core/test/lib/asset-saver-test.js +++ b/core/test/lib/asset-saver-test.js @@ -459,12 +459,12 @@ describe('asset-saver helper', () => { // eslint-disable-next-line max-len expect(lhr.audits['bf-cache'].errorStack).toEqual(`Error: LighthouseError: ERRORED_REQUIRED_ARTIFACT - at Runner._runAudit (/Users/cjamcl/src/lighthouse/core/runner.js) - at Runner._runAudits (/Users/cjamcl/src/lighthouse/core/runner.js) + at Runner._runAudit (/core/runner.js) + at Runner._runAudits (/core/runner.js) at process.processTicksAndRejections (node:internal/process/task_queues) - at async Runner.audit (/Users/cjamcl/src/lighthouse/core/runner.js) - at async runLighthouse (/Users/cjamcl/src/lighthouse/cli/run.js) - at async /Users/cjamcl/src/lighthouse/cli/index.js + at async Runner.audit (/core/runner.js) + at async runLighthouse (/cli/run.js) + at async /cli/index.js at `); }); });