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

fix: evaluateAsync behavior #1037

Merged
merged 7 commits into from
Dec 3, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lighthouse-cli/test/fixtures/dobetterweb/dbw_tester.html
Original file line number Diff line number Diff line change
Expand Up @@ -293,5 +293,6 @@ <h2>Do better web tester page</h2>
}
</script>

<script src="/promise_polyfill.js"></script>
</body>
</html>
8 changes: 7 additions & 1 deletion lighthouse-cli/test/fixtures/static-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
49 changes: 41 additions & 8 deletions lighthouse-core/gather/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Copy link
Member

Choose a reason for hiding this comment

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

this feels like it could be simplified, but are there reasons for everything? Why wrap in a promise constructor and try/catch and Promise.resolve() instead of doing a single promise wrapper?

Copy link
Member

Choose a reason for hiding this comment

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

should also update the function docs

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes each has a purpose

try/catch - for errors that happen outside of promises
Promise.resolve - to enable sync executions
new Promise - to ensure the promise returned is indeed a native promise + avoid inconsistent error handling between sync and async paths

but as I'm typing this just remembering that we opted for Promise.resolve().then( => ), will fix

Copy link
Member

Choose a reason for hiding this comment

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

wanna add a comment to document this? we're definitely gonna be headscratching if we need to touch this code again. :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

done :)

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);
Copy link
Member

Choose a reason for hiding this comment

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

we can save for another issue (maybe this is also #941), but the fact that there are three cases here

  • success
  • failure due to driver error
  • failure (of some sort) due to the evaluated expression

makes it feel like we shouldn't be conflating the last two, but I'm not exactly sure of an elegant way to do this, or even what gatherers should do (catch or re-throw) if we do differentiate by type

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think the differentiation is just done by what error message and stack trace results. Unless we think driver error should be fatal?

Copy link
Member

Choose a reason for hiding this comment

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

yeah, I think @paulirish is addressing this in his latest comments in #941. We basically need a way to say "this is an error I expected (fetch rejected on offline request or whatever)" vs "whoooops". For this, the caller of driver.evaluateAsync will have to tell the difference for now and we can revisit as we figure out error paths

}
}).catch(err => {
clearTimeout(asyncTimeout);
Expand Down Expand Up @@ -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;
7 changes: 5 additions & 2 deletions lighthouse-core/gather/gather-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;'))
Copy link
Member

Choose a reason for hiding this comment

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

need to doc this in outline at top

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

done

.then(_ => driver.cleanAndDisableBrowserCaches())
.then(_ => driver.clearDataForOrigin(options.url));
}
Expand Down
6 changes: 5 additions & 1 deletion lighthouse-core/gather/gatherers/accessibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`));
}
});
}
Expand Down Expand Up @@ -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
};
});
}
Expand Down
3 changes: 3 additions & 0 deletions lighthouse-core/test/gather/fake-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ module.exports = {
enableRuntimeEvents() {
return Promise.resolve();
},
evaluateScriptOnLoad() {
return Promise.resolve();
},
cleanAndDisableBrowserCaches() {},
clearDataForOrigin() {},
beginTrace() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(_ => {
Expand Down
Loading