f10f8db5fb
This commit fixes several issues discovered through use in real apps. * The sha1() function operated on text content, causing issues for binary-format files. A sha1Binary() function which operates on unparsed data now avoids any encoding issues. * The characters '?' and '+' were not escaped in Glob-to-regex conversion previously, but are now. * URLs from the browser contain the full origin, but were checked against the table of hashes from the manifest which only has the path for URLs from the same origin. Now the origin is checked and URLs are relativized to the domain root before comparison if appropriate. * ngsw: prefix was missing from data groups, is now added. * Occasionally servers will return a redirected response for an asset, and caching it could cause errors for navigation requests. The SW now handles this by detecting such responses and following the redirect manually, to avoid caching a redirected response. * The request for known assets is now created from scratch from the URL before fetching from the network, in order to sanitize it and avoid carrying any special modes or headers that might result in opaque responses. * Debugging log for troubleshooting. * Avoid creating errors by returning 504 responses on error. * Fix bug where idle queue doesn't run in some circumstances. * Add tests for the above.
271 lines
9.5 KiB
TypeScript
271 lines
9.5 KiB
TypeScript
/**
|
|
* @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 {CacheDatabase} from '../src/db-cache';
|
|
import {Driver} from '../src/driver';
|
|
import {Manifest} from '../src/manifest';
|
|
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';
|
|
|
|
const dist = new MockFileSystemBuilder()
|
|
.addFile('/foo.txt', 'this is foo')
|
|
.addFile('/bar.txt', 'this is bar')
|
|
.addFile('/api/test', 'version 1')
|
|
.addFile('/api/a', 'version A')
|
|
.addFile('/api/b', 'version B')
|
|
.addFile('/api/c', 'version C')
|
|
.addFile('/api/d', 'version D')
|
|
.addFile('/api/e', 'version E')
|
|
.addFile('/fresh/data', 'this is fresh data')
|
|
.addFile('/refresh/data', 'this is some data')
|
|
.build();
|
|
|
|
|
|
const distUpdate = new MockFileSystemBuilder()
|
|
.addFile('/foo.txt', 'this is foo v2')
|
|
.addFile('/bar.txt', 'this is bar')
|
|
.addFile('/api/test', 'version 2')
|
|
.addFile('/fresh/data', 'this is fresher data')
|
|
.addFile('/refresh/data', 'this is refreshed data')
|
|
.build();
|
|
|
|
const manifest: Manifest = {
|
|
configVersion: 1,
|
|
index: '/index.html',
|
|
assetGroups: [
|
|
{
|
|
name: 'assets',
|
|
installMode: 'prefetch',
|
|
updateMode: 'prefetch',
|
|
urls: [
|
|
'/foo.txt',
|
|
'/bar.txt',
|
|
],
|
|
patterns: [],
|
|
},
|
|
],
|
|
dataGroups: [
|
|
{
|
|
name: 'testPerf',
|
|
maxSize: 3,
|
|
strategy: 'performance',
|
|
patterns: ['^/api/.*$'],
|
|
timeoutMs: 1000,
|
|
maxAge: 5000,
|
|
version: 1,
|
|
},
|
|
{
|
|
name: 'testRefresh',
|
|
maxSize: 3,
|
|
strategy: 'performance',
|
|
patterns: ['^/refresh/.*$'],
|
|
timeoutMs: 1000,
|
|
refreshAheadMs: 1000,
|
|
maxAge: 5000,
|
|
version: 1,
|
|
},
|
|
{
|
|
name: 'testFresh',
|
|
maxSize: 3,
|
|
strategy: 'freshness',
|
|
patterns: ['^/fresh/.*$'],
|
|
timeoutMs: 1000,
|
|
maxAge: 5000,
|
|
version: 1,
|
|
},
|
|
],
|
|
hashTable: tmpHashTableForFs(dist),
|
|
};
|
|
|
|
const seqIncreasedManifest: Manifest = {
|
|
...manifest,
|
|
dataGroups: [
|
|
{
|
|
...manifest.dataGroups ![0],
|
|
version: 2,
|
|
},
|
|
manifest.dataGroups ![1],
|
|
manifest.dataGroups ![2],
|
|
],
|
|
};
|
|
|
|
|
|
const server = new MockServerStateBuilder().withStaticFiles(dist).withManifest(manifest).build();
|
|
|
|
const serverUpdate =
|
|
new MockServerStateBuilder().withStaticFiles(distUpdate).withManifest(manifest).build();
|
|
|
|
const serverSeqUpdate = new MockServerStateBuilder()
|
|
.withStaticFiles(distUpdate)
|
|
.withManifest(seqIncreasedManifest)
|
|
.build();
|
|
|
|
const scope = new SwTestHarnessBuilder().withServerState(server).build();
|
|
|
|
function asyncWrap(fn: () => Promise<void>): (done: DoneFn) => void {
|
|
return (done: DoneFn) => { fn().then(() => done(), err => done.fail(err)); };
|
|
}
|
|
|
|
export function main() {
|
|
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
|
if (!SwTestHarness.envIsSupported()) {
|
|
return;
|
|
}
|
|
describe('data cache', () => {
|
|
let scope: SwTestHarness;
|
|
let driver: Driver;
|
|
async_beforeEach(async() => {
|
|
server.clearRequests();
|
|
scope = new SwTestHarnessBuilder().withServerState(server).build();
|
|
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
|
|
|
// Initialize.
|
|
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
|
await driver.initialized;
|
|
server.reset();
|
|
serverUpdate.reset();
|
|
});
|
|
|
|
describe('in performance mode', () => {
|
|
async_it('names the caches correctly', async() => {
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
|
|
const keys = await scope.caches.keys();
|
|
expect(keys.every(key => key.startsWith('ngsw:'))).toEqual(true);
|
|
});
|
|
|
|
async_it('caches a basic request', async() => {
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
|
|
server.assertSawRequestFor('/api/test');
|
|
scope.advance(1000);
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
|
|
server.assertNoOtherRequests();
|
|
});
|
|
|
|
async_it('refreshes after awhile', async() => {
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
|
|
server.clearRequests();
|
|
scope.advance(10000);
|
|
scope.updateServerState(serverUpdate);
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 2');
|
|
});
|
|
|
|
async_it('expires the least recently used entry', async() => {
|
|
expect(await makeRequest(scope, '/api/a')).toEqual('version A');
|
|
expect(await makeRequest(scope, '/api/b')).toEqual('version B');
|
|
expect(await makeRequest(scope, '/api/c')).toEqual('version C');
|
|
expect(await makeRequest(scope, '/api/d')).toEqual('version D');
|
|
expect(await makeRequest(scope, '/api/e')).toEqual('version E');
|
|
server.clearRequests();
|
|
expect(await makeRequest(scope, '/api/c')).toEqual('version C');
|
|
expect(await makeRequest(scope, '/api/d')).toEqual('version D');
|
|
expect(await makeRequest(scope, '/api/e')).toEqual('version E');
|
|
server.assertNoOtherRequests();
|
|
expect(await makeRequest(scope, '/api/a')).toEqual('version A');
|
|
expect(await makeRequest(scope, '/api/b')).toEqual('version B');
|
|
server.assertSawRequestFor('/api/a');
|
|
server.assertSawRequestFor('/api/b');
|
|
server.assertNoOtherRequests();
|
|
});
|
|
|
|
async_it('does not carry over cache with new version', async() => {
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
|
|
scope.updateServerState(serverSeqUpdate);
|
|
expect(await driver.checkForUpdate()).toEqual(true);
|
|
await driver.updateClient(await scope.clients.get('default'));
|
|
expect(await makeRequest(scope, '/api/test')).toEqual('version 2');
|
|
});
|
|
});
|
|
|
|
describe('in freshness mode', () => {
|
|
async_it('goes to the server first', async() => {
|
|
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresh data');
|
|
server.assertSawRequestFor('/fresh/data');
|
|
server.clearRequests();
|
|
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresh data');
|
|
server.assertSawRequestFor('/fresh/data');
|
|
server.assertNoOtherRequests();
|
|
scope.updateServerState(serverUpdate);
|
|
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresher data');
|
|
serverUpdate.assertSawRequestFor('/fresh/data');
|
|
serverUpdate.assertNoOtherRequests();
|
|
});
|
|
|
|
async_it('falls back on the cache when server times out', async() => {
|
|
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresh data');
|
|
server.assertSawRequestFor('/fresh/data');
|
|
server.clearRequests();
|
|
scope.updateServerState(serverUpdate);
|
|
serverUpdate.pause();
|
|
const [res, done] = makePendingRequest(scope, '/fresh/data');
|
|
|
|
await serverUpdate.nextRequest;
|
|
|
|
// Since the network request doesn't return within the timeout of 1,000ms,
|
|
// this should return cached data.
|
|
scope.advance(2000);
|
|
|
|
expect(await res).toEqual('this is fresh data');
|
|
|
|
// Unpausing allows the worker to continue with caching.
|
|
serverUpdate.unpause();
|
|
await done;
|
|
|
|
serverUpdate.pause();
|
|
const [res2, done2] = makePendingRequest(scope, '/fresh/data');
|
|
await serverUpdate.nextRequest;
|
|
scope.advance(2000);
|
|
expect(await res2).toEqual('this is fresher data');
|
|
});
|
|
|
|
async_it('refreshes ahead', async() => {
|
|
server.assertNoOtherRequests();
|
|
serverUpdate.assertNoOtherRequests();
|
|
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is some data');
|
|
server.assertSawRequestFor('/refresh/data');
|
|
server.clearRequests();
|
|
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is some data');
|
|
server.assertNoOtherRequests();
|
|
scope.updateServerState(serverUpdate);
|
|
scope.advance(1500);
|
|
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is some data');
|
|
serverUpdate.assertSawRequestFor('/refresh/data');
|
|
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is refreshed data');
|
|
serverUpdate.assertNoOtherRequests();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function makeRequest(scope: SwTestHarness, url: string, clientId?: string):
|
|
Promise<string|null> {
|
|
const [resPromise, done] = scope.handleFetch(new MockRequest(url), clientId || 'default');
|
|
await done;
|
|
const res = await resPromise;
|
|
if (res !== undefined) {
|
|
return res.text();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function makePendingRequest(scope: SwTestHarness, url: string, clientId?: string):
|
|
[Promise<string|null>, Promise<void>] {
|
|
const [resPromise, done] = scope.handleFetch(new MockRequest(url), clientId || 'default');
|
|
return [
|
|
(async() => {
|
|
const res = await resPromise;
|
|
if (res !== undefined) {
|
|
return res.text();
|
|
}
|
|
return null;
|
|
})(),
|
|
done
|
|
];
|
|
} |