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

core(start-url): stay offline for entirety of offlinePass #9451

Merged
merged 8 commits into from
Aug 15, 2019
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 5 additions & 3 deletions lighthouse-core/gather/gatherers/offline.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class Offline extends Gatherer {
* @param {LH.Gatherer.PassContext} passContext
*/
beforePass(passContext) {
// This call sets up the offline state for the page navigation of the `offlinePass` in gather-runner.
// gather-runner will automatically go back online before the `afterPass` phase, so no additional
// cleanup is necessary.
return passContext.driver.goOffline();
}

Expand All @@ -21,14 +24,13 @@ class Offline extends Gatherer {
* @param {LH.Gatherer.LoadData} loadData
* @return {Promise<LH.Artifacts['Offline']>}
*/
afterPass(passContext, loadData) {
async afterPass(passContext, loadData) {
const navigationRecord = loadData.networkRecords.filter(record => {
return URL.equalWithExcludedFragments(record.url, passContext.url) &&
record.fetchedViaServiceWorker;
}).pop(); // Take the last record that matches.

return passContext.driver.goOnline(passContext)
Copy link
Member

Choose a reason for hiding this comment

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

can you add a comment around L16 that the goOnline now happens in start-url which sequentially follows this gatherer?

Copy link
Member

Choose a reason for hiding this comment

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

actually it seems like the goOffline in L16 isnt needed anymore since starturl does it on its own. right

Copy link
Collaborator Author

@patrickhulce patrickhulce Jul 25, 2019

Choose a reason for hiding this comment

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

can you add a comment around L16 that the goOnline now happens in start-url which sequentially follows this gatherer?

well the goOnline that cleans this up actually happens in gather-runner that was the problem. each afterPass happens while online and each gatherer has to handle its own online/offline cycle. but yes comment is great idea 👍

actually it seems like the goOffline in L16 isnt needed anymore since starturl does it on its own. right

it's still needed to setup the offline for the load of the page, we do the offline check for both the page and the start url separately

.then(_ => navigationRecord ? navigationRecord.statusCode : -1);
return navigationRecord ? navigationRecord.statusCode : -1;
}
}

Expand Down
29 changes: 23 additions & 6 deletions lighthouse-core/gather/gatherers/start-url.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,37 @@ const Gatherer = require('./gatherer.js');

class StartUrl extends Gatherer {
/**
* Grab the manifest, extract it's start_url, attempt to `fetch()` it while offline
* Go offline, assess the start url, go back online.
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['StartUrl']>}
*/
async afterPass(passContext) {
// Offline pass is not actually offline in `afterPass`,
// each gatherer must set and cleanup its own offline state.
await passContext.driver.goOffline();
const result = await this._determineStartUrlAvailability(passContext);
await passContext.driver.goOnline(passContext);

return result;
}

/**
* Grab the manifest, extract it's start_url, attempt to `fetch()` it while offline
* @param {LH.Gatherer.PassContext} passContext
* @return {Promise<LH.Artifacts['StartUrl']>}
*/
async _determineStartUrlAvailability(passContext) {
const manifest = passContext.baseArtifacts.WebAppManifest;
const startUrlInfo = this._readManifestStartUrl(manifest);
if (startUrlInfo.isReadFailure) {
return {statusCode: -1, explanation: startUrlInfo.reason};
}

return this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl).catch(() => {
return {statusCode: -1, explanation: 'Unable to fetch start URL via service worker.'};
});
try {
return await this._attemptStartURLFetch(passContext.driver, startUrlInfo.startUrl);
} catch (err) {
return {statusCode: -1, explanation: 'Unable to fetch start_url via service worker.'};
}
}

/**
Expand Down Expand Up @@ -59,7 +76,7 @@ class StartUrl extends Gatherer {
// Wait up to 3s to get a matched network request from the fetch() to work
const timeoutPromise = new Promise(resolve =>
setTimeout(
() => resolve({statusCode: -1, explanation: 'Timed out waiting for fetched start_url.'}),
() => resolve({statusCode: -1, explanation: 'Timed out waiting for start_url to respond.'}),
3000
)
);
Expand All @@ -77,7 +94,7 @@ class StartUrl extends Gatherer {
if (!response.fromServiceWorker) {
return resolve({
statusCode: -1,
explanation: 'Unable to fetch start URL via service worker.',
explanation: 'The start_url did respond, but not via a service worker.',
});
}
// Successful SW-served fetch of the start_URL
Expand Down
264 changes: 143 additions & 121 deletions lighthouse-core/test/gather/gatherers/start-url-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,168 +7,190 @@

/* eslint-env jest */

jest.useFakeTimers();

const StartUrlGatherer = require('../../../gather/gatherers/start-url.js');
const parseManifest = require('../../../lib/manifest-parser.js');
const assert = require('assert');

const mockDriver = {
goOffline() {
return Promise.resolve();
},
goOnline() {
return Promise.resolve();
},
off() {},
};

const wrapSendCommand = (mockDriver, url, status = 200, fromServiceWorker = false) => {
mockDriver = Object.assign({}, mockDriver);
mockDriver.evaluateAsync = () => Promise.resolve();
mockDriver.on = (name, cb) => {
cb({response: {status, url, fromServiceWorker}});
};

return mockDriver;
};

describe('Start-url gatherer', () => {
let baseArtifacts;

describe('StartUrl Gatherer', () => {
let mockDriver;
let gatherer;

function createArtifactsWithURL(url) {
return {WebAppManifest: {value: {start_url: {value: url}}}};
}

function unimplemented() {
throw new Error('Unimplemented');
}

beforeEach(() => {
jest.useFakeTimers();
baseArtifacts = {WebAppManifest: null};
gatherer = new StartUrlGatherer();
mockDriver = {
goOffline: unimplemented,
goOnline: unimplemented,
evaluateAsync: unimplemented,
on: unimplemented,
off: unimplemented,
};
Copy link
Member

Choose a reason for hiding this comment

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

moving off bespoke mocks is good, but I have to say this is a nice model for this file :)

});

afterEach(() => {
jest.advanceTimersByTime(5000);
});

it('returns an artifact set to -1 when offline loading fails', () => {
const startUrlGatherer = new StartUrlGatherer();
const startUrlGathererWithQueryString = new StartUrlGatherer();
const startUrlGathererWithResponseNotFromSW = new StartUrlGatherer();
const throwOnEvaluate = (mockDriver) => {
mockDriver.on = () => {};
mockDriver.evaluateAsync = () => {
throw new Error({
TypeError: 'Failed to fetch',
__failedInBrowser: true,
name: 'TypeError',
message: 'Failed to fetch',
});
};

return mockDriver;
};
it('returns a explanation when manifest cannot be found', async () => {
mockDriver.goOffline = jest.fn();
mockDriver.goOnline = jest.fn();

const passContext = {
url: 'https://do-not-match.com/',
baseArtifacts,
driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://do-not-match.com/', -1)),
};
const passContextWithFragment = {
baseArtifacts,
url: 'https://ifixit-pwa.appspot.com/?history',
driver: throwOnEvaluate(wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/?history', -1)),
};
const passContextWithResponseNotFromSW = {
url: 'https://do-not-match.com/',
baseArtifacts: createArtifactsWithURL('https://do-not-match.com/'),
driver: wrapSendCommand(mockDriver, 'https://do-not-match.com/', 200),
baseArtifacts: {WebAppManifest: null},
driver: mockDriver,
};

return Promise.all([
startUrlGatherer.afterPass(passContext),
startUrlGathererWithQueryString.afterPass(passContextWithFragment),
startUrlGathererWithResponseNotFromSW.afterPass(passContextWithResponseNotFromSW),
]).then(([artifact, artifactWithQueryString, artifactWithResponseNotFromSW]) => {
assert.equal(artifact.statusCode, -1);
assert.ok(artifact.explanation, 'did not set debug string');
assert.equal(artifactWithQueryString.statusCode, -1);
assert.ok(artifactWithQueryString.explanation, 'did not set debug string');
assert.equal(artifactWithResponseNotFromSW.statusCode, -1);
assert.equal(artifactWithResponseNotFromSW.explanation,
'Unable to fetch start URL via service worker.');
const result = await gatherer.afterPass(passContext);
expect(result).toEqual({
statusCode: -1,
explanation: 'No usable web app manifest found on page.',
});
});

it('returns an artifact set to 200 when offline loading from service worker succeeds', () => {
const startUrlGatherer = new StartUrlGatherer();
const startUrlGathererWithFragment = new StartUrlGatherer();
it('returns a explanation when manifest cannot be parsed', async () => {
mockDriver.goOffline = jest.fn();
mockDriver.goOnline = jest.fn();

const passContext = {
url: 'https://ifixit-pwa.appspot.com/',
baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/', 200, true),
baseArtifacts: {
WebAppManifest: parseManifest(
'this is invalid',
'https://example.com/manifest.json',
'https://example.com/'
),
},
driver: mockDriver,
};
const passContextWithFragment = {
url: 'https://ifixit-pwa.appspot.com/#/history',
baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/#/history'),
driver: wrapSendCommand(mockDriver, 'https://ifixit-pwa.appspot.com/#/history', 200, true),

const result = await gatherer.afterPass(passContext);
expect(mockDriver.goOffline).toHaveBeenCalled();
expect(mockDriver.goOnline).toHaveBeenCalled();
expect(result).toEqual({
statusCode: -1,
explanation:
`Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
`SyntaxError: Unexpected token h in JSON at position 1.`,
});
});

it('sets the status code to -1 when navigation fails', async () => {
mockDriver.goOffline = jest.fn();
mockDriver.goOnline = jest.fn();
mockDriver.evaluateAsync = jest.fn().mockRejectedValue(new Error('Fetch failed'));
mockDriver.on = jest.fn();
mockDriver.off = jest.fn();

const passContext = {
baseArtifacts: createArtifactsWithURL('https://example.com/'),
driver: mockDriver,
};

return Promise.all([
startUrlGatherer.afterPass(passContext),
startUrlGathererWithFragment.afterPass(passContextWithFragment),
]).then(([artifact, artifactWithFragment]) => {
assert.equal(artifact.statusCode, 200);
assert.equal(artifactWithFragment.statusCode, 200);
const result = await gatherer.afterPass(passContext);
expect(mockDriver.goOffline).toHaveBeenCalled();
expect(mockDriver.goOnline).toHaveBeenCalled();
Copy link
Member

Choose a reason for hiding this comment

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

what do you think about an extra level of checking for these that it was offline when evaluateAsync was called, not just that off and on were both called?

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 like it as a separate test but not on every single one :) done!

expect(result).toEqual({
statusCode: -1,
explanation: 'Unable to fetch start_url via service worker.',
});
});

it('returns a explanation when manifest cannot be found', () => {
const driver = Object.assign({}, mockDriver);
const startUrlGatherer = new StartUrlGatherer();
it('sets the status code to page status code', async () => {
mockDriver.goOffline = jest.fn();
mockDriver.goOnline = jest.fn();
mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
mockDriver.on = jest.fn();
mockDriver.off = jest.fn();

const passContext = {
baseArtifacts,
url: 'https://ifixit-pwa.appspot.com/',
driver,
baseArtifacts: createArtifactsWithURL('https://example.com/'),
driver: mockDriver,
};

return startUrlGatherer.afterPass(passContext)
.then(artifact => {
assert.equal(artifact.explanation,
`No usable web app manifest found on page.`);
});
const response = {
url: 'https://example.com/',
status: 200,
fromServiceWorker: true,
};

mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));

const result = await gatherer.afterPass(passContext);
expect(mockDriver.goOffline).toHaveBeenCalled();
expect(mockDriver.goOnline).toHaveBeenCalled();
expect(result).toEqual({
statusCode: 200,
});
});

it('returns a explanation when manifest cannot be parsed', () => {
const driver = Object.assign({}, mockDriver);
const startUrlGatherer = new StartUrlGatherer();
it('sets the status code to -1 when not from service worker', async () => {
mockDriver.goOffline = jest.fn();
mockDriver.goOnline = jest.fn();
mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
mockDriver.on = jest.fn();
mockDriver.off = jest.fn();

const passContext = {
baseArtifacts,
url: 'https://ifixit-pwa.appspot.com/',
driver,
baseArtifacts: createArtifactsWithURL('https://example.com/'),
driver: mockDriver,
};

const response = {
url: 'https://example.com/',
status: 200,
fromServiceWorker: false,
};

baseArtifacts.WebAppManifest = parseManifest(
'this is invalid',
'https://ifixit-pwa.appspot.com/manifest.json',
passContext.url
);

return startUrlGatherer.afterPass(passContext)
.then(artifact => {
assert.equal(artifact.explanation,
`Error fetching web app manifest: ERROR: file isn't valid JSON: ` +
`SyntaxError: Unexpected token h in JSON at position 1.`);
});
mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));

const result = await gatherer.afterPass(passContext);
expect(mockDriver.goOffline).toHaveBeenCalled();
expect(mockDriver.goOnline).toHaveBeenCalled();
expect(result).toEqual({
statusCode: -1,
explanation: 'The start_url did respond, but not via a service worker.',
});
});

it('times out when a start_url is too slow to respond', async () => {
const startUrlGatherer = new StartUrlGatherer();
it('sets the status code to -1 when times out waiting for a matching url', async () => {
mockDriver.goOffline = jest.fn();
mockDriver.goOnline = jest.fn();
mockDriver.evaluateAsync = jest.fn().mockResolvedValue();
mockDriver.on = jest.fn();
mockDriver.off = jest.fn();

const passContext = {
url: 'https://ifixit-pwa.appspot.com/',
baseArtifacts: createArtifactsWithURL('https://ifixit-pwa.appspot.com/'),
driver: wrapSendCommand(mockDriver, ''),
baseArtifacts: createArtifactsWithURL('https://example.com/'),
driver: mockDriver,
};

const response = {
url: 'https://random-other-url.com/',
status: 200,
fromServiceWorker: true,
};

const resultPromise = startUrlGatherer.afterPass(passContext);
mockDriver.on.mockImplementation((_, onResponseReceived) => onResponseReceived({response}));

const resultPromise = gatherer.afterPass(passContext);
// Wait a tick for the evaluateAsync promise resolution to hold for the timer.
await Promise.resolve();
// Skip past our timeout of 3000.
jest.advanceTimersByTime(5000);
const artifact = await resultPromise;
assert.equal(artifact.explanation, 'Timed out waiting for fetched start_url.');
const result = await resultPromise;

expect(mockDriver.goOffline).toHaveBeenCalled();
expect(mockDriver.goOnline).toHaveBeenCalled();
expect(result).toEqual({
statusCode: -1,
explanation: 'Timed out waiting for start_url to respond.',
});
});
});