diff --git a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html index 546aa58e01f4..9e535c9f2ef1 100644 --- a/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html +++ b/lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html @@ -280,5 +280,6 @@

Do better web tester page

} + diff --git a/lighthouse-cli/test/fixtures/dobetterweb/promise_polyfill.js b/lighthouse-cli/test/fixtures/dobetterweb/promise_polyfill.js new file mode 100644 index 000000000000..fd3de3d04a0b --- /dev/null +++ b/lighthouse-cli/test/fixtures/dobetterweb/promise_polyfill.js @@ -0,0 +1,254 @@ +/* + * @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. +*/ +(function () { + + // 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; + +})(); diff --git a/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js b/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js index c8ea19f45f32..de8f5412e3a3 100644 --- a/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js +++ b/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js @@ -49,7 +49,8 @@ class LinkBlockingFirstPaintAudit extends Audit { if (typeof artifact === 'undefined' || artifact.value === -1) { return { rawValue: -1, - debugString: 'TagsBlockingFirstPaint gatherer did not run' + debugString: (artifact && artifact.debugString) || + 'TagsBlockingFirstPaint gatherer did not run' }; } diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js index 8b8c28685111..f9543b6e09a1 100644 --- a/lighthouse-core/gather/driver.js +++ b/lighthouse-core/gather/driver.js @@ -143,17 +143,33 @@ class Driver { (_ => reject(new Error('The asynchronous expression exceeded the allotted time of 60s'))), 60000 ); + this.sendCommand('Runtime.evaluate', { - expression: asyncExpression, + expression: `(function wrapInNativePromise() { + const __nativePromise = window.__nativePromise || Promise; + return new __nativePromise(function(resolve) { + const wrapError = ${wrapRuntimeEvalErrorInBrowser.toString()}; + try { + (${asyncExpression}).then(resolve, wrapError); + } catch (e) { + wrapError(e); + } + }); + }())`, 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 enter our try block, should be *very* rare + reject(new Error('an unknown driver error occurred')); + } if (value.__failedInBrowser) { + reject(Object.assign(new Error(), value)); } else { - resolve(result.result.value); + resolve(value); } }).catch(err => { clearTimeout(asyncTimeout); @@ -713,4 +729,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'; + + resolve({ + __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 814bbce79ea8..a467b26d823e 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..bd42536f5d63 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.toString() }; }); } 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() {