angular-cn/packages/service-worker/worker/test/happy_spec.ts

1108 lines
41 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {processNavigationUrls} from '../../config/src/generator';
import {CacheDatabase} from '../src/db-cache';
fix(service-worker): properly handle invalid hashes in all scenarios (#21288) When the SW fetches URLs listed in a manifest with hashes, it checks the content hash against the manifest to make sure it has the correct version of the URL. In the event of a mismatch, the SW is supposed to consider the manifest invalid, and avoid using it. There are 3 cases to consider by which this can happen. Case 1: during the initial SW installation, a manifest is activated without waiting for every URL to be fully loaded. In the background, every prefetch URL listed by the manifest is requested and cached. One such prefetch request could fail the hash test, and cause the manifest to be treated as invalid. In such a case, the SW should enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is invalid. This case works today. Case 2: during the initial SW installation, as in Case 1, a manifest is activated without waiting for each URL to fully load. However, it's possible that the application could request a URL with a bad hash before background initialization tries to load that URL. This happens if, for example, the application has a broken index.html. In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY, and serve the request from the network instead. What happens today is that the internal error escapes the SW and is returned as a rejected Promise to respondWith(), causing a browser-level error that the site cannot be loaded, breaking the site. This change allows the SW to detect the error and enter the correct state, falling back on the network if needed. Case 3: during checkForUpdate(), the SW will try to fully cache the new update before making it the latest version. Failure here is complicated - if the page fails to load due to transient network conditions (timeouts, 500s, etc), then it makes sense to continue serving the existing cached version, and attempt to activate the update on the next cycle. If the page fails due to non-transient conditions though (400 error, hash mismatch, etc), then the SW should consider the updated manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY. Currently, all errors are treated as transient. This change causes the SW to treat all errors during updates as non-transient, which can cause the SW to unnecessarily enter a safe mode. A future change can allow the SW to remain in normal mode if the error is provably transient. PR Close #21288
2018-01-02 15:42:39 -05:00
import {Driver, DriverReadyState} from '../src/driver';
import {AssetGroupConfig, DataGroupConfig, Manifest} from '../src/manifest';
import {sha1} from '../src/sha1';
import {MockCache, clearAllCaches} from '../testing/cache';
import {MockRequest} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
import {async_beforeEach, async_fit, async_it} from './async';
(function() {
// Skip environments that don't support the minimum APIs needed to run the SW tests.
if (!SwTestHarness.envIsSupported()) {
return;
}
const dist =
new MockFileSystemBuilder()
.addFile('/foo.txt', 'this is foo')
.addFile('/bar.txt', 'this is bar')
.addFile('/baz.txt', 'this is baz')
.addFile('/qux.txt', 'this is qux')
.addFile('/quux.txt', 'this is quux')
.addFile('/quuux.txt', 'this is quuux')
.addFile('/lazy/unchanged1.txt', 'this is unchanged (1)')
.addFile('/lazy/unchanged2.txt', 'this is unchanged (2)')
.addUnhashedFile('/unhashed/a.txt', 'this is unhashed', {'Cache-Control': 'max-age=10'})
.addUnhashedFile('/unhashed/b.txt', 'this is unhashed b', {'Cache-Control': 'no-cache'})
.build();
const distUpdate =
new MockFileSystemBuilder()
.addFile('/foo.txt', 'this is foo v2')
.addFile('/bar.txt', 'this is bar')
.addFile('/baz.txt', 'this is baz v2')
.addFile('/qux.txt', 'this is qux v2')
.addFile('/quux.txt', 'this is quux v2')
.addFile('/quuux.txt', 'this is quuux v2')
.addFile('/lazy/unchanged1.txt', 'this is unchanged (1)')
.addFile('/lazy/unchanged2.txt', 'this is unchanged (2)')
.addUnhashedFile(
'/unhashed/a.txt', 'this is unhashed v2', {'Cache-Control': 'max-age=10'})
.addUnhashedFile('/ignored/file1', 'this is not handled by the SW')
.addUnhashedFile('/ignored/dir/file2', 'this is not handled by the SW either')
.build();
const brokenFs = new MockFileSystemBuilder().addFile('/foo.txt', 'this is foo').build();
const brokenManifest: Manifest = {
configVersion: 1,
timestamp: 1234567890123,
index: '/foo.txt',
assetGroups: [{
name: 'assets',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: [
'/foo.txt',
],
patterns: [],
}],
dataGroups: [],
navigationUrls: processNavigationUrls(''),
hashTable: tmpHashTableForFs(brokenFs, {'/foo.txt': true}),
};
// Manifest without navigation urls to test backward compatibility with
// versions < 6.0.0.
interface ManifestV5 {
configVersion: number;
appData?: {[key: string]: string};
index: string;
assetGroups?: AssetGroupConfig[];
dataGroups?: DataGroupConfig[];
hashTable: {[url: string]: string};
}
// To simulate versions < 6.0.0
const manifestOld: ManifestV5 = {
configVersion: 1,
index: '/foo.txt',
hashTable: tmpHashTableForFs(dist),
};
const manifest: Manifest = {
configVersion: 1,
timestamp: 1234567890123,
appData: {
version: 'original',
},
index: '/foo.txt',
assetGroups: [
{
name: 'assets',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: [
'/foo.txt',
'/bar.txt',
'/redirected.txt',
],
patterns: [
'/unhashed/.*',
],
},
{
name: 'other',
installMode: 'lazy',
updateMode: 'lazy',
urls: [
'/baz.txt',
'/qux.txt',
],
patterns: [],
},
{
name: 'lazy_prefetch',
installMode: 'lazy',
updateMode: 'prefetch',
urls: [
'/quux.txt',
'/quuux.txt',
'/lazy/unchanged1.txt',
'/lazy/unchanged2.txt',
],
patterns: [],
}
],
navigationUrls: processNavigationUrls(''),
hashTable: tmpHashTableForFs(dist),
};
const manifestUpdate: Manifest = {
configVersion: 1,
timestamp: 1234567890123,
appData: {
version: 'update',
},
index: '/foo.txt',
assetGroups: [
{
name: 'assets',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: [
'/foo.txt',
'/bar.txt',
'/redirected.txt',
],
patterns: [
'/unhashed/.*',
],
},
{
name: 'other',
installMode: 'lazy',
updateMode: 'lazy',
urls: [
'/baz.txt',
'/qux.txt',
],
patterns: [],
},
{
name: 'lazy_prefetch',
installMode: 'lazy',
updateMode: 'prefetch',
urls: [
'/quux.txt',
'/quuux.txt',
'/lazy/unchanged1.txt',
'/lazy/unchanged2.txt',
],
patterns: [],
}
],
navigationUrls: processNavigationUrls(
'',
[
'/**/file1',
'/**/file2',
'!/ignored/file1',
'!/ignored/dir/**',
]),
hashTable: tmpHashTableForFs(distUpdate),
};
const serverBuilderBase =
new MockServerStateBuilder()
.withStaticFiles(dist)
.withRedirect('/redirected.txt', '/redirect-target.txt', 'this was a redirect')
.withError('/error.txt');
const server = serverBuilderBase.withManifest(manifest).build();
const serverRollback =
serverBuilderBase.withManifest({...manifest, timestamp: manifest.timestamp + 1}).build();
const serverUpdate =
new MockServerStateBuilder()
.withStaticFiles(distUpdate)
.withManifest(manifestUpdate)
.withRedirect('/redirected.txt', '/redirect-target.txt', 'this was a redirect')
.build();
const brokenServer =
new MockServerStateBuilder().withStaticFiles(brokenFs).withManifest(brokenManifest).build();
const server404 = new MockServerStateBuilder().withStaticFiles(dist).build();
const manifestHash = sha1(JSON.stringify(manifest));
const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate));
describe('Driver', () => {
let scope: SwTestHarness;
let driver: Driver;
beforeEach(() => {
server.reset();
serverUpdate.reset();
server404.reset();
brokenServer.reset();
scope = new SwTestHarnessBuilder().withServerState(server).build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
});
async_it('activates without waiting', async() => {
const skippedWaiting = await scope.startup(true);
expect(skippedWaiting).toBe(true);
});
async_it('claims all clients, after activation', async() => {
const claimSpy = spyOn(scope.clients, 'claim');
await scope.startup(true);
expect(claimSpy).toHaveBeenCalledTimes(1);
});
async_it('cleans up old `@angular/service-worker` caches, after activation', async() => {
const claimSpy = spyOn(scope.clients, 'claim');
const cleanupOldSwCachesSpy = spyOn(driver, 'cleanupOldSwCaches');
// Automatically advance time to trigger idle tasks as they are added.
scope.autoAdvanceTime = true;
await scope.startup(true);
await scope.resolveSelfMessages();
scope.autoAdvanceTime = false;
expect(cleanupOldSwCachesSpy).toHaveBeenCalledTimes(1);
expect(claimSpy).toHaveBeenCalledBefore(cleanupOldSwCachesSpy);
});
async_it(
'does not blow up if cleaning up old `@angular/service-worker` caches fails', async() => {
spyOn(driver, 'cleanupOldSwCaches').and.callFake(() => Promise.reject('Ooops'));
// Automatically advance time to trigger idle tasks as they are added.
scope.autoAdvanceTime = true;
await scope.startup(true);
await scope.resolveSelfMessages();
scope.autoAdvanceTime = false;
server.clearRequests();
expect(driver.state).toBe(DriverReadyState.NORMAL);
expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo');
server.assertNoOtherRequests();
});
async_it('initializes prefetched content correctly, after activation', async() => {
// Automatically advance time to trigger idle tasks as they are added.
scope.autoAdvanceTime = true;
await scope.startup(true);
await scope.resolveSelfMessages();
scope.autoAdvanceTime = false;
server.assertSawRequestFor('ngsw.json');
server.assertSawRequestFor('/foo.txt');
server.assertSawRequestFor('/bar.txt');
server.assertSawRequestFor('/redirected.txt');
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
server.assertNoOtherRequests();
});
async_it('initializes prefetched content correctly, after a request kicks it off', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.assertSawRequestFor('ngsw.json');
server.assertSawRequestFor('/foo.txt');
server.assertSawRequestFor('/bar.txt');
server.assertSawRequestFor('/redirected.txt');
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
server.assertNoOtherRequests();
});
async_it('handles non-relative URLs', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
expect(await makeRequest(scope, 'http://localhost/foo.txt')).toEqual('this is foo');
server.assertNoOtherRequests();
});
async_it('handles actual errors from the browser', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
const [resPromise, done] = scope.handleFetch(new MockRequest('/error.txt'), 'default');
await done;
const res = (await resPromise) !;
expect(res.status).toEqual(504);
expect(res.statusText).toEqual('Gateway Timeout');
});
async_it('handles redirected responses', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
expect(await makeRequest(scope, '/redirected.txt')).toEqual('this was a redirect');
server.assertNoOtherRequests();
});
async_it('caches lazy content on-request', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
expect(await makeRequest(scope, '/baz.txt')).toEqual('this is baz');
server.assertSawRequestFor('/baz.txt');
server.assertNoOtherRequests();
expect(await makeRequest(scope, '/baz.txt')).toEqual('this is baz');
server.assertNoOtherRequests();
expect(await makeRequest(scope, '/qux.txt')).toEqual('this is qux');
server.assertSawRequestFor('/qux.txt');
server.assertNoOtherRequests();
});
async_it('updates to new content when requested', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
const client = scope.clients.getMock('default') !;
expect(client.messages).toEqual([]);
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
serverUpdate.assertSawRequestFor('ngsw.json');
serverUpdate.assertSawRequestFor('/foo.txt');
serverUpdate.assertSawRequestFor('/redirected.txt');
serverUpdate.assertNoOtherRequests();
expect(client.messages).toEqual([{
type: 'UPDATE_AVAILABLE',
current: {hash: manifestHash, appData: {version: 'original'}},
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
}]);
// Default client is still on the old version of the app.
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
// Sending a new client id should result in the updated version being returned.
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
// Of course, the old version should still work.
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
serverUpdate.assertNoOtherRequests();
});
async_it('detects new version even if only `manifest.timestamp` is different', async() => {
expect(await makeRequest(scope, '/foo.txt', 'newClient')).toEqual('this is foo');
await driver.initialized;
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
expect(await makeRequest(scope, '/foo.txt', 'newerClient')).toEqual('this is foo v2');
scope.updateServerState(serverRollback);
expect(await driver.checkForUpdate()).toEqual(true);
expect(await makeRequest(scope, '/foo.txt', 'newestClient')).toEqual('this is foo');
});
async_it('updates a specific client to new content on request', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
const client = scope.clients.getMock('default') !;
expect(client.messages).toEqual([]);
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
serverUpdate.clearRequests();
await driver.updateClient(client as any as Client);
expect(client.messages).toEqual([
{
type: 'UPDATE_AVAILABLE',
current: {hash: manifestHash, appData: {version: 'original'}},
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
},
{
type: 'UPDATE_ACTIVATED',
previous: {hash: manifestHash, appData: {version: 'original'}},
current: {hash: manifestUpdateHash, appData: {version: 'update'}},
}
]);
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo v2');
});
async_it('handles empty client ID', async() => {
const navRequest = (url: string, clientId: string | null) =>
makeRequest(scope, url, clientId, {
headers: {Accept: 'text/plain, text/html, text/css'},
mode: 'navigate',
});
// Initialize the SW.
expect(await navRequest('/foo/file1', '')).toEqual('this is foo');
expect(await navRequest('/bar/file2', null)).toEqual('this is foo');
await driver.initialized;
// Update to a new version.
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
// Correctly handle navigation requests, even if `clientId` is null/empty.
expect(await navRequest('/foo/file1', '')).toEqual('this is foo v2');
expect(await navRequest('/bar/file2', null)).toEqual('this is foo v2');
});
async_it('checks for updates on restart', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope = new SwTestHarnessBuilder()
.withCacheState(scope.caches.dehydrate())
.withServerState(serverUpdate)
.build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
serverUpdate.assertNoOtherRequests();
scope.advance(12000);
await driver.idle.empty;
serverUpdate.assertSawRequestFor('ngsw.json');
serverUpdate.assertSawRequestFor('/foo.txt');
serverUpdate.assertSawRequestFor('/redirected.txt');
serverUpdate.assertNoOtherRequests();
});
async_it('checks for updates on navigation', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
expect(await makeRequest(scope, '/foo.txt', 'default', {
mode: 'navigate',
})).toEqual('this is foo');
scope.advance(12000);
await driver.idle.empty;
server.assertSawRequestFor('ngsw.json');
});
async_it('does not make concurrent checks for updates on navigation', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
expect(await makeRequest(scope, '/foo.txt', 'default', {
mode: 'navigate',
})).toEqual('this is foo');
expect(await makeRequest(scope, '/foo.txt', 'default', {
mode: 'navigate',
})).toEqual('this is foo');
scope.advance(12000);
await driver.idle.empty;
server.assertSawRequestFor('ngsw.json');
server.assertNoOtherRequests();
});
async_it('preserves multiple client assignments across restarts', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
serverUpdate.clearRequests();
scope = new SwTestHarnessBuilder()
.withCacheState(scope.caches.dehydrate())
.withServerState(serverUpdate)
.build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
serverUpdate.assertNoOtherRequests();
});
async_it('updates when refreshed', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
const client = scope.clients.getMock('default') !;
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
serverUpdate.clearRequests();
expect(await makeRequest(scope, '/file1', 'default', {
headers: {
'Accept': 'text/plain, text/html, text/css',
},
mode: 'navigate',
})).toEqual('this is foo v2');
expect(client.messages).toEqual([
{
type: 'UPDATE_AVAILABLE',
current: {hash: manifestHash, appData: {version: 'original'}},
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
},
{
type: 'UPDATE_ACTIVATED',
previous: {hash: manifestHash, appData: {version: 'original'}},
current: {hash: manifestUpdateHash, appData: {version: 'update'}},
}
]);
serverUpdate.assertNoOtherRequests();
});
async_it('cleans up properly when manually requested', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
serverUpdate.clearRequests();
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
// Delete the default client.
scope.clients.remove('default');
// After this, the old version should no longer be cached.
await driver.cleanupCaches();
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo v2');
serverUpdate.assertNoOtherRequests();
});
async_it('cleans up properly on restart', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope = new SwTestHarnessBuilder()
.withCacheState(scope.caches.dehydrate())
.withServerState(serverUpdate)
.build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
serverUpdate.assertNoOtherRequests();
let keys = await scope.caches.keys();
let hasOriginalCaches = keys.some(name => name.startsWith(`ngsw:/:${manifestHash}:`));
expect(hasOriginalCaches).toEqual(true);
scope.clients.remove('default');
scope.advance(12000);
await driver.idle.empty;
serverUpdate.clearRequests();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo v2');
keys = await scope.caches.keys();
hasOriginalCaches = keys.some(name => name.startsWith(`ngsw:/:${manifestHash}:`));
expect(hasOriginalCaches).toEqual(false);
});
async_it('shows notifications for push notifications', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
await scope.handlePush({
notification: {
title: 'This is a test',
body: 'Test body',
}
});
expect(scope.notifications).toEqual([{
title: 'This is a test',
options: {title: 'This is a test', body: 'Test body'},
}]);
expect(scope.clients.getMock('default') !.messages).toEqual([{
type: 'PUSH',
data: {
notification: {
title: 'This is a test',
body: 'Test body',
},
},
}]);
});
async_it('broadcasts notification click events with action', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
await scope.handleClick(
{title: 'This is a test with action', body: 'Test body with action'}, 'button');
const message: any = scope.clients.getMock('default') !.messages[0];
expect(message.type).toEqual('NOTIFICATION_CLICK');
expect(message.data.action).toEqual('button');
expect(message.data.notification.title).toEqual('This is a test with action');
expect(message.data.notification.body).toEqual('Test body with action');
});
async_it('broadcasts notification click events without action', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
await scope.handleClick(
{title: 'This is a test without action', body: 'Test body without action'});
const message: any = scope.clients.getMock('default') !.messages[0];
expect(message.type).toEqual('NOTIFICATION_CLICK');
expect(message.data.action).toBeUndefined();
expect(message.data.notification.title).toEqual('This is a test without action');
expect(message.data.notification.body).toEqual('Test body without action');
});
async_it('prefetches updates to lazy cache when set', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
// Fetch some files from the `lazy_prefetch` asset group.
expect(await makeRequest(scope, '/quux.txt')).toEqual('this is quux');
expect(await makeRequest(scope, '/lazy/unchanged1.txt')).toEqual('this is unchanged (1)');
// Install update.
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toBe(true);
// Previously requested and changed: Fetch from network.
serverUpdate.assertSawRequestFor('/quux.txt');
// Never requested and changed: Don't fetch.
serverUpdate.assertNoRequestFor('/quuux.txt');
// Previously requested and unchanged: Fetch from cache.
serverUpdate.assertNoRequestFor('/lazy/unchanged1.txt');
// Never requested and unchanged: Don't fetch.
serverUpdate.assertNoRequestFor('/lazy/unchanged2.txt');
serverUpdate.clearRequests();
// Update client.
await driver.updateClient(await scope.clients.get('default'));
// Already cached.
expect(await makeRequest(scope, '/quux.txt')).toBe('this is quux v2');
serverUpdate.assertNoOtherRequests();
// Not cached: Fetch from network.
expect(await makeRequest(scope, '/quuux.txt')).toBe('this is quuux v2');
serverUpdate.assertSawRequestFor('/quuux.txt');
// Already cached (copied from old cache).
expect(await makeRequest(scope, '/lazy/unchanged1.txt')).toBe('this is unchanged (1)');
serverUpdate.assertNoOtherRequests();
// Not cached: Fetch from network.
expect(await makeRequest(scope, '/lazy/unchanged2.txt')).toBe('this is unchanged (2)');
serverUpdate.assertSawRequestFor('/lazy/unchanged2.txt');
serverUpdate.assertNoOtherRequests();
});
async_it('unregisters when manifest 404s', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope.updateServerState(server404);
expect(await driver.checkForUpdate()).toEqual(false);
expect(scope.unregistered).toEqual(true);
expect(await scope.caches.keys()).toEqual([]);
});
async_it('does not unregister or change state when offline (i.e. manifest 504s)', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.online = false;
expect(await driver.checkForUpdate()).toEqual(false);
expect(driver.state).toEqual(DriverReadyState.NORMAL);
expect(scope.unregistered).toBeFalsy();
expect(await scope.caches.keys()).not.toEqual([]);
});
describe('unhashed requests', () => {
async_beforeEach(async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
});
async_it('are cached appropriately', async() => {
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.assertSawRequestFor('/unhashed/a.txt');
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.assertNoOtherRequests();
});
async_it(`doesn't error when 'Cache-Control' is 'no-cache'`, async() => {
expect(await makeRequest(scope, '/unhashed/b.txt')).toEqual('this is unhashed b');
server.assertSawRequestFor('/unhashed/b.txt');
expect(await makeRequest(scope, '/unhashed/b.txt')).toEqual('this is unhashed b');
server.assertNoOtherRequests();
});
async_it('avoid opaque responses', async() => {
expect(await makeRequest(scope, '/unhashed/a.txt', 'default', {
credentials: 'include'
})).toEqual('this is unhashed');
server.assertSawRequestFor('/unhashed/a.txt');
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.assertNoOtherRequests();
});
async_it('expire according to Cache-Control headers', async() => {
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.clearRequests();
// Update the resource on the server.
scope.updateServerState(serverUpdate);
// Move ahead by 15 seconds.
scope.advance(15000);
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
serverUpdate.assertNoOtherRequests();
// Another 6 seconds.
scope.advance(6000);
await driver.idle.empty;
serverUpdate.assertSawRequestFor('/unhashed/a.txt');
// Now the new version of the resource should be served.
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed v2');
server.assertNoOtherRequests();
});
async_it('survive serialization', async() => {
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.clearRequests();
const state = scope.caches.dehydrate();
scope = new SwTestHarnessBuilder().withCacheState(state).withServerState(server).build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.assertNoRequestFor('/unhashed/a.txt');
server.clearRequests();
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.assertNoOtherRequests();
// Advance the clock by 6 seconds, triggering the idle tasks. If an idle task
// was scheduled from the request above, it means that the metadata was not
// properly saved.
scope.advance(6000);
await driver.idle.empty;
server.assertNoRequestFor('/unhashed/a.txt');
});
async_it('get carried over during updates', async() => {
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
server.clearRequests();
scope = new SwTestHarnessBuilder()
.withCacheState(scope.caches.dehydrate())
.withServerState(serverUpdate)
.build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope.advance(15000);
await driver.idle.empty;
serverUpdate.assertNoRequestFor('/unhashed/a.txt');
serverUpdate.clearRequests();
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
serverUpdate.assertNoOtherRequests();
scope.advance(15000);
await driver.idle.empty;
serverUpdate.assertSawRequestFor('/unhashed/a.txt');
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed v2');
serverUpdate.assertNoOtherRequests();
});
});
describe('routing', () => {
const navRequest = (url: string, init = {}) => makeRequest(scope, url, undefined, {
headers: {Accept: 'text/plain, text/html, text/css'},
mode: 'navigate', ...init,
});
async_beforeEach(async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
});
async_it('redirects to index on a route-like request', async() => {
expect(await navRequest('/baz')).toEqual('this is foo');
server.assertNoOtherRequests();
});
async_it('redirects to index on a request to the origin URL request', async() => {
expect(await navRequest('http://localhost/')).toEqual('this is foo');
server.assertNoOtherRequests();
});
async_it('does not redirect to index on a non-navigation request', async() => {
expect(await navRequest('/baz', {mode: undefined})).toBeNull();
server.assertSawRequestFor('/baz');
});
async_it('does not redirect to index on a request that does not accept HTML', async() => {
expect(await navRequest('/baz', {headers: {}})).toBeNull();
server.assertSawRequestFor('/baz');
expect(await navRequest('/qux', {headers: {'Accept': 'text/plain'}})).toBeNull();
server.assertSawRequestFor('/qux');
});
async_it('does not redirect to index on a request with an extension', async() => {
expect(await navRequest('/baz.html')).toBeNull();
server.assertSawRequestFor('/baz.html');
// Only considers the last path segment when checking for a file extension.
expect(await navRequest('/baz.html/qux')).toBe('this is foo');
server.assertNoOtherRequests();
});
async_it('does not redirect to index if the URL contains `__`', async() => {
expect(await navRequest('/baz/x__x')).toBeNull();
server.assertSawRequestFor('/baz/x__x');
expect(await navRequest('/baz/x__x/qux')).toBeNull();
server.assertSawRequestFor('/baz/x__x/qux');
expect(await navRequest('/baz/__')).toBeNull();
server.assertSawRequestFor('/baz/__');
expect(await navRequest('/baz/__/qux')).toBeNull();
server.assertSawRequestFor('/baz/__/qux');
});
describe('(with custom `navigationUrls`)', () => {
async_beforeEach(async() => {
scope.updateServerState(serverUpdate);
await driver.checkForUpdate();
serverUpdate.clearRequests();
});
async_it('redirects to index on a request that matches any positive pattern', async() => {
expect(await navRequest('/foo/file0')).toBeNull();
serverUpdate.assertSawRequestFor('/foo/file0');
expect(await navRequest('/foo/file1')).toBe('this is foo v2');
serverUpdate.assertNoOtherRequests();
expect(await navRequest('/bar/file2')).toBe('this is foo v2');
serverUpdate.assertNoOtherRequests();
});
async_it(
'does not redirect to index on a request that matches any negative pattern',
async() => {
expect(await navRequest('/ignored/file1')).toBe('this is not handled by the SW');
serverUpdate.assertSawRequestFor('/ignored/file1');
expect(await navRequest('/ignored/dir/file2'))
.toBe('this is not handled by the SW either');
serverUpdate.assertSawRequestFor('/ignored/dir/file2');
expect(await navRequest('/ignored/directory/file2')).toBe('this is foo v2');
serverUpdate.assertNoOtherRequests();
});
async_it('strips URL query before checking `navigationUrls`', async() => {
expect(await navRequest('/foo/file1?query=/a/b')).toBe('this is foo v2');
serverUpdate.assertNoOtherRequests();
expect(await navRequest('/ignored/file1?query=/a/b'))
.toBe('this is not handled by the SW');
serverUpdate.assertSawRequestFor('/ignored/file1');
expect(await navRequest('/ignored/dir/file2?query=/a/b'))
.toBe('this is not handled by the SW either');
serverUpdate.assertSawRequestFor('/ignored/dir/file2');
});
async_it('strips registration scope before checking `navigationUrls`', async() => {
expect(await navRequest('http://localhost/ignored/file1'))
.toBe('this is not handled by the SW');
serverUpdate.assertSawRequestFor('/ignored/file1');
});
});
});
fix(service-worker): properly handle invalid hashes in all scenarios (#21288) When the SW fetches URLs listed in a manifest with hashes, it checks the content hash against the manifest to make sure it has the correct version of the URL. In the event of a mismatch, the SW is supposed to consider the manifest invalid, and avoid using it. There are 3 cases to consider by which this can happen. Case 1: during the initial SW installation, a manifest is activated without waiting for every URL to be fully loaded. In the background, every prefetch URL listed by the manifest is requested and cached. One such prefetch request could fail the hash test, and cause the manifest to be treated as invalid. In such a case, the SW should enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is invalid. This case works today. Case 2: during the initial SW installation, as in Case 1, a manifest is activated without waiting for each URL to fully load. However, it's possible that the application could request a URL with a bad hash before background initialization tries to load that URL. This happens if, for example, the application has a broken index.html. In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY, and serve the request from the network instead. What happens today is that the internal error escapes the SW and is returned as a rejected Promise to respondWith(), causing a browser-level error that the site cannot be loaded, breaking the site. This change allows the SW to detect the error and enter the correct state, falling back on the network if needed. Case 3: during checkForUpdate(), the SW will try to fully cache the new update before making it the latest version. Failure here is complicated - if the page fails to load due to transient network conditions (timeouts, 500s, etc), then it makes sense to continue serving the existing cached version, and attempt to activate the update on the next cycle. If the page fails due to non-transient conditions though (400 error, hash mismatch, etc), then the SW should consider the updated manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY. Currently, all errors are treated as transient. This change causes the SW to treat all errors during updates as non-transient, which can cause the SW to unnecessarily enter a safe mode. A future change can allow the SW to remain in normal mode if the error is provably transient. PR Close #21288
2018-01-02 15:42:39 -05:00
describe('cleanupOldSwCaches()', () => {
async_it('should delete the correct caches', async() => {
const oldSwCacheNames = [
// Example cache names from the beta versions of `@angular/service-worker`.
'ngsw:active',
'ngsw:staged',
'ngsw:manifest:a1b2c3:super:duper',
// Example cache names from the beta versions of `@angular/service-worker`.
'ngsw:a1b2c3:assets:foo',
'ngsw:db:a1b2c3:assets:bar',
];
const otherCacheNames = [
'ngsuu:active',
'not:ngsw:active',
'NgSw:StAgEd',
'ngsw:/:active',
'ngsw:/foo/:staged',
];
const allCacheNames = oldSwCacheNames.concat(otherCacheNames);
await Promise.all(allCacheNames.map(name => scope.caches.open(name)));
expect(await scope.caches.keys()).toEqual(allCacheNames);
await driver.cleanupOldSwCaches();
expect(await scope.caches.keys()).toEqual(otherCacheNames);
});
async_it('should delete other caches even if deleting one of them fails', async() => {
const oldSwCacheNames = ['ngsw:active', 'ngsw:staged', 'ngsw:manifest:a1b2c3:super:duper'];
const deleteSpy = spyOn(scope.caches, 'delete')
.and.callFake(
(cacheName: string) =>
Promise.reject(`Failed to delete cache '${cacheName}'.`));
await Promise.all(oldSwCacheNames.map(name => scope.caches.open(name)));
const error = await driver.cleanupOldSwCaches().catch(err => err);
expect(error).toBe('Failed to delete cache \'ngsw:active\'.');
expect(deleteSpy).toHaveBeenCalledTimes(3);
oldSwCacheNames.forEach(name => expect(deleteSpy).toHaveBeenCalledWith(name));
});
});
fix(service-worker): properly handle invalid hashes in all scenarios (#21288) When the SW fetches URLs listed in a manifest with hashes, it checks the content hash against the manifest to make sure it has the correct version of the URL. In the event of a mismatch, the SW is supposed to consider the manifest invalid, and avoid using it. There are 3 cases to consider by which this can happen. Case 1: during the initial SW installation, a manifest is activated without waiting for every URL to be fully loaded. In the background, every prefetch URL listed by the manifest is requested and cached. One such prefetch request could fail the hash test, and cause the manifest to be treated as invalid. In such a case, the SW should enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is invalid. This case works today. Case 2: during the initial SW installation, as in Case 1, a manifest is activated without waiting for each URL to fully load. However, it's possible that the application could request a URL with a bad hash before background initialization tries to load that URL. This happens if, for example, the application has a broken index.html. In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY, and serve the request from the network instead. What happens today is that the internal error escapes the SW and is returned as a rejected Promise to respondWith(), causing a browser-level error that the site cannot be loaded, breaking the site. This change allows the SW to detect the error and enter the correct state, falling back on the network if needed. Case 3: during checkForUpdate(), the SW will try to fully cache the new update before making it the latest version. Failure here is complicated - if the page fails to load due to transient network conditions (timeouts, 500s, etc), then it makes sense to continue serving the existing cached version, and attempt to activate the update on the next cycle. If the page fails due to non-transient conditions though (400 error, hash mismatch, etc), then the SW should consider the updated manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY. Currently, all errors are treated as transient. This change causes the SW to treat all errors during updates as non-transient, which can cause the SW to unnecessarily enter a safe mode. A future change can allow the SW to remain in normal mode if the error is provably transient. PR Close #21288
2018-01-02 15:42:39 -05:00
describe('bugs', () => {
async_it('does not crash with bad index hash', async() => {
scope = new SwTestHarnessBuilder().withServerState(brokenServer).build();
(scope.registration as any).scope = 'http://site.com';
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
});
async_it('enters degraded mode when update has a bad index', async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
scope = new SwTestHarnessBuilder()
.withCacheState(scope.caches.dehydrate())
.withServerState(brokenServer)
.build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
await driver.checkForUpdate();
scope.advance(12000);
await driver.idle.empty;
expect(driver.state).toEqual(DriverReadyState.EXISTING_CLIENTS_ONLY);
});
async_it('enters degraded mode when failing to write to cache', async() => {
// Initialize the SW.
await makeRequest(scope, '/foo.txt');
await driver.initialized;
expect(driver.state).toBe(DriverReadyState.NORMAL);
server.clearRequests();
// Operate normally.
expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo');
server.assertNoOtherRequests();
// Clear the caches and make them unwritable.
await clearAllCaches(scope.caches);
spyOn(MockCache.prototype, 'put').and.throwError('Can\'t touch this');
// Enter degraded mode and serve from network.
expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo');
expect(driver.state).toBe(DriverReadyState.EXISTING_CLIENTS_ONLY);
server.assertSawRequestFor('/foo.txt');
});
async_it('ignores invalid `only-if-cached` requests ', async() => {
const requestFoo = (cache: RequestCache | 'only-if-cached', mode: RequestMode) =>
makeRequest(scope, '/foo.txt', undefined, {cache, mode});
expect(await requestFoo('default', 'no-cors')).toBe('this is foo');
expect(await requestFoo('only-if-cached', 'same-origin')).toBe('this is foo');
expect(await requestFoo('only-if-cached', 'no-cors')).toBeNull();
});
async_it('ignores passive mixed content requests ', async() => {
const scopeFetchSpy = spyOn(scope, 'fetch').and.callThrough();
const getRequestUrls = () => scopeFetchSpy.calls.allArgs().map(args => args[0].url);
const httpScopeUrl = 'http://mock.origin.dev';
const httpsScopeUrl = 'https://mock.origin.dev';
const httpRequestUrl = 'http://other.origin.sh/unknown.png';
const httpsRequestUrl = 'https://other.origin.sh/unknown.pnp';
// Registration scope: `http:`
(scope.registration.scope as string) = httpScopeUrl;
await makeRequest(scope, httpRequestUrl);
await makeRequest(scope, httpsRequestUrl);
const requestUrls1 = getRequestUrls();
expect(requestUrls1).toContain(httpRequestUrl);
expect(requestUrls1).toContain(httpsRequestUrl);
scopeFetchSpy.calls.reset();
// Registration scope: `https:`
(scope.registration.scope as string) = httpsScopeUrl;
await makeRequest(scope, httpRequestUrl);
await makeRequest(scope, httpsRequestUrl);
const requestUrls2 = getRequestUrls();
expect(requestUrls2).not.toContain(httpRequestUrl);
expect(requestUrls2).toContain(httpsRequestUrl);
});
describe('Backwards compatibility with v5', () => {
beforeEach(() => {
const serverV5 = new MockServerStateBuilder()
.withStaticFiles(dist)
.withManifest(<Manifest>manifestOld)
.build();
scope = new SwTestHarnessBuilder().withServerState(serverV5).build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
});
// Test this bug: https://github.com/angular/angular/issues/27209
async_it(
'Fill previous versions of manifests with default navigation urls for backwards compatibility',
async() => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
});
});
fix(service-worker): properly handle invalid hashes in all scenarios (#21288) When the SW fetches URLs listed in a manifest with hashes, it checks the content hash against the manifest to make sure it has the correct version of the URL. In the event of a mismatch, the SW is supposed to consider the manifest invalid, and avoid using it. There are 3 cases to consider by which this can happen. Case 1: during the initial SW installation, a manifest is activated without waiting for every URL to be fully loaded. In the background, every prefetch URL listed by the manifest is requested and cached. One such prefetch request could fail the hash test, and cause the manifest to be treated as invalid. In such a case, the SW should enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is invalid. This case works today. Case 2: during the initial SW installation, as in Case 1, a manifest is activated without waiting for each URL to fully load. However, it's possible that the application could request a URL with a bad hash before background initialization tries to load that URL. This happens if, for example, the application has a broken index.html. In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY, and serve the request from the network instead. What happens today is that the internal error escapes the SW and is returned as a rejected Promise to respondWith(), causing a browser-level error that the site cannot be loaded, breaking the site. This change allows the SW to detect the error and enter the correct state, falling back on the network if needed. Case 3: during checkForUpdate(), the SW will try to fully cache the new update before making it the latest version. Failure here is complicated - if the page fails to load due to transient network conditions (timeouts, 500s, etc), then it makes sense to continue serving the existing cached version, and attempt to activate the update on the next cycle. If the page fails due to non-transient conditions though (400 error, hash mismatch, etc), then the SW should consider the updated manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY. Currently, all errors are treated as transient. This change causes the SW to treat all errors during updates as non-transient, which can cause the SW to unnecessarily enter a safe mode. A future change can allow the SW to remain in normal mode if the error is provably transient. PR Close #21288
2018-01-02 15:42:39 -05:00
});
});
})();
async function makeRequest(
scope: SwTestHarness, url: string, clientId: string | null = 'default',
init?: Object): Promise<string|null> {
const [resPromise, done] = scope.handleFetch(new MockRequest(url, init), clientId);
await done;
const res = await resPromise;
if (res !== undefined && res.ok) {
return res.text();
}
return null;
}