test(service-worker): make mock implementations more similar to actual ones (#37922)

This commit makes the mock implementations used is ServiceWorker tests
behave more similar to the actual ones.

PR Close #37922
This commit is contained in:
George Kalpakas 2020-07-06 16:55:37 +03:00 committed by atscott
parent d380e93b82
commit 667aba7508
5 changed files with 258 additions and 134 deletions

View File

@ -13,7 +13,7 @@ import {AssetGroupConfig, DataGroupConfig, Manifest} from '../src/manifest';
import {sha1} from '../src/sha1'; import {sha1} from '../src/sha1';
import {clearAllCaches, MockCache} from '../testing/cache'; import {clearAllCaches, MockCache} from '../testing/cache';
import {MockRequest, MockResponse} from '../testing/fetch'; import {MockRequest, MockResponse} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock'; import {MockFileSystem, MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
(function() { (function() {
@ -352,7 +352,7 @@ describe('Driver', () => {
await scope.resolveSelfMessages(); await scope.resolveSelfMessages();
scope.autoAdvanceTime = false; scope.autoAdvanceTime = false;
server.assertSawRequestFor('ngsw.json'); server.assertSawRequestFor('/ngsw.json');
server.assertSawRequestFor('/foo.txt'); server.assertSawRequestFor('/foo.txt');
server.assertSawRequestFor('/bar.txt'); server.assertSawRequestFor('/bar.txt');
server.assertSawRequestFor('/redirected.txt'); server.assertSawRequestFor('/redirected.txt');
@ -364,7 +364,7 @@ describe('Driver', () => {
it('initializes prefetched content correctly, after a request kicks it off', async () => { it('initializes prefetched content correctly, after a request kicks it off', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized; await driver.initialized;
server.assertSawRequestFor('ngsw.json'); server.assertSawRequestFor('/ngsw.json');
server.assertSawRequestFor('/foo.txt'); server.assertSawRequestFor('/foo.txt');
server.assertSawRequestFor('/bar.txt'); server.assertSawRequestFor('/bar.txt');
server.assertSawRequestFor('/redirected.txt'); server.assertSawRequestFor('/redirected.txt');
@ -381,7 +381,7 @@ describe('Driver', () => {
// Making a request initializes the driver (fetches assets). // Making a request initializes the driver (fetches assets).
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
expect(driver['latestHash']).toEqual(jasmine.any(String)); expect(driver['latestHash']).toEqual(jasmine.any(String));
server.assertSawRequestFor('ngsw.json'); server.assertSawRequestFor('/ngsw.json');
server.assertSawRequestFor('/foo.txt'); server.assertSawRequestFor('/foo.txt');
server.assertSawRequestFor('/bar.txt'); server.assertSawRequestFor('/bar.txt');
server.assertSawRequestFor('/redirected.txt'); server.assertSawRequestFor('/redirected.txt');
@ -400,7 +400,7 @@ describe('Driver', () => {
// Pushing a message initializes the driver (fetches assets). // Pushing a message initializes the driver (fetches assets).
await scope.handleMessage({action: 'foo'}, 'someClient'); await scope.handleMessage({action: 'foo'}, 'someClient');
expect(driver['latestHash']).toEqual(jasmine.any(String)); expect(driver['latestHash']).toEqual(jasmine.any(String));
server.assertSawRequestFor('ngsw.json'); server.assertSawRequestFor('/ngsw.json');
server.assertSawRequestFor('/foo.txt'); server.assertSawRequestFor('/foo.txt');
server.assertSawRequestFor('/bar.txt'); server.assertSawRequestFor('/bar.txt');
server.assertSawRequestFor('/redirected.txt'); server.assertSawRequestFor('/redirected.txt');
@ -466,7 +466,7 @@ describe('Driver', () => {
scope.updateServerState(serverUpdate); scope.updateServerState(serverUpdate);
expect(await driver.checkForUpdate()).toEqual(true); expect(await driver.checkForUpdate()).toEqual(true);
serverUpdate.assertSawRequestFor('ngsw.json'); serverUpdate.assertSawRequestFor('/ngsw.json');
serverUpdate.assertSawRequestFor('/foo.txt'); serverUpdate.assertSawRequestFor('/foo.txt');
serverUpdate.assertSawRequestFor('/redirected.txt'); serverUpdate.assertSawRequestFor('/redirected.txt');
serverUpdate.assertNoOtherRequests(); serverUpdate.assertNoOtherRequests();
@ -562,7 +562,7 @@ describe('Driver', () => {
scope.advance(12000); scope.advance(12000);
await driver.idle.empty; await driver.idle.empty;
serverUpdate.assertSawRequestFor('ngsw.json'); serverUpdate.assertSawRequestFor('/ngsw.json');
serverUpdate.assertSawRequestFor('/foo.txt'); serverUpdate.assertSawRequestFor('/foo.txt');
serverUpdate.assertSawRequestFor('/redirected.txt'); serverUpdate.assertSawRequestFor('/redirected.txt');
serverUpdate.assertNoOtherRequests(); serverUpdate.assertNoOtherRequests();
@ -578,7 +578,7 @@ describe('Driver', () => {
scope.advance(12000); scope.advance(12000);
await driver.idle.empty; await driver.idle.empty;
server.assertSawRequestFor('ngsw.json'); server.assertSawRequestFor('/ngsw.json');
}); });
it('does not make concurrent checks for updates on navigation', async () => { it('does not make concurrent checks for updates on navigation', async () => {
@ -593,7 +593,7 @@ describe('Driver', () => {
scope.advance(12000); scope.advance(12000);
await driver.idle.empty; await driver.idle.empty;
server.assertSawRequestFor('ngsw.json'); server.assertSawRequestFor('/ngsw.json');
server.assertNoOtherRequests(); server.assertNoOtherRequests();
}); });
@ -791,70 +791,75 @@ describe('Driver', () => {
}); });
it('should bypass serviceworker on ngsw-bypass parameter', async () => { it('should bypass serviceworker on ngsw-bypass parameter', async () => {
await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypass': 'true'}}); // NOTE:
server.assertNoRequestFor('/foo.txt'); // Requests that bypass the SW are not handled at all in the mock implementation of `scope`,
// therefore no requests reach the server.
await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypass': 'anything'}}); await makeRequest(scope, '/some/url', undefined, {headers: {'ngsw-bypass': 'true'}});
server.assertNoRequestFor('/foo.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypass': null!}}); await makeRequest(scope, '/some/url', undefined, {headers: {'ngsw-bypass': 'anything'}});
server.assertNoRequestFor('/foo.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/foo.txt', undefined, {headers: {'NGSW-bypass': 'upperCASE'}}); await makeRequest(scope, '/some/url', undefined, {headers: {'ngsw-bypass': null!}});
server.assertNoRequestFor('/foo.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypasss': 'anything'}}); await makeRequest(scope, '/some/url', undefined, {headers: {'NGSW-bypass': 'upperCASE'}});
server.assertSawRequestFor('/foo.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/some/url', undefined, {headers: {'ngsw-bypasss': 'anything'}});
server.assertSawRequestFor('/some/url');
server.clearRequests(); server.clearRequests();
await makeRequest(scope, '/bar.txt?ngsw-bypass=true'); await makeRequest(scope, '/some/url?ngsw-bypass=true');
server.assertNoRequestFor('/bar.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar.txt?ngsw-bypasss=true'); await makeRequest(scope, '/some/url?ngsw-bypasss=true');
server.assertSawRequestFor('/bar.txt'); server.assertSawRequestFor('/some/url');
server.clearRequests(); server.clearRequests();
await makeRequest(scope, '/bar.txt?ngsw-bypaSS=something'); await makeRequest(scope, '/some/url?ngsw-bypaSS=something');
server.assertNoRequestFor('/bar.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar.txt?testparam=test&ngsw-byPASS=anything'); await makeRequest(scope, '/some/url?testparam=test&ngsw-byPASS=anything');
server.assertNoRequestFor('/bar.txt'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar.txt?testparam=test&angsw-byPASS=anything'); await makeRequest(scope, '/some/url?testparam=test&angsw-byPASS=anything');
server.assertSawRequestFor('/bar.txt'); server.assertSawRequestFor('/some/url');
server.clearRequests(); server.clearRequests();
await makeRequest(scope, '/bar&ngsw-bypass=true.txt?testparam=test&angsw-byPASS=anything'); await makeRequest(scope, '/some/url&ngsw-bypass=true.txt?testparam=test&angsw-byPASS=anything');
server.assertSawRequestFor('/bar&ngsw-bypass=true.txt'); server.assertSawRequestFor('/some/url&ngsw-bypass=true.txt');
server.clearRequests(); server.clearRequests();
await makeRequest(scope, '/bar&ngsw-bypass=true.txt'); await makeRequest(scope, '/some/url&ngsw-bypass=true.txt');
server.assertSawRequestFor('/bar&ngsw-bypass=true.txt'); server.assertSawRequestFor('/some/url&ngsw-bypass=true.txt');
server.clearRequests(); server.clearRequests();
await makeRequest( await makeRequest(
scope, '/bar&ngsw-bypass=true.txt?testparam=test&ngSW-BYPASS=SOMETHING&testparam2=test'); scope,
server.assertNoRequestFor('/bar&ngsw-bypass=true.txt'); '/some/url&ngsw-bypass=true.txt?testparam=test&ngSW-BYPASS=SOMETHING&testparam2=test');
server.assertNoRequestFor('/some/url&ngsw-bypass=true.txt');
await makeRequest(scope, '/bar?testparam=test&ngsw-bypass'); await makeRequest(scope, '/some/url?testparam=test&ngsw-bypass');
server.assertNoRequestFor('/bar'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar?testparam=test&ngsw-bypass&testparam2'); await makeRequest(scope, '/some/url?testparam=test&ngsw-bypass&testparam2');
server.assertNoRequestFor('/bar'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar?ngsw-bypass&testparam2'); await makeRequest(scope, '/some/url?ngsw-bypass&testparam2');
server.assertNoRequestFor('/bar'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar?ngsw-bypass=&foo=ngsw-bypass'); await makeRequest(scope, '/some/url?ngsw-bypass=&foo=ngsw-bypass');
server.assertNoRequestFor('/bar'); server.assertNoRequestFor('/some/url');
await makeRequest(scope, '/bar?ngsw-byapass&testparam2'); await makeRequest(scope, '/some/url?ngsw-byapass&testparam2');
server.assertSawRequestFor('/bar'); server.assertSawRequestFor('/some/url');
}); });
it('unregisters when manifest 404s', async () => { it('unregisters when manifest 404s', async () => {
@ -922,115 +927,172 @@ describe('Driver', () => {
}); });
describe('cache naming', () => { describe('cache naming', () => {
let uid: number;
// Helpers // Helpers
const cacheKeysFor = (baseHref: string) => const cacheKeysFor = (baseHref: string, manifestHash: string) =>
[`ngsw:${baseHref}:db:control`, [`ngsw:${baseHref}:db:control`,
`ngsw:${baseHref}:${manifestHash}:assets:assets:cache`, `ngsw:${baseHref}:${manifestHash}:assets:eager:cache`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:assets:meta`, `ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:eager:meta`,
`ngsw:${baseHref}:${manifestHash}:assets:other:cache`, `ngsw:${baseHref}:${manifestHash}:assets:lazy:cache`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:other:meta`, `ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:lazy:meta`,
`ngsw:${baseHref}:${manifestHash}:assets:lazy_prefetch:cache`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:lazy_prefetch:meta`,
`ngsw:${baseHref}:42:data:dynamic:api:cache`, `ngsw:${baseHref}:42:data:dynamic:api:cache`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:42:data:dynamic:api:lru`, `ngsw:${baseHref}:db:ngsw:${baseHref}:42:data:dynamic:api:lru`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:42:data:dynamic:api:age`, `ngsw:${baseHref}:db:ngsw:${baseHref}:42:data:dynamic:api:age`,
`ngsw:${baseHref}:43:data:dynamic:api-static:cache`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:43:data:dynamic:api-static:lru`,
`ngsw:${baseHref}:db:ngsw:${baseHref}:43:data:dynamic:api-static:age`,
]; ];
const createManifestWithBaseHref = (baseHref: string, distDir: MockFileSystem): Manifest => ({
configVersion: 1,
timestamp: 1234567890123,
index: `${baseHref}foo.txt`,
assetGroups: [
{
name: 'eager',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: [
`${baseHref}foo.txt`,
`${baseHref}bar.txt`,
],
patterns: [],
cacheQueryOptions: {ignoreVary: true},
},
{
name: 'lazy',
installMode: 'lazy',
updateMode: 'lazy',
urls: [
`${baseHref}baz.txt`,
`${baseHref}qux.txt`,
],
patterns: [],
cacheQueryOptions: {ignoreVary: true},
},
],
dataGroups: [
{
name: 'api',
version: 42,
maxAge: 3600000,
maxSize: 100,
strategy: 'freshness',
patterns: [
'/api/.*',
],
cacheQueryOptions: {ignoreVary: true},
},
],
navigationUrls: processNavigationUrls(baseHref),
hashTable: tmpHashTableForFs(distDir, {}, baseHref),
});
const getClientAssignments = async (sw: SwTestHarness, baseHref: string) => { const getClientAssignments = async (sw: SwTestHarness, baseHref: string) => {
const cache = await sw.caches.open(`ngsw:${baseHref}:db:control`) as unknown as MockCache; const cache = await sw.caches.open(`ngsw:${baseHref}:db:control`) as unknown as MockCache;
const dehydrated = cache.dehydrate(); const dehydrated = cache.dehydrate();
return JSON.parse(dehydrated['/assignments'].body!); return JSON.parse(dehydrated['/assignments'].body!);
}; };
const initializeSwFor = const initializeSwFor = async (baseHref: string, initialCacheState = '{}') => {
async (baseHref: string, initialCacheState = '{}', serverState = server) => { const newDistDir = dist.extend().addFile('/foo.txt', `this is foo v${++uid}`).build();
const newManifest = createManifestWithBaseHref(baseHref, newDistDir);
const newManifestHash = sha1(JSON.stringify(newManifest));
const serverState = new MockServerStateBuilder()
.withRootDirectory(baseHref)
.withStaticFiles(newDistDir)
.withManifest(newManifest)
.build();
const newScope = new SwTestHarnessBuilder(`http://localhost${baseHref}`) const newScope = new SwTestHarnessBuilder(`http://localhost${baseHref}`)
.withCacheState(initialCacheState) .withCacheState(initialCacheState)
.withServerState(serverState) .withServerState(serverState)
.build(); .build();
const newDriver = new Driver(newScope, newScope, new CacheDatabase(newScope, newScope)); const newDriver = new Driver(newScope, newScope, new CacheDatabase(newScope, newScope));
await makeRequest(newScope, '/foo.txt', baseHref.replace(/\//g, '_')); await makeRequest(newScope, newManifest.index, baseHref.replace(/\//g, '_'));
await newDriver.initialized; await newDriver.initialized;
return newScope; return [newScope, newManifestHash] as [SwTestHarness, string];
}; };
it('includes the SW scope in all cache names', async () => { beforeEach(() => {
// Default SW with scope `/`. uid = 0;
await makeRequest(scope, '/foo.txt'); });
await driver.initialized;
const cacheNames = await scope.caches.keys();
expect(cacheNames).toEqual(cacheKeysFor('/')); it('includes the SW scope in all cache names', async () => {
// SW with scope `/`.
const [rootScope, rootManifestHash] = await initializeSwFor('/');
const cacheNames = await rootScope.caches.keys();
expect(cacheNames).toEqual(cacheKeysFor('/', rootManifestHash));
expect(cacheNames.every(name => name.includes('/'))).toBe(true); expect(cacheNames.every(name => name.includes('/'))).toBe(true);
// SW with scope `/foo/`. // SW with scope `/foo/`.
const fooScope = await initializeSwFor('/foo/'); const [fooScope, fooManifestHash] = await initializeSwFor('/foo/');
const fooCacheNames = await fooScope.caches.keys(); const fooCacheNames = await fooScope.caches.keys();
expect(fooCacheNames).toEqual(cacheKeysFor('/foo/')); expect(fooCacheNames).toEqual(cacheKeysFor('/foo/', fooManifestHash));
expect(fooCacheNames.every(name => name.includes('/foo/'))).toBe(true); expect(fooCacheNames.every(name => name.includes('/foo/'))).toBe(true);
}); });
it('does not affect caches from other scopes', async () => { it('does not affect caches from other scopes', async () => {
// Create SW with scope `/foo/`. // Create SW with scope `/foo/`.
const fooScope = await initializeSwFor('/foo/'); const [fooScope, fooManifestHash] = await initializeSwFor('/foo/');
const fooAssignments = await getClientAssignments(fooScope, '/foo/'); const fooAssignments = await getClientAssignments(fooScope, '/foo/');
expect(fooAssignments).toEqual({_foo_: manifestHash}); expect(fooAssignments).toEqual({_foo_: fooManifestHash});
// Add new SW with different scope. // Add new SW with different scope.
const barScope = await initializeSwFor('/bar/', await fooScope.caches.dehydrate()); const [barScope, barManifestHash] =
await initializeSwFor('/bar/', await fooScope.caches.dehydrate());
const barCacheNames = await barScope.caches.keys(); const barCacheNames = await barScope.caches.keys();
const barAssignments = await getClientAssignments(barScope, '/bar/'); const barAssignments = await getClientAssignments(barScope, '/bar/');
expect(barAssignments).toEqual({_bar_: manifestHash}); expect(barAssignments).toEqual({_bar_: barManifestHash});
expect(barCacheNames).toEqual([ expect(barCacheNames).toEqual([
...cacheKeysFor('/foo/'), ...cacheKeysFor('/foo/', fooManifestHash),
...cacheKeysFor('/bar/'), ...cacheKeysFor('/bar/', barManifestHash),
]); ]);
// The caches for `/foo/` should be intact. // The caches for `/foo/` should be intact.
const fooAssignments2 = await getClientAssignments(barScope, '/foo/'); const fooAssignments2 = await getClientAssignments(barScope, '/foo/');
expect(fooAssignments2).toEqual({_foo_: manifestHash}); expect(fooAssignments2).toEqual({_foo_: fooManifestHash});
}); });
it('updates existing caches for same scope', async () => { it('updates existing caches for same scope', async () => {
// Create SW with scope `/foo/`. // Create SW with scope `/foo/`.
const fooScope = await initializeSwFor('/foo/'); const [fooScope, fooManifestHash] = await initializeSwFor('/foo/');
await makeRequest(fooScope, '/foo.txt', '_bar_'); await makeRequest(fooScope, '/foo/foo.txt', '_bar_');
const fooAssignments = await getClientAssignments(fooScope, '/foo/'); const fooAssignments = await getClientAssignments(fooScope, '/foo/');
expect(fooAssignments).toEqual({ expect(fooAssignments).toEqual({
_foo_: manifestHash, _foo_: fooManifestHash,
_bar_: manifestHash, _bar_: fooManifestHash,
}); });
expect(await makeRequest(fooScope, '/baz.txt', '_foo_')).toBe('this is baz'); expect(await makeRequest(fooScope, '/foo/baz.txt', '_foo_')).toBe('this is baz');
expect(await makeRequest(fooScope, '/baz.txt', '_bar_')).toBe('this is baz'); expect(await makeRequest(fooScope, '/foo/baz.txt', '_bar_')).toBe('this is baz');
// Add new SW with same scope. // Add new SW with same scope.
const fooScope2 = const [fooScope2, fooManifestHash2] =
await initializeSwFor('/foo/', await fooScope.caches.dehydrate(), serverUpdate); await initializeSwFor('/foo/', await fooScope.caches.dehydrate());
// Update client `_foo_` but not client `_bar_`.
await fooScope2.handleMessage({action: 'CHECK_FOR_UPDATES'}, '_foo_'); await fooScope2.handleMessage({action: 'CHECK_FOR_UPDATES'}, '_foo_');
await fooScope2.handleMessage({action: 'ACTIVATE_UPDATE'}, '_foo_'); await fooScope2.handleMessage({action: 'ACTIVATE_UPDATE'}, '_foo_');
const fooAssignments2 = await getClientAssignments(fooScope2, '/foo/'); const fooAssignments2 = await getClientAssignments(fooScope2, '/foo/');
expect(fooAssignments2).toEqual({ expect(fooAssignments2).toEqual({
_foo_: manifestUpdateHash, _foo_: fooManifestHash2,
_bar_: manifestHash, _bar_: fooManifestHash,
}); });
// Everything should still work as expected. // Everything should still work as expected.
expect(await makeRequest(fooScope2, '/foo.txt', '_foo_')).toBe('this is foo v2'); expect(await makeRequest(fooScope2, '/foo/foo.txt', '_foo_')).toBe('this is foo v2');
expect(await makeRequest(fooScope2, '/foo.txt', '_bar_')).toBe('this is foo'); expect(await makeRequest(fooScope2, '/foo/foo.txt', '_bar_')).toBe('this is foo v1');
expect(await makeRequest(fooScope2, '/baz.txt', '_foo_')).toBe('this is baz v2'); expect(await makeRequest(fooScope2, '/foo/baz.txt', '_foo_')).toBe('this is baz');
expect(await makeRequest(fooScope2, '/baz.txt', '_bar_')).toBe('this is baz'); expect(await makeRequest(fooScope2, '/foo/baz.txt', '_bar_')).toBe('this is baz');
}); });
}); });
@ -1154,7 +1216,7 @@ describe('Driver', () => {
server.assertNoOtherRequests(); server.assertNoOtherRequests();
}); });
it('redirects to index on a request to the origin URL request', async () => { it('redirects to index on a request to the scope URL', async () => {
expect(await navRequest('http://localhost/')).toEqual('this is foo'); expect(await navRequest('http://localhost/')).toEqual('this is foo');
server.assertNoOtherRequests(); server.assertNoOtherRequests();
}); });

View File

@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {MockRequest, MockResponse} from './fetch'; import {MockResponse} from './fetch';
import {normalizeUrl} from './utils';
export interface DehydratedResponse { export interface DehydratedResponse {
body: string|null; body: string|null;
@ -104,7 +105,7 @@ export class MockCache {
} }
async 'delete'(request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> { async 'delete'(request: RequestInfo, options?: CacheQueryOptions): Promise<boolean> {
let url = (typeof request === 'string' ? request : request.url); let url = this.getRequestUrl(request);
if (this.cache.has(url)) { if (this.cache.has(url)) {
this.cache.delete(url); this.cache.delete(url);
return true; return true;
@ -127,10 +128,7 @@ export class MockCache {
} }
async match(request: RequestInfo, options?: CacheQueryOptions): Promise<Response> { async match(request: RequestInfo, options?: CacheQueryOptions): Promise<Response> {
let url = (typeof request === 'string' ? request : request.url); let url = this.getRequestUrl(request);
if (url.startsWith(this.origin)) {
url = '/' + url.substr(this.origin.length);
}
// TODO: cleanup typings. Typescript doesn't know this can resolve to undefined. // TODO: cleanup typings. Typescript doesn't know this can resolve to undefined.
let res = this.cache.get(url); let res = this.cache.get(url);
if (!res && options?.ignoreSearch) { if (!res && options?.ignoreSearch) {
@ -150,8 +148,7 @@ export class MockCache {
if (request === undefined) { if (request === undefined) {
return Array.from(this.cache.values()); return Array.from(this.cache.values());
} }
const url = (typeof request === 'string' ? request : request.url); const res = await this.match(request, options);
const res = await this.match(url, options);
if (res) { if (res) {
return [res]; return [res];
} else { } else {
@ -160,7 +157,7 @@ export class MockCache {
} }
async put(request: RequestInfo, response: Response): Promise<void> { async put(request: RequestInfo, response: Response): Promise<void> {
const url = (typeof request === 'string' ? request : request.url); const url = this.getRequestUrl(request);
this.cache.set(url, response.clone()); this.cache.set(url, response.clone());
// Even though the body above is cloned, consume it here because the // Even though the body above is cloned, consume it here because the
@ -190,6 +187,12 @@ export class MockCache {
return dehydrated; return dehydrated;
} }
/** Get the normalized URL from a `RequestInfo` value. */
private getRequestUrl(request: RequestInfo): string {
const url = typeof request === 'string' ? request : request.url;
return normalizeUrl(url, this.origin);
}
/** remove the query/hash part from a url*/ /** remove the query/hash part from a url*/
private stripQueryAndHash(url: string): string { private stripQueryAndHash(url: string): string {
return url.replace(/[?#].*/, ''); return url.replace(/[?#].*/, '');

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {AssetGroupConfig, Manifest} from '../src/manifest'; import {Manifest} from '../src/manifest';
import {sha1} from '../src/sha1'; import {sha1} from '../src/sha1';
import {MockResponse} from './fetch'; import {MockResponse} from './fetch';
@ -69,19 +69,38 @@ export class MockFileSystem {
} }
export class MockServerStateBuilder { export class MockServerStateBuilder {
private rootDir = '/';
private resources = new Map<string, Response>(); private resources = new Map<string, Response>();
private errors = new Set<string>(); private errors = new Set<string>();
withStaticFiles(fs: MockFileSystem): MockServerStateBuilder { withRootDirectory(newRootDir: string): MockServerStateBuilder {
fs.list().forEach(path => { // Update existing resources/errors.
const file = fs.lookup(path)!; const oldRootDir = this.rootDir;
this.resources.set(path, new MockResponse(file.contents, {headers: file.headers})); const updateRootDir = (path: string) =>
path.startsWith(oldRootDir) ? joinPaths(newRootDir, path.slice(oldRootDir.length)) : path;
this.resources = new Map(
[...this.resources].map(([path, contents]) => [updateRootDir(path), contents.clone()]));
this.errors = new Set([...this.errors].map(url => updateRootDir(url)));
// Set `rootDir` for future resource/error additions.
this.rootDir = newRootDir;
return this;
}
withStaticFiles(dir: MockFileSystem): MockServerStateBuilder {
dir.list().forEach(path => {
const file = dir.lookup(path)!;
this.resources.set(
joinPaths(this.rootDir, path), new MockResponse(file.contents, {headers: file.headers}));
}); });
return this; return this;
} }
withManifest(manifest: Manifest): MockServerStateBuilder { withManifest(manifest: Manifest): MockServerStateBuilder {
this.resources.set('ngsw.json', new MockResponse(JSON.stringify(manifest))); const manifestPath = joinPaths(this.rootDir, 'ngsw.json');
this.resources.set(manifestPath, new MockResponse(JSON.stringify(manifest)));
return this; return this;
} }
@ -234,14 +253,16 @@ export function tmpManifestSingleAssetGroup(fs: MockFileSystem): Manifest {
} }
export function tmpHashTableForFs( export function tmpHashTableForFs(
fs: MockFileSystem, breakHashes: {[url: string]: boolean} = {}): {[url: string]: string} { fs: MockFileSystem, breakHashes: {[url: string]: boolean} = {},
baseHref = '/'): {[url: string]: string} {
const table: {[url: string]: string} = {}; const table: {[url: string]: string} = {};
fs.list().forEach(path => { fs.list().forEach(filePath => {
const file = fs.lookup(path)!; const urlPath = joinPaths(baseHref, filePath);
const file = fs.lookup(filePath)!;
if (file.hashThisFile) { if (file.hashThisFile) {
table[path] = file.hash; table[urlPath] = file.hash;
if (breakHashes[path]) { if (breakHashes[filePath]) {
table[path] = table[path].split('').reverse().join(''); table[urlPath] = table[urlPath].split('').reverse().join('');
} }
} }
}); });
@ -256,3 +277,11 @@ export function tmpHashTable(manifest: Manifest): Map<string, string> {
}); });
return map; return map;
} }
// Helpers
/**
* Join two path segments, ensuring that there is exactly one slash (`/`) between them.
*/
function joinPaths(path1: string, path2: string): string {
return `${path1.replace(/\/$/, '')}/${path2.replace(/^\//, '')}`;
}

View File

@ -15,6 +15,7 @@ import {sha1} from '../src/sha1';
import {MockCacheStorage} from './cache'; import {MockCacheStorage} from './cache';
import {MockHeaders, MockRequest, MockResponse} from './fetch'; import {MockHeaders, MockRequest, MockResponse} from './fetch';
import {MockServerState, MockServerStateBuilder} from './mock'; import {MockServerState, MockServerStateBuilder} from './mock';
import {normalizeUrl, parseUrl} from './utils';
const EMPTY_SERVER_STATE = new MockServerStateBuilder().build(); const EMPTY_SERVER_STATE = new MockServerStateBuilder().build();
@ -32,10 +33,11 @@ export class MockClient {
} }
export class SwTestHarnessBuilder { export class SwTestHarnessBuilder {
private origin = parseUrl(this.scopeUrl).origin;
private server = EMPTY_SERVER_STATE; private server = EMPTY_SERVER_STATE;
private caches = new MockCacheStorage(this.origin); private caches = new MockCacheStorage(this.origin);
constructor(private origin = 'http://localhost/') {} constructor(private scopeUrl = 'http://localhost/') {}
withCacheState(cache: string): SwTestHarnessBuilder { withCacheState(cache: string): SwTestHarnessBuilder {
this.caches = new MockCacheStorage(this.origin, cache); this.caches = new MockCacheStorage(this.origin, cache);
@ -48,7 +50,7 @@ export class SwTestHarnessBuilder {
} }
build(): SwTestHarness { build(): SwTestHarness {
return new SwTestHarness(this.server, this.caches, this.origin); return new SwTestHarness(this.server, this.caches, this.scopeUrl);
} }
} }
@ -137,6 +139,8 @@ export class SwTestHarness extends Adapter implements ServiceWorkerGlobalScope,
fired: boolean, fired: boolean,
}[] = []; }[] = [];
parseUrl = parseUrl;
constructor( constructor(
private server: MockServerState, readonly caches: MockCacheStorage, scopeUrl: string) { private server: MockServerState, readonly caches: MockCacheStorage, scopeUrl: string) {
super(scopeUrl); super(scopeUrl);
@ -176,17 +180,12 @@ export class SwTestHarness extends Adapter implements ServiceWorkerGlobalScope,
this.server = server || EMPTY_SERVER_STATE; this.server = server || EMPTY_SERVER_STATE;
} }
fetch(req: string|Request): Promise<Response> { fetch(req: RequestInfo): Promise<Response> {
if (typeof req === 'string') { if (typeof req === 'string') {
if (req.startsWith(this.origin)) { return this.server.fetch(new MockRequest(normalizeUrl(req, this.scopeUrl)));
req = '/' + req.substr(this.origin.length);
}
return this.server.fetch(new MockRequest(req));
} else { } else {
const mockReq = req.clone() as MockRequest; const mockReq = req.clone() as MockRequest;
if (mockReq.url.startsWith(this.origin)) { mockReq.url = normalizeUrl(mockReq.url, this.scopeUrl);
mockReq.url = '/' + mockReq.url.substr(this.origin.length);
}
return this.server.fetch(mockReq); return this.server.fetch(mockReq);
} }
} }
@ -200,7 +199,7 @@ export class SwTestHarness extends Adapter implements ServiceWorkerGlobalScope,
} }
newRequest(url: string, init: Object = {}): Request { newRequest(url: string, init: Object = {}): Request {
return new MockRequest(url, init); return new MockRequest(normalizeUrl(url, this.scopeUrl), init);
} }
newResponse(body: string, init: Object = {}): Response { newResponse(body: string, init: Object = {}): Response {
@ -214,18 +213,6 @@ export class SwTestHarness extends Adapter implements ServiceWorkerGlobalScope,
}, new MockHeaders()); }, new MockHeaders());
} }
parseUrl(url: string, relativeTo?: string): {origin: string, path: string, search: string} {
const parsedUrl: URL = (typeof URL === 'function') ?
(!relativeTo ? new URL(url) : new URL(url, relativeTo)) :
require('url').parse(require('url').resolve(relativeTo || '', url));
return {
origin: parsedUrl.origin || `${parsedUrl.protocol}//${parsedUrl.host}`,
path: parsedUrl.pathname,
search: parsedUrl.search || '',
};
}
async skipWaiting(): Promise<void> { async skipWaiting(): Promise<void> {
this.skippedWaiting = true; this.skippedWaiting = true;
} }
@ -409,6 +396,7 @@ class MockPushEvent extends MockExtendableEvent {
json: () => this._data, json: () => this._data,
}; };
} }
class MockNotificationEvent extends MockExtendableEvent { class MockNotificationEvent extends MockExtendableEvent {
constructor(private _notification: any, readonly action?: string) { constructor(private _notification: any, readonly action?: string) {
super(); super();
@ -418,5 +406,4 @@ class MockNotificationEvent extends MockExtendableEvent {
class MockInstallEvent extends MockExtendableEvent {} class MockInstallEvent extends MockExtendableEvent {}
class MockActivateEvent extends MockExtendableEvent {} class MockActivateEvent extends MockExtendableEvent {}

View File

@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC 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
*/
/**
* Get a normalized representation of a URL relative to a provided base URL.
*
* More specifically:
* 1. Resolve the URL relative to the provided base URL.
* 2. If the URL is relative to the base URL, then strip the origin (and only return the path and
* search parts). Otherwise, return the full URL.
*
* @param url The raw URL.
* @param relativeTo The base URL to resolve `url` relative to.
* (This is usually the ServiceWorker's origin or registration scope).
* @return A normalized representation of the URL.
*/
export function normalizeUrl(url: string, relativeTo: string): string {
const {origin, path, search} = parseUrl(url, relativeTo);
const {origin: relativeToOrigin} = parseUrl(relativeTo);
return (origin === relativeToOrigin) ? path + search : url;
}
/**
* Parse a URL into its different parts, such as `origin`, `path` and `search`.
*/
export function parseUrl(
url: string, relativeTo?: string): {origin: string, path: string, search: string} {
const parsedUrl: URL = (typeof URL === 'function') ?
(!relativeTo ? new URL(url) : new URL(url, relativeTo)) :
require('url').parse(require('url').resolve(relativeTo || '', url));
return {
origin: parsedUrl.origin || `${parsedUrl.protocol}//${parsedUrl.host}`,
path: parsedUrl.pathname,
search: parsedUrl.search || '',
};
}