diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html index ea2be755e7f6..3546fb908520 100644 --- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html +++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html @@ -293,5 +293,6 @@

Do better web tester page

} + diff --git a/lighthouse-cli/test/fixtures/static-server.js b/lighthouse-cli/test/fixtures/static-server.js index 32550ca17c65..b74693624cf2 100644 --- a/lighthouse-cli/test/fixtures/static-server.js +++ b/lighthouse-cli/test/fixtures/static-server.js @@ -24,7 +24,13 @@ const parseURL = require('url').parse; function requestHandler(request, response) { const filePath = parseURL(request.url).pathname; const queryString = parseURL(request.url).search; - const absoluteFilePath = path.join(__dirname, filePath); + let absoluteFilePath = path.join(__dirname, filePath); + if (filePath === '/promise_polyfill.js') { + // evaluateAsync previously had a bug that LH would fail if a page polyfilled Promise. + // We bring in a third-party Promise polyfill to ensure we don't still fail. + const thirdPartyPath = '../../../lighthouse-core/third_party'; + absoluteFilePath = path.join(__dirname, `${thirdPartyPath}/promise-polyfill/promise.js`); + } fs.exists(absoluteFilePath, fsExistsCallback); diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js index 1b6b7702b6bf..34aff932fcea 100644 --- a/lighthouse-core/gather/driver.js +++ b/lighthouse-core/gather/driver.js @@ -129,30 +129,44 @@ class Driver { } /** - * Evaluate an expression in the context of the current page. Expression must - * evaluate to a Promise. Returns a promise that resolves on asyncExpression's - * resolved value. - * @param {string} asyncExpression + * Evaluate an expression in the context of the current page. + * Returns a promise that resolves on the expression's value. + * @param {string} expression * @return {!Promise<*>} */ - evaluateAsync(asyncExpression) { + evaluateAsync(expression) { return new Promise((resolve, reject) => { // If this gets to 60s and it hasn't been resolved, reject the Promise. const asyncTimeout = setTimeout( (_ => reject(new Error('The asynchronous expression exceeded the allotted time of 60s'))), 60000 ); + this.sendCommand('Runtime.evaluate', { - expression: asyncExpression, + // We need to wrap the raw expression for several purposes + // 1. Ensure that the expression will be a native Promise and not a polyfill/non-Promise. + // 2. Ensure that errors captured in the Promise are converted into plain-old JS Objects + // so that they can be serialized properly b/c JSON.stringify(new Error('foo')) === '{}' + expression: `(function wrapInNativePromise() { + const __nativePromise = window.__nativePromise || Promise; + return __nativePromise.resolve() + .then(_ => ${expression}) + .catch(${wrapRuntimeEvalErrorInBrowser.toString()}); + }())`, includeCommandLineAPI: true, awaitPromise: true, returnByValue: true }).then(result => { clearTimeout(asyncTimeout); + const value = result.result.value; + if (result.exceptionDetails) { - reject(result.exceptionDetails.exception.value); + // An error occurred before we could even create a Promise, should be *very* rare + reject(new Error('an unexpected driver error occurred')); + } if (value && value.__failedInBrowser) { + reject(Object.assign(new Error(), value)); } else { - resolve(result.result.value); + resolve(value); } }).catch(err => { clearTimeout(asyncTimeout); @@ -746,4 +760,23 @@ function captureJSCallUsage(funcRef, set) { }; } +/** + * The `exceptionDetails` provided by the debugger protocol does not contain the useful + * information such as name, message, and stack trace of the error when it's wrapped in a + * promise. Instead, map to a successful object that contains this information. + * @param {string|Error} err The error to convert + * istanbul ignore next + */ +function wrapRuntimeEvalErrorInBrowser(err) { + err = err || new Error(); + const fallbackMessage = typeof err === 'string' ? err : 'unknown error'; + + return { + __failedInBrowser: true, + name: err.name || 'Error', + message: err.message || fallbackMessage, + stack: err.stack || (new Error()).stack, + }; +} + module.exports = Driver; diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js index ebf97887e234..753d0b4f769e 100644 --- a/lighthouse-core/gather/gather-runner.js +++ b/lighthouse-core/gather/gather-runner.js @@ -30,8 +30,10 @@ const path = require('path'); * C. GatherRunner.setupDriver() * i. assertNoSameOriginServiceWorkerClients * ii. beginEmulation - * iii. cleanAndDisableBrowserCaches - * iiii. clearDataForOrigin + * iii. enableRuntimeEvents + * iv. evaluateScriptOnLoad rescue native Promise from potential polyfill + * v. cleanAndDisableBrowserCaches + * vi. clearDataForOrigin * * 2. For each pass in the config: * A. GatherRunner.beforePass() @@ -90,6 +92,7 @@ class GatherRunner { return driver.assertNoSameOriginServiceWorkerClients(options.url) .then(_ => driver.beginEmulation(options.flags)) .then(_ => driver.enableRuntimeEvents()) + .then(_ => driver.evaluateScriptOnLoad('window.__nativePromise = Promise;')) .then(_ => driver.cleanAndDisableBrowserCaches()) .then(_ => driver.clearDataForOrigin(options.url)); } diff --git a/lighthouse-core/gather/gatherers/accessibility.js b/lighthouse-core/gather/gatherers/accessibility.js index 20292f4b9407..56b01b237033 100644 --- a/lighthouse-core/gather/gatherers/accessibility.js +++ b/lighthouse-core/gather/gatherers/accessibility.js @@ -43,9 +43,13 @@ class Accessibility extends Gatherer { afterPass(options) { const driver = options.driver; + const expression = `(function () { + ${axe}; + return (${runA11yChecks.toString()}()); + })()`; return driver - .evaluateAsync(`${axe};(${runA11yChecks.toString()}())`) + .evaluateAsync(expression) .then(returnedValue => { if (!returnedValue) { this.artifact = Accessibility._errorAccessibility('Unable to parse axe results'); diff --git a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js index cb0603c19644..349ffbcd24a4 100644 --- a/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js +++ b/lighthouse-core/gather/gatherers/dobetterweb/tags-blocking-first-paint.js @@ -53,7 +53,8 @@ function collectTagsThatBlockFirstPaint() { }); resolve(tagList); } catch (e) { - reject(`Unable to gather Scripts/Stylesheets/HTML Imports on the page: ${e.message}`); + const friendly = 'Unable to gather Scripts/Stylesheets/HTML Imports on the page'; + reject(new Error(`${friendly}: ${e.message}`)); } }); } @@ -117,10 +118,10 @@ class TagsBlockingFirstPaint extends Gatherer { .then(artifact => { this.artifact = artifact; }) - .catch(debugString => { + .catch(err => { this.artifact = { value: -1, - debugString + debugString: err.message }; }); } diff --git a/lighthouse-core/test/gather/fake-driver.js b/lighthouse-core/test/gather/fake-driver.js index c27a06c40e5e..8ecc48c59a3b 100644 --- a/lighthouse-core/test/gather/fake-driver.js +++ b/lighthouse-core/test/gather/fake-driver.js @@ -38,6 +38,9 @@ module.exports = { enableRuntimeEvents() { return Promise.resolve(); }, + evaluateScriptOnLoad() { + return Promise.resolve(); + }, cleanAndDisableBrowserCaches() {}, clearDataForOrigin() {}, beginTrace() { diff --git a/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js b/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js index a77cf3f1f14d..07ffa3783459 100644 --- a/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js +++ b/lighthouse-core/test/gather/gatherers/dobetterweb/tags-blocking-first-paint-test.js @@ -131,7 +131,7 @@ describe('First paint blocking tags', () => { return tagsBlockingFirstPaint.afterPass({ driver: { evaluateAsync() { - return Promise.reject('such a fail'); + return Promise.reject(new Error('such a fail')); } } }, traceData).then(_ => { diff --git a/lighthouse-core/third_party/promise-polyfill/promise.js b/lighthouse-core/third_party/promise-polyfill/promise.js new file mode 100644 index 000000000000..e4c2e85b1a4f --- /dev/null +++ b/lighthouse-core/third_party/promise-polyfill/promise.js @@ -0,0 +1,257 @@ +/* + * @license + * From taylorhakes/promise-polyfill + + * Copyright (c) 2014 Taylor Hakes + * Copyright (c) 2014 Forbes Lindesay + + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. +*/ +'use strict'; + +(function() { + /* eslint-disable */ + // Store setTimeout reference so promise-polyfill will be unaffected by + // other code modifying setTimeout (like sinon.useFakeTimers()) + var setTimeoutFunc = setTimeout; + + function noop() {} + + // Polyfill for Function.prototype.bind + function bind(fn, thisArg) { + return function () { + fn.apply(thisArg, arguments); + }; + } + + function Promise(fn) { + if (typeof this !== 'object') throw new TypeError('Promises must be constructed via new'); + if (typeof fn !== 'function') throw new TypeError('not a function'); + this._state = 0; + this._handled = false; + this._value = undefined; + this._deferreds = []; + + doResolve(fn, this); + } + + function handle(self, deferred) { + while (self._state === 3) { + self = self._value; + } + if (self._state === 0) { + self._deferreds.push(deferred); + return; + } + self._handled = true; + Promise._immediateFn(function () { + var cb = self._state === 1 ? deferred.onFulfilled : deferred.onRejected; + if (cb === null) { + (self._state === 1 ? resolve : reject)(deferred.promise, self._value); + return; + } + var ret; + try { + ret = cb(self._value); + } catch (e) { + reject(deferred.promise, e); + return; + } + resolve(deferred.promise, ret); + }); + } + + function resolve(self, newValue) { + try { + // Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure + if (newValue === self) throw new TypeError('A promise cannot be resolved with itself.'); + if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) { + var then = newValue.then; + if (newValue instanceof Promise) { + self._state = 3; + self._value = newValue; + finale(self); + return; + } else if (typeof then === 'function') { + doResolve(bind(then, newValue), self); + return; + } + } + self._state = 1; + self._value = newValue; + finale(self); + } catch (e) { + reject(self, e); + } + } + + function reject(self, newValue) { + self._state = 2; + self._value = newValue; + finale(self); + } + + function finale(self) { + if (self._state === 2 && self._deferreds.length === 0) { + Promise._immediateFn(function() { + if (!self._handled) { + Promise._unhandledRejectionFn(self._value); + } + }); + } + + for (var i = 0, len = self._deferreds.length; i < len; i++) { + handle(self, self._deferreds[i]); + } + self._deferreds = null; + } + + function Handler(onFulfilled, onRejected, promise) { + this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null; + this.onRejected = typeof onRejected === 'function' ? onRejected : null; + this.promise = promise; + } + + /** + * Take a potentially misbehaving resolver function and make sure + * onFulfilled and onRejected are only called once. + * + * Makes no guarantees about asynchrony. + */ + function doResolve(fn, self) { + var done = false; + try { + fn(function (value) { + if (done) return; + done = true; + resolve(self, value); + }, function (reason) { + if (done) return; + done = true; + reject(self, reason); + }); + } catch (ex) { + if (done) return; + done = true; + reject(self, ex); + } + } + + Promise.prototype['catch'] = function (onRejected) { + return this.then(null, onRejected); + }; + + Promise.prototype.then = function (onFulfilled, onRejected) { + var prom = new (this.constructor)(noop); + + handle(this, new Handler(onFulfilled, onRejected, prom)); + return prom; + }; + + Promise.all = function (arr) { + var args = Array.prototype.slice.call(arr); + + return new Promise(function (resolve, reject) { + if (args.length === 0) return resolve([]); + var remaining = args.length; + + function res(i, val) { + try { + if (val && (typeof val === 'object' || typeof val === 'function')) { + var then = val.then; + if (typeof then === 'function') { + then.call(val, function (val) { + res(i, val); + }, reject); + return; + } + } + args[i] = val; + if (--remaining === 0) { + resolve(args); + } + } catch (ex) { + reject(ex); + } + } + + for (var i = 0; i < args.length; i++) { + res(i, args[i]); + } + }); + }; + + Promise.resolve = function (value) { + if (value && typeof value === 'object' && value.constructor === Promise) { + return value; + } + + return new Promise(function (resolve) { + resolve(value); + }); + }; + + Promise.reject = function (value) { + return new Promise(function (resolve, reject) { + reject(value); + }); + }; + + Promise.race = function (values) { + return new Promise(function (resolve, reject) { + for (var i = 0, len = values.length; i < len; i++) { + values[i].then(resolve, reject); + } + }); + }; + + // Use polyfill for setImmediate for performance gains + Promise._immediateFn = (typeof setImmediate === 'function' && function (fn) { setImmediate(fn); }) || + function (fn) { + setTimeoutFunc(fn, 0); + }; + + Promise._unhandledRejectionFn = function _unhandledRejectionFn(err) { + if (typeof console !== 'undefined' && console) { + console.warn('Possible Unhandled Promise Rejection:', err); // eslint-disable-line no-console + } + }; + + /** + * Set the immediate function to execute callbacks + * @param fn {function} Function to execute + * @deprecated + */ + Promise._setImmediateFn = function _setImmediateFn(fn) { + Promise._immediateFn = fn; + }; + + /** + * Change the function to execute on unhandled rejection + * @param {function} fn Function to execute on unhandled rejection + * @deprecated + */ + Promise._setUnhandledRejectionFn = function _setUnhandledRejectionFn(fn) { + Promise._unhandledRejectionFn = fn; + }; + + window.Promise = Promise; + + /* eslint-enable */ +})();