Previously, if an app version contained the same files as an older version (e.g. making a change, then rolling it back), the SW would not detect it as the latest version (and update clients). This commit fixes it by adding a `timestamp` field in `ngsw.json`, which makes each build unique (with sufficiently high probability). Fixes #24338 PR Close #26006
		
			
				
	
	
		
			234 lines
		
	
	
		
			6.6 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			234 lines
		
	
	
		
			6.6 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 {AssetGroupConfig, Manifest} from '../src/manifest';
 | |
| import {sha1} from '../src/sha1';
 | |
| 
 | |
| import {MockResponse} from './fetch';
 | |
| 
 | |
| export type HeaderMap = {
 | |
|   [key: string]: string
 | |
| };
 | |
| 
 | |
| export class MockFile {
 | |
|   constructor(
 | |
|       readonly path: string, readonly contents: string, readonly headers = {},
 | |
|       readonly hashThisFile: boolean) {}
 | |
| 
 | |
|   get hash(): string { return sha1(this.contents); }
 | |
| }
 | |
| 
 | |
| export class MockFileSystemBuilder {
 | |
|   private resources = new Map<string, MockFile>();
 | |
| 
 | |
|   addFile(path: string, contents: string, headers?: HeaderMap): MockFileSystemBuilder {
 | |
|     this.resources.set(path, new MockFile(path, contents, headers, true));
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   addUnhashedFile(path: string, contents: string, headers?: HeaderMap): MockFileSystemBuilder {
 | |
|     this.resources.set(path, new MockFile(path, contents, headers, false));
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   build(): MockFileSystem { return new MockFileSystem(this.resources); }
 | |
| }
 | |
| 
 | |
| export class MockFileSystem {
 | |
|   constructor(private resources: Map<string, MockFile>) {}
 | |
| 
 | |
|   lookup(path: string): MockFile|undefined { return this.resources.get(path); }
 | |
| 
 | |
|   extend(): MockFileSystemBuilder {
 | |
|     const builder = new MockFileSystemBuilder();
 | |
|     Array.from(this.resources.keys()).forEach(path => {
 | |
|       const res = this.resources.get(path) !;
 | |
|       if (res.hashThisFile) {
 | |
|         builder.addFile(path, res.contents, res.headers);
 | |
|       } else {
 | |
|         builder.addUnhashedFile(path, res.contents, res.headers);
 | |
|       }
 | |
|     });
 | |
|     return builder;
 | |
|   }
 | |
| 
 | |
|   list(): string[] { return Array.from(this.resources.keys()); }
 | |
| }
 | |
| 
 | |
| export class MockServerStateBuilder {
 | |
|   private resources = new Map<string, Response>();
 | |
|   private errors = new Set<string>();
 | |
| 
 | |
|   withStaticFiles(fs: MockFileSystem): MockServerStateBuilder {
 | |
|     fs.list().forEach(path => {
 | |
|       const file = fs.lookup(path) !;
 | |
|       this.resources.set(path, new MockResponse(file.contents, {headers: file.headers}));
 | |
|     });
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   withManifest(manifest: Manifest): MockServerStateBuilder {
 | |
|     this.resources.set('ngsw.json', new MockResponse(JSON.stringify(manifest)));
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   withRedirect(from: string, to: string, toContents: string): MockServerStateBuilder {
 | |
|     this.resources.set(from, new MockResponse(toContents, {redirected: true, url: to}));
 | |
|     this.resources.set(to, new MockResponse(toContents));
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   withError(url: string): MockServerStateBuilder {
 | |
|     this.errors.add(url);
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   build(): MockServerState {
 | |
|     // Take a "snapshot" of the current `resources` and `errors`.
 | |
|     const resources = new Map(this.resources.entries());
 | |
|     const errors = new Set(this.errors.values());
 | |
| 
 | |
|     return new MockServerState(resources, errors);
 | |
|   }
 | |
| }
 | |
| 
 | |
| export class MockServerState {
 | |
|   private requests: Request[] = [];
 | |
|   private gate: Promise<void> = Promise.resolve();
 | |
|   private resolve: Function|null = null;
 | |
|   // TODO(issue/24571): remove '!'.
 | |
|   private resolveNextRequest !: Function;
 | |
|   online = true;
 | |
|   nextRequest: Promise<Request>;
 | |
| 
 | |
|   constructor(private resources: Map<string, Response>, private errors: Set<string>) {
 | |
|     this.nextRequest = new Promise(resolve => { this.resolveNextRequest = resolve; });
 | |
|   }
 | |
| 
 | |
|   async fetch(req: Request): Promise<Response> {
 | |
|     this.resolveNextRequest(req);
 | |
|     this.nextRequest = new Promise(resolve => { this.resolveNextRequest = resolve; });
 | |
| 
 | |
|     await this.gate;
 | |
| 
 | |
|     if (!this.online) {
 | |
|       throw new Error('Offline.');
 | |
|     }
 | |
| 
 | |
|     if (req.credentials === 'include') {
 | |
|       return new MockResponse(null, {status: 0, statusText: '', type: 'opaque'});
 | |
|     }
 | |
|     const url = req.url.split('?')[0];
 | |
|     this.requests.push(req);
 | |
|     if (this.resources.has(url)) {
 | |
|       return this.resources.get(url) !.clone();
 | |
|     }
 | |
|     if (this.errors.has(url)) {
 | |
|       throw new Error('Intentional failure!');
 | |
|     }
 | |
|     return new MockResponse(null, {status: 404, statusText: 'Not Found'});
 | |
|   }
 | |
| 
 | |
|   pause(): void {
 | |
|     this.gate = new Promise(resolve => { this.resolve = resolve; });
 | |
|   }
 | |
| 
 | |
|   unpause(): void {
 | |
|     if (this.resolve === null) {
 | |
|       return;
 | |
|     }
 | |
|     this.resolve();
 | |
|     this.resolve = null;
 | |
|   }
 | |
| 
 | |
|   assertSawRequestFor(url: string): void {
 | |
|     if (!this.sawRequestFor(url)) {
 | |
|       throw new Error(`Expected request for ${url}, got none.`);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   assertNoRequestFor(url: string): void {
 | |
|     if (this.sawRequestFor(url)) {
 | |
|       throw new Error(`Expected no request for ${url} but saw one.`);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   sawRequestFor(url: string): boolean {
 | |
|     const matching = this.requests.filter(req => req.url.split('?')[0] === url);
 | |
|     if (matching.length > 0) {
 | |
|       this.requests = this.requests.filter(req => req !== matching[0]);
 | |
|       return true;
 | |
|     }
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   assertNoOtherRequests(): void {
 | |
|     if (!this.noOtherRequests()) {
 | |
|       throw new Error(
 | |
|           `Expected no other requests, got requests for ${this.requests.map(req => req.url.split('?')[0]).join(', ')}`);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   noOtherRequests(): boolean { return this.requests.length === 0; }
 | |
| 
 | |
|   clearRequests(): void { this.requests = []; }
 | |
| 
 | |
|   reset(): void {
 | |
|     this.clearRequests();
 | |
|     this.nextRequest = new Promise(resolve => { this.resolveNextRequest = resolve; });
 | |
|     this.gate = Promise.resolve();
 | |
|     this.resolve = null;
 | |
|     this.online = true;
 | |
|   }
 | |
| }
 | |
| 
 | |
| export function tmpManifestSingleAssetGroup(fs: MockFileSystem): Manifest {
 | |
|   const files = fs.list();
 | |
|   const hashTable: {[url: string]: string} = {};
 | |
|   files.forEach(path => { hashTable[path] = fs.lookup(path) !.hash; });
 | |
|   return {
 | |
|     configVersion: 1,
 | |
|     timestamp: 1234567890123,
 | |
|     index: '/index.html',
 | |
|     assetGroups: [
 | |
|       {
 | |
|         name: 'group',
 | |
|         installMode: 'prefetch',
 | |
|         updateMode: 'prefetch',
 | |
|         urls: files,
 | |
|         patterns: [],
 | |
|       },
 | |
|     ],
 | |
|     navigationUrls: [], hashTable,
 | |
|   };
 | |
| }
 | |
| 
 | |
| export function tmpHashTableForFs(
 | |
|     fs: MockFileSystem, breakHashes: {[url: string]: boolean} = {}): {[url: string]: string} {
 | |
|   const table: {[url: string]: string} = {};
 | |
|   fs.list().forEach(path => {
 | |
|     const file = fs.lookup(path) !;
 | |
|     if (file.hashThisFile) {
 | |
|       table[path] = file.hash;
 | |
|       if (breakHashes[path]) {
 | |
|         table[path] = table[path].split('').reverse().join('');
 | |
|       }
 | |
|     }
 | |
|   });
 | |
|   return table;
 | |
| }
 | |
| 
 | |
| export function tmpHashTable(manifest: Manifest): Map<string, string> {
 | |
|   const map = new Map<string, string>();
 | |
|   Object.keys(manifest.hashTable).forEach(url => {
 | |
|     const hash = manifest.hashTable[url];
 | |
|     map.set(url, hash);
 | |
|   });
 | |
|   return map;
 | |
| }
 |