core(source-maps): workaround CORS for fetching maps (#9459)

This commit is contained in:
Connor Clark 2020-03-17 16:52:35 -07:00 коммит произвёл GitHub
Родитель 30ec2e33c3
Коммит 72f07747f3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 336 добавлений и 51 удалений

8
lighthouse-cli/test/fixtures/source-map/script.js.map поставляемый Normal file
Просмотреть файл

@ -0,0 +1,8 @@
{
"version": 3,
"file": "out.js",
"sourceRoot": "",
"sources": ["foo.js", "bar.js"],
"names": ["src", "maps", "are", "fun"],
"mappings": "AAgBC,SAAQ,CAAEA"
}

30
lighthouse-cli/test/fixtures/source-map/source-map-tester.html поставляемый Normal file
Просмотреть файл

@ -0,0 +1,30 @@
<!--
* Copyright 2019 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Source maps tester</title>
</head>
<body>
<script>
// Test that source maps work.
//# sourceMappingURL=http://localhost:10200/source-map/script.js.map
</script>
<script>
// Test that source maps work when on a different origin (CORS).
//# sourceMappingURL=http://localhost:10503/source-map/script.js.map
</script>
map time map time! map time map time! 🎉
</body>
</html>

Просмотреть файл

@ -70,11 +70,14 @@ const smokeTests = [{
id: 'mixed-content',
expectations: require('./mixed-content/mixed-content-expectations.js'),
config: require('../../../../lighthouse-core/config/mixed-content-config.js'),
},
{
}, {
id: 'legacy-javascript',
expectations: require('./legacy-javascript/expectations.js'),
config: require('./legacy-javascript/legacy-javascript-config.js'),
}, {
id: 'source-maps',
expectations: require('./source-maps/expectations.js'),
config: require('./source-maps/source-maps-config.js'),
}];
module.exports = smokeTests;

Просмотреть файл

@ -0,0 +1,42 @@
/**
* @license Copyright 2019 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
'use strict';
const fs = require('fs');
const mapPath = require.resolve('../../../fixtures/source-map/script.js.map');
const mapJson = fs.readFileSync(mapPath, 'utf-8');
const map = JSON.parse(mapJson);
/**
* @type {Array<Smokehouse.ExpectedRunnerResult>}
* Expected Lighthouse audit values for seo tests
*/
const expectations = [
{
artifacts: {
SourceMaps: [
{
scriptUrl: 'http://localhost:10200/source-map/source-map-tester.html',
sourceMapUrl: 'http://localhost:10200/source-map/script.js.map',
map,
},
{
scriptUrl: 'http://localhost:10200/source-map/source-map-tester.html',
sourceMapUrl: 'http://localhost:10503/source-map/script.js.map',
map,
},
],
},
lhr: {
requestedUrl: 'http://localhost:10200/source-map/source-map-tester.html',
finalUrl: 'http://localhost:10200/source-map/source-map-tester.html',
audits: {},
},
},
];
module.exports = expectations;

Просмотреть файл

@ -0,0 +1,26 @@
/**
* @license Copyright 2019 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
'use strict';
/**
* Config file for running source map smokehouse.
*/
// source-maps currently isn't in the default config yet, so we make a new one with it.
// Also, no audits use source-maps yet, and at least one is required for a successful run,
// so `viewport` and its required gatherer `meta-elements` is used.
/** @type {LH.Config.Json} */
module.exports = {
passes: [{
passName: 'defaultPass',
gatherers: [
'source-maps',
'meta-elements',
],
}],
audits: ['viewport'],
};

Просмотреть файл

@ -5,6 +5,7 @@
*/
'use strict';
const Fetcher = require('./fetcher.js');
const NetworkRecorder = require('../lib/network-recorder.js');
const emulation = require('../lib/emulation.js');
const LHElement = require('../lib/lh-element.js');
@ -90,6 +91,9 @@ class Driver {
* @private
*/
this._nextProtocolTimeout = DEFAULT_PROTOCOL_TIMEOUT;
/** @type {Fetcher} */
this.fetcher = new Fetcher(this);
}
static get traceCategories() {

Просмотреть файл

@ -0,0 +1,178 @@
/**
* @license Copyright 2020 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/
'use strict';
/**
* @fileoverview Fetcher is a utility for making requests within the context of the page.
* Requests can circumvent CORS, and so are good for fetching source maps that may be hosted
* on a different origin.
*/
/* global document */
class Fetcher {
/**
* @param {import('./driver.js')} driver
*/
constructor(driver) {
this.driver = driver;
/** @type {Map<string, (event: LH.Crdp.Fetch.RequestPausedEvent) => void>} */
this._onRequestPausedHandlers = new Map();
this._onRequestPaused = this._onRequestPaused.bind(this);
this._enabled = false;
}
/**
* The Fetch domain accepts patterns for controlling what requests are intercepted, but we
* enable the domain for all patterns and filter events at a lower level to support multiple
* concurrent usages. Reasons for this:
*
* 1) only one set of patterns may be applied for the entire domain.
* 2) every request that matches the patterns are paused and only resumes when certain Fetch
* commands are sent. So a listener of the `Fetch.requestPaused` event must either handle
* the requests it cares about, or explicitly allow them to continue.
* 3) if multiple commands to continue the same request are sent, protocol errors occur.
*
* So instead we have one global `Fetch.enable` / `Fetch.requestPaused` pair, and allow specific
* urls to be intercepted via `fetcher._setOnRequestPausedHandler`.
*/
async enableRequestInterception() {
if (this._enabled) return;
this._enabled = true;
await this.driver.sendCommand('Fetch.enable', {
patterns: [{requestStage: 'Request'}, {requestStage: 'Response'}],
});
await this.driver.on('Fetch.requestPaused', this._onRequestPaused);
}
async disableRequestInterception() {
if (!this._enabled) return;
this._enabled = false;
await this.driver.off('Fetch.requestPaused', this._onRequestPaused);
await this.driver.sendCommand('Fetch.disable');
this._onRequestPausedHandlers.clear();
}
/**
* @param {string} url
* @param {(event: LH.Crdp.Fetch.RequestPausedEvent) => void} handler
*/
async _setOnRequestPausedHandler(url, handler) {
this._onRequestPausedHandlers.set(url, handler);
}
/**
* @param {LH.Crdp.Fetch.RequestPausedEvent} event
*/
async _onRequestPaused(event) {
const handler = this._onRequestPausedHandlers.get(event.request.url);
if (handler) {
await handler(event);
} else {
// Nothing cares about this URL, so continue.
await this.driver.sendCommand('Fetch.continueRequest', {requestId: event.requestId});
}
}
/**
* Requires that `driver.enableRequestInterception` has been called.
*
* Fetches any resource in a way that circumvents CORS.
*
* @param {string} url
* @param {{timeout: number}} options timeout is in ms
* @return {Promise<string>}
*/
async fetchResource(url, {timeout = 500}) {
if (!this._enabled) {
throw new Error('Must call `enableRequestInterception` before using fetchResource');
}
/** @type {Promise<string>} */
const requestInterceptionPromise = new Promise((resolve, reject) => {
this._setOnRequestPausedHandler(url, async (event) => {
const {requestId, responseStatusCode} = event;
// The first requestPaused event is for the request stage. Continue it.
if (!responseStatusCode) {
// Remove cookies so we aren't buying stuff on Amazon.
const headers = Object.entries(event.request.headers)
.filter(([name]) => name !== 'Cookie')
.map(([name, value]) => {
return {name, value};
});
this.driver.sendCommand('Fetch.continueRequest', {
requestId,
headers,
});
return;
}
// Now in the response stage, but the request failed.
if (!(responseStatusCode >= 200 && responseStatusCode < 300)) {
reject(new Error(`Invalid response status code: ${responseStatusCode}`));
return;
}
const responseBody = await this.driver.sendCommand('Fetch.getResponseBody', {requestId});
if (responseBody.base64Encoded) {
resolve(Buffer.from(responseBody.body, 'base64').toString());
} else {
resolve(responseBody.body);
}
// Fail the request (from the page's perspective) so that the iframe never loads.
this.driver.sendCommand('Fetch.failRequest', {requestId, errorReason: 'Aborted'});
});
});
/**
* @param {string} src
*/
/* istanbul ignore next */
function injectIframe(src) {
/** @type {HTMLIFrameElement} */
const iframe = document.createElement('iframe');
// Try really hard not to affect the page.
iframe.style.display = 'none';
iframe.style.visibility = 'hidden';
iframe.style.position = 'absolute';
iframe.style.top = '-1000px';
iframe.style.left = '-1000px';
iframe.style.width = '1px';
iframe.style.height = '1px';
iframe.src = src;
iframe.onload = iframe.onerror = () => {
iframe.remove();
delete iframe.onload;
delete iframe.onerror;
};
document.body.appendChild(iframe);
}
await this.driver.evaluateAsync(`${injectIframe}(${JSON.stringify(url)})`, {
useIsolation: true,
});
/** @type {NodeJS.Timeout} */
let timeoutHandle;
/** @type {Promise<never>} */
const timeoutPromise = new Promise((_, reject) => {
const errorMessage = 'Timed out fetching resource.';
timeoutHandle = setTimeout(() => reject(new Error(errorMessage)), timeout);
});
return Promise.race([
timeoutPromise,
requestInterceptionPromise,
]).finally(() => clearTimeout(timeoutHandle));
}
}
module.exports = Fetcher;

Просмотреть файл

@ -131,6 +131,11 @@ class GatherRunner {
const resetStorage = !options.settings.disableStorageReset;
if (resetStorage) await driver.clearDataForOrigin(options.requestedUrl);
// Disable fetcher, in case a gatherer enabled it.
// This cleanup should be removed once the only usage of
// fetcher (fetching arbitrary URLs) is replaced by new protocol support.
await driver.fetcher.disableRequestInterception();
await driver.disconnect();
} catch (err) {
// Ignore disconnecting error if browser was already closed.
@ -644,6 +649,12 @@ class GatherRunner {
await GatherRunner.populateBaseArtifacts(passContext);
isFirstPass = false;
}
// Disable fetcher for every pass, in case a gatherer enabled it.
// Noop if fetcher was never enabled.
// This cleanup should be removed once the only usage of
// fetcher (fetching arbitrary URLs) is replaced by new protocol support.
await driver.fetcher.disableRequestInterception();
}
await GatherRunner.disposeDriver(driver, options);

Просмотреть файл

@ -10,25 +10,6 @@
const Gatherer = require('./gatherer.js');
const URL = require('../../lib/url-shim.js');
/**
* This function fetches source maps; it is careful not to parse the response as JSON, as it will
* just need to be serialized again over the protocol, and source maps can
* be huge.
*
* @param {string} url
* @return {Promise<string>}
*/
/* istanbul ignore next */
async function fetchSourceMap(url) {
// eslint-disable-next-line no-undef
const response = await fetch(url);
if (response.ok) {
return response.text();
} else {
throw new Error(`Received status code ${response.status} for ${url}`);
}
}
/**
* @fileoverview Gets JavaScript source maps.
*/
@ -45,13 +26,9 @@ class SourceMaps extends Gatherer {
* @param {string} sourceMapUrl
* @return {Promise<LH.Artifacts.RawSourceMap>}
*/
async fetchSourceMapInPage(driver, sourceMapUrl) {
driver.setNextProtocolTimeout(1500);
async fetchSourceMap(driver, sourceMapUrl) {
/** @type {string} */
const sourceMapJson =
await driver.evaluateAsync(`(${fetchSourceMap})(${JSON.stringify(sourceMapUrl)})`, {
useIsolation: true,
});
const sourceMapJson = await driver.fetcher.fetchResource(sourceMapUrl, {timeout: 1500});
return JSON.parse(sourceMapJson);
}
@ -126,7 +103,7 @@ class SourceMaps extends Gatherer {
try {
const map = isSourceMapADataUri ?
this.parseSourceMapFromDataUrl(rawSourceMapUrl) :
await this.fetchSourceMapInPage(driver, rawSourceMapUrl);
await this.fetchSourceMap(driver, rawSourceMapUrl);
if (map.sections) {
map.sections = map.sections.filter(section => section.map);
}
@ -154,9 +131,9 @@ class SourceMaps extends Gatherer {
driver.off('Debugger.scriptParsed', this.onScriptParsed);
await driver.sendCommand('Debugger.disable');
await driver.fetcher.enableRequestInterception();
const eventProcessPromises = this._scriptParsedEvents
.map((event) => this._retrieveMapFromScriptParsedEvent(driver, event));
return Promise.all(eventProcessPromises);
}
}

Просмотреть файл

@ -12,6 +12,11 @@ function makeFakeDriver({protocolGetVersionResponse}) {
let scrollPosition = {x: 0, y: 0};
return {
get fetcher() {
return {
disableRequestInterception: () => Promise.resolve(),
};
},
getBrowserVersion() {
return Promise.resolve(Object.assign({}, protocolGetVersionResponse, {milestone: 71}));
},

Просмотреть файл

@ -34,11 +34,20 @@ describe('SourceMaps gatherer', () => {
* @return {Promise<LH.Artifacts['SourceMaps']>}
*/
async function runSourceMaps(mapsAndEvents) {
const onMock = createMockOnFn();
// pre-condition: should only define map or fetchError, not both.
for (const {map, fetchError} of mapsAndEvents) {
if (map && fetchError) {
throw new Error('should only define map or fetchError, not both.');
}
}
const onMock = createMockOnFn();
const sendCommandMock = createMockSendCommandFn()
.mockResponse('Debugger.enable', {})
.mockResponse('Debugger.disable', {});
.mockResponse('Debugger.disable', {})
.mockResponse('Fetch.enable', {})
.mockResponse('Fetch.disable', {});
const fetchMock = jest.fn();
for (const {scriptParsedEvent, map, resolvedSourceMapUrl, fetchError} of mapsAndEvents) {
onMock.mockEvent('protocolevent', {
@ -47,37 +56,29 @@ describe('SourceMaps gatherer', () => {
});
if (scriptParsedEvent.sourceMapURL.startsWith('data:')) {
// Only the source maps that need to be fetched use the `evaluateAsync` code path.
// Only the source maps that need to be fetched use the `fetchMock` code path.
continue;
}
if (map && fetchError) {
throw new Error('should only define map or fetchError, not both.');
}
fetchMock.mockImplementationOnce(async (sourceMapUrl) => {
// Check that the source map url was resolved correctly.
if (resolvedSourceMapUrl) {
expect(sourceMapUrl).toBe(resolvedSourceMapUrl);
}
sendCommandMock
.mockResponse('Page.getResourceTree', {frameTree: {frame: {id: 1337}}})
.mockResponse('Page.createIsolatedWorld', {executionContextId: 1})
.mockResponse('Runtime.evaluate', ({expression, contextId}) => {
expect(contextId).toBe(driver._isolatedExecutionContextId);
if (fetchError) {
throw new Error(fetchError);
}
// Check that the source map url was resolved correctly. It'll be somewhere
// in the code sent to Runtime.evaluate.
if (resolvedSourceMapUrl && !expression.includes(resolvedSourceMapUrl)) {
throw new Error(`did not request expected url: ${resolvedSourceMapUrl}`);
}
const value = fetchError ?
Object.assign(new Error(), {message: fetchError, __failedInBrowser: true}) :
map;
return {result: {value}};
});
return map;
});
}
const connectionStub = new Connection();
connectionStub.sendCommand = sendCommandMock;
connectionStub.on = onMock;
const driver = new Driver(connectionStub);
driver.fetcher.fetchResource = fetchMock;
const sourceMaps = new SourceMaps();
await sourceMaps.beforePass({driver});