/**
 * @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
 */

import {processNavigationUrls} from '../../config/src/generator';
import {CacheDatabase} from '../src/db-cache';
import {Driver, DriverReadyState} from '../src/driver';
import {AssetGroupConfig, DataGroupConfig, Manifest} from '../src/manifest';
import {sha1} from '../src/sha1';
import {clearAllCaches, MockCache} from '../testing/cache';
import {MockRequest, MockResponse} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';

(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'})
        .addUnhashedFile('/api/foo', 'this is api foo', {'Cache-Control': 'no-cache'})
        .addUnhashedFile('/api-static/bar', 'this is static api bar', {'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 (broken)')
                     .addFile('/bar.txt', 'this is bar (broken)')
                     .build();

const brokenManifest: Manifest = {
  configVersion: 1,
  timestamp: 1234567890123,
  index: '/foo.txt',
  assetGroups: [{
    name: 'assets',
    installMode: 'prefetch',
    updateMode: 'prefetch',
    urls: [
      '/foo.txt',
    ],
    patterns: [],
    cacheQueryOptions: {ignoreVary: true},
  }],
  dataGroups: [],
  navigationUrls: processNavigationUrls(''),
  hashTable: tmpHashTableForFs(brokenFs, {'/foo.txt': true}),
};

const brokenLazyManifest: Manifest = {
  configVersion: 1,
  timestamp: 1234567890123,
  index: '/foo.txt',
  assetGroups: [
    {
      name: 'assets',
      installMode: 'prefetch',
      updateMode: 'prefetch',
      urls: [
        '/foo.txt',
      ],
      patterns: [],
      cacheQueryOptions: {ignoreVary: true},
    },
    {
      name: 'lazy-assets',
      installMode: 'lazy',
      updateMode: 'lazy',
      urls: [
        '/bar.txt',
      ],
      patterns: [],
      cacheQueryOptions: {ignoreVary: true},
    },
  ],
  dataGroups: [],
  navigationUrls: processNavigationUrls(''),
  hashTable: tmpHashTableForFs(brokenFs, {'/bar.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/.*',
      ],
      cacheQueryOptions: {ignoreVary: true},
    },
    {
      name: 'other',
      installMode: 'lazy',
      updateMode: 'lazy',
      urls: [
        '/baz.txt',
        '/qux.txt',
      ],
      patterns: [],
      cacheQueryOptions: {ignoreVary: true},
    },
    {
      name: 'lazy_prefetch',
      installMode: 'lazy',
      updateMode: 'prefetch',
      urls: [
        '/quux.txt',
        '/quuux.txt',
        '/lazy/unchanged1.txt',
        '/lazy/unchanged2.txt',
      ],
      patterns: [],
      cacheQueryOptions: {ignoreVary: true},
    }
  ],
  dataGroups: [
    {
      name: 'api',
      version: 42,
      maxAge: 3600000,
      maxSize: 100,
      strategy: 'freshness',
      patterns: [
        '/api/.*',
      ],
      cacheQueryOptions: {ignoreVary: true},
    },
    {
      name: 'api-static',
      version: 43,
      maxAge: 3600000,
      maxSize: 100,
      strategy: 'performance',
      patterns: [
        '/api-static/.*',
      ],
      cacheQueryOptions: {ignoreVary: true},
    },
  ],
  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/.*',
      ],
      cacheQueryOptions: {ignoreVary: true},
    },
    {
      name: 'other',
      installMode: 'lazy',
      updateMode: 'lazy',
      urls: [
        '/baz.txt',
        '/qux.txt',
      ],
      patterns: [],
      cacheQueryOptions: {ignoreVary: true},
    },
    {
      name: 'lazy_prefetch',
      installMode: 'lazy',
      updateMode: 'prefetch',
      urls: [
        '/quux.txt',
        '/quuux.txt',
        '/lazy/unchanged1.txt',
        '/lazy/unchanged2.txt',
      ],
      patterns: [],
      cacheQueryOptions: {ignoreVary: true},
    }
  ],
  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 brokenLazyServer =
    new MockServerStateBuilder().withStaticFiles(brokenFs).withManifest(brokenLazyManifest).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));
  });

  it('activates without waiting', async () => {
    const skippedWaiting = await scope.startup(true);
    expect(skippedWaiting).toBe(true);
  });

  it('claims all clients, after activation', async () => {
    const claimSpy = spyOn(scope.clients, 'claim');

    await scope.startup(true);
    expect(claimSpy).toHaveBeenCalledTimes(1);
  });

  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);
  });

  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();
  });

  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();
  });

  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();
  });

  it('initializes the service worker on fetch if it has not yet been initialized', async () => {
    // Driver is initially uninitialized.
    expect(driver.initialized).toBeNull();
    expect(driver['latestHash']).toBeNull();

    // Making a request initializes the driver (fetches assets).
    expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
    expect(driver['latestHash']).toEqual(jasmine.any(String));
    server.assertSawRequestFor('ngsw.json');
    server.assertSawRequestFor('/foo.txt');
    server.assertSawRequestFor('/bar.txt');
    server.assertSawRequestFor('/redirected.txt');

    // Once initialized, cached resources are served without network requests.
    expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
    expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
    server.assertNoOtherRequests();
  });

  it('initializes the service worker on message if it has not yet been initialized', async () => {
    // Driver is initially uninitialized.
    expect(driver.initialized).toBeNull();
    expect(driver['latestHash']).toBeNull();

    // Pushing a message initializes the driver (fetches assets).
    await scope.handleMessage({action: 'foo'}, 'someClient');
    expect(driver['latestHash']).toEqual(jasmine.any(String));
    server.assertSawRequestFor('ngsw.json');
    server.assertSawRequestFor('/foo.txt');
    server.assertSawRequestFor('/bar.txt');
    server.assertSawRequestFor('/redirected.txt');

    // Once initialized, pushed messages are handled without re-initializing.
    await scope.handleMessage({action: 'bar'}, 'someClient');
    server.assertNoOtherRequests();

    // Once initialized, cached resources are served without network requests.
    expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
    expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
    server.assertNoOtherRequests();
  });

  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();
  });

  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');
  });

  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();
  });

  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();
  });

  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();
  });

  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');
  });

  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');
  });

  it('handles empty client ID', async () => {
    // Initialize the SW.
    expect(await makeNavigationRequest(scope, '/foo/file1', '')).toEqual('this is foo');
    expect(await makeNavigationRequest(scope, '/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 makeNavigationRequest(scope, '/foo/file1', '')).toEqual('this is foo v2');
    expect(await makeNavigationRequest(scope, '/bar/file2', null)).toEqual('this is foo v2');
  });

  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();
  });

  it('checks for updates on navigation', async () => {
    expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
    await driver.initialized;
    server.clearRequests();

    expect(await makeNavigationRequest(scope, '/foo.txt')).toEqual('this is foo');

    scope.advance(12000);
    await driver.idle.empty;

    server.assertSawRequestFor('ngsw.json');
  });

  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 makeNavigationRequest(scope, '/foo.txt')).toEqual('this is foo');

    expect(await makeNavigationRequest(scope, '/foo.txt')).toEqual('this is foo');

    scope.advance(12000);
    await driver.idle.empty;

    server.assertSawRequestFor('ngsw.json');
    server.assertNoOtherRequests();
  });

  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();
  });

  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 makeNavigationRequest(scope, '/file1')).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();
  });

  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();
  });

  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);
  });

  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',
        },
      },
    }]);
  });

  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');
  });

  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');
  });

  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();
  });

  it('should bypass serviceworker on ngsw-bypass parameter', async () => {
    await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypass': 'true'}});
    server.assertNoRequestFor('/foo.txt');

    await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypass': 'anything'}});
    server.assertNoRequestFor('/foo.txt');

    await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypass': null!}});
    server.assertNoRequestFor('/foo.txt');

    await makeRequest(scope, '/foo.txt', undefined, {headers: {'NGSW-bypass': 'upperCASE'}});
    server.assertNoRequestFor('/foo.txt');

    await makeRequest(scope, '/foo.txt', undefined, {headers: {'ngsw-bypasss': 'anything'}});
    server.assertSawRequestFor('/foo.txt');

    server.clearRequests();

    await makeRequest(scope, '/bar.txt?ngsw-bypass=true');
    server.assertNoRequestFor('/bar.txt');

    await makeRequest(scope, '/bar.txt?ngsw-bypasss=true');
    server.assertSawRequestFor('/bar.txt');

    server.clearRequests();

    await makeRequest(scope, '/bar.txt?ngsw-bypaSS=something');
    server.assertNoRequestFor('/bar.txt');

    await makeRequest(scope, '/bar.txt?testparam=test&ngsw-byPASS=anything');
    server.assertNoRequestFor('/bar.txt');

    await makeRequest(scope, '/bar.txt?testparam=test&angsw-byPASS=anything');
    server.assertSawRequestFor('/bar.txt');

    server.clearRequests();

    await makeRequest(scope, '/bar&ngsw-bypass=true.txt?testparam=test&angsw-byPASS=anything');
    server.assertSawRequestFor('/bar&ngsw-bypass=true.txt');

    server.clearRequests();

    await makeRequest(scope, '/bar&ngsw-bypass=true.txt');
    server.assertSawRequestFor('/bar&ngsw-bypass=true.txt');

    server.clearRequests();

    await makeRequest(
        scope, '/bar&ngsw-bypass=true.txt?testparam=test&ngSW-BYPASS=SOMETHING&testparam2=test');
    server.assertNoRequestFor('/bar&ngsw-bypass=true.txt');

    await makeRequest(scope, '/bar?testparam=test&ngsw-bypass');
    server.assertNoRequestFor('/bar');

    await makeRequest(scope, '/bar?testparam=test&ngsw-bypass&testparam2');
    server.assertNoRequestFor('/bar');

    await makeRequest(scope, '/bar?ngsw-bypass&testparam2');
    server.assertNoRequestFor('/bar');

    await makeRequest(scope, '/bar?ngsw-bypass=&foo=ngsw-bypass');
    server.assertNoRequestFor('/bar');

    await makeRequest(scope, '/bar?ngsw-byapass&testparam2');
    server.assertSawRequestFor('/bar');
  });

  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([]);
  });

  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([]);
  });

  it('does not unregister or change state when status code is 503 (service unavailable)',
     async () => {
       expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
       await driver.initialized;
       spyOn(server, 'fetch').and.callFake(async (req: Request) => new MockResponse(null, {
                                             status: 503,
                                             statusText: 'Service Unavailable'
                                           }));

       expect(await driver.checkForUpdate()).toEqual(false);
       expect(driver.state).toEqual(DriverReadyState.NORMAL);
       expect(scope.unregistered).toBeFalsy();
       expect(await scope.caches.keys()).not.toEqual([]);
     });

  describe('cache naming', () => {
    // Helpers
    const cacheKeysFor = (baseHref: string) =>
        [`ngsw:${baseHref}:db:control`,
         `ngsw:${baseHref}:${manifestHash}:assets:assets:cache`,
         `ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:assets:meta`,
         `ngsw:${baseHref}:${manifestHash}:assets:other:cache`,
         `ngsw:${baseHref}:db:ngsw:${baseHref}:${manifestHash}:assets:other: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}:db:ngsw:${baseHref}:42:data:dynamic:api:lru`,
         `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 getClientAssignments = async (sw: SwTestHarness, baseHref: string) => {
      const cache = await sw.caches.open(`ngsw:${baseHref}:db:control`) as unknown as MockCache;
      const dehydrated = cache.dehydrate();
      return JSON.parse(dehydrated['/assignments'].body!);
    };

    const initializeSwFor =
        async (baseHref: string, initialCacheState = '{}', serverState = server) => {
      const newScope = new SwTestHarnessBuilder(`http://localhost${baseHref}`)
                           .withCacheState(initialCacheState)
                           .withServerState(serverState)
                           .build();
      const newDriver = new Driver(newScope, newScope, new CacheDatabase(newScope, newScope));

      await makeRequest(newScope, '/foo.txt', baseHref.replace(/\//g, '_'));
      await newDriver.initialized;

      return newScope;
    };

    it('includes the SW scope in all cache names', async () => {
      // Default SW with scope `/`.
      await makeRequest(scope, '/foo.txt');
      await driver.initialized;
      const cacheNames = await scope.caches.keys();

      expect(cacheNames).toEqual(cacheKeysFor('/'));
      expect(cacheNames.every(name => name.includes('/'))).toBe(true);

      // SW with scope `/foo/`.
      const fooScope = await initializeSwFor('/foo/');
      const fooCacheNames = await fooScope.caches.keys();

      expect(fooCacheNames).toEqual(cacheKeysFor('/foo/'));
      expect(fooCacheNames.every(name => name.includes('/foo/'))).toBe(true);
    });

    it('does not affect caches from other scopes', async () => {
      // Create SW with scope `/foo/`.
      const fooScope = await initializeSwFor('/foo/');
      const fooAssignments = await getClientAssignments(fooScope, '/foo/');

      expect(fooAssignments).toEqual({_foo_: manifestHash});

      // Add new SW with different scope.
      const barScope = await initializeSwFor('/bar/', await fooScope.caches.dehydrate());
      const barCacheNames = await barScope.caches.keys();
      const barAssignments = await getClientAssignments(barScope, '/bar/');

      expect(barAssignments).toEqual({_bar_: manifestHash});
      expect(barCacheNames).toEqual([
        ...cacheKeysFor('/foo/'),
        ...cacheKeysFor('/bar/'),
      ]);

      // The caches for `/foo/` should be intact.
      const fooAssignments2 = await getClientAssignments(barScope, '/foo/');
      expect(fooAssignments2).toEqual({_foo_: manifestHash});
    });

    it('updates existing caches for same scope', async () => {
      // Create SW with scope `/foo/`.
      const fooScope = await initializeSwFor('/foo/');
      await makeRequest(fooScope, '/foo.txt', '_bar_');
      const fooAssignments = await getClientAssignments(fooScope, '/foo/');

      expect(fooAssignments).toEqual({
        _foo_: manifestHash,
        _bar_: manifestHash,
      });

      expect(await makeRequest(fooScope, '/baz.txt', '_foo_')).toBe('this is baz');
      expect(await makeRequest(fooScope, '/baz.txt', '_bar_')).toBe('this is baz');

      // Add new SW with same scope.
      const fooScope2 =
          await initializeSwFor('/foo/', await fooScope.caches.dehydrate(), serverUpdate);
      await fooScope2.handleMessage({action: 'CHECK_FOR_UPDATES'}, '_foo_');
      await fooScope2.handleMessage({action: 'ACTIVATE_UPDATE'}, '_foo_');
      const fooAssignments2 = await getClientAssignments(fooScope2, '/foo/');

      expect(fooAssignments2).toEqual({
        _foo_: manifestUpdateHash,
        _bar_: manifestHash,
      });

      // Everything should still work as expected.
      expect(await makeRequest(fooScope2, '/foo.txt', '_foo_')).toBe('this is foo v2');
      expect(await makeRequest(fooScope2, '/foo.txt', '_bar_')).toBe('this is foo');

      expect(await makeRequest(fooScope2, '/baz.txt', '_foo_')).toBe('this is baz v2');
      expect(await makeRequest(fooScope2, '/baz.txt', '_bar_')).toBe('this is baz');
    });
  });

  describe('unhashed requests', () => {
    beforeEach(async () => {
      expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
      await driver.initialized;
      server.clearRequests();
    });

    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();
    });

    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();
    });

    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();
    });

    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();
    });

    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');
    });

    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 = {}) =>
        makeNavigationRequest(scope, url, undefined, init);

    beforeEach(async () => {
      expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
      await driver.initialized;
      server.clearRequests();
    });

    it('redirects to index on a route-like request', async () => {
      expect(await navRequest('/baz')).toEqual('this is foo');
      server.assertNoOtherRequests();
    });

    it('redirects to index on a request to the origin URL request', async () => {
      expect(await navRequest('http://localhost/')).toEqual('this is foo');
      server.assertNoOtherRequests();
    });

    it('does not redirect to index on a non-navigation request', async () => {
      expect(await navRequest('/baz', {mode: undefined})).toBeNull();
      server.assertSawRequestFor('/baz');
    });

    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');
    });

    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();
    });

    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`)', () => {
      beforeEach(async () => {
        scope.updateServerState(serverUpdate);
        await driver.checkForUpdate();
        serverUpdate.clearRequests();
      });

      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();
      });

      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();
      });

      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');
      });

      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');
      });
    });
  });

  describe('cleanupOldSwCaches()', () => {
    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);
    });

    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));
    });
  });

  describe('bugs', () => {
    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 (broken)');
    });

    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);
    });

    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');
    });

    it('keeps serving api requests with freshness strategy when failing to write to cache',
       async () => {
         // Initialize the SW.
         expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
         await driver.initialized;
         server.clearRequests();

         // Make the caches unwritable.
         spyOn(MockCache.prototype, 'put').and.throwError('Can\'t touch this');
         spyOn(driver.debugger, 'log');

         expect(await makeRequest(scope, '/api/foo')).toEqual('this is api foo');
         expect(driver.state).toBe(DriverReadyState.NORMAL);
         // Since we are swallowing an error here, make sure it is at least properly logged
         expect(driver.debugger.log)
             .toHaveBeenCalledWith(
                 new Error('Can\'t touch this'),
                 'DataGroup(api@42).safeCacheResponse(/api/foo, status: 200)');
         server.assertSawRequestFor('/api/foo');
       });

    it('keeps serving api requests with performance strategy when failing to write to cache',
       async () => {
         // Initialize the SW.
         expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
         await driver.initialized;
         server.clearRequests();

         // Make the caches unwritable.
         spyOn(MockCache.prototype, 'put').and.throwError('Can\'t touch this');
         spyOn(driver.debugger, 'log');

         expect(await makeRequest(scope, '/api-static/bar')).toEqual('this is static api bar');
         expect(driver.state).toBe(DriverReadyState.NORMAL);
         // Since we are swallowing an error here, make sure it is at least properly logged
         expect(driver.debugger.log)
             .toHaveBeenCalledWith(
                 new Error('Can\'t touch this'),
                 'DataGroup(api-static@43).safeCacheResponse(/api-static/bar, status: 200)');
         server.assertSawRequestFor('/api-static/bar');
       });

    it('keeps serving mutating api requests when failing to write to cache',
       // sw can invalidate LRU cache entry and try to write to cache storage on mutating request
       async () => {
         // Initialize the SW.
         expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
         await driver.initialized;
         server.clearRequests();

         // Make the caches unwritable.
         spyOn(MockCache.prototype, 'put').and.throwError('Can\'t touch this');
         spyOn(driver.debugger, 'log');
         expect(await makeRequest(scope, '/api/foo', 'default', {
           method: 'post'
         })).toEqual('this is api foo');
         expect(driver.state).toBe(DriverReadyState.NORMAL);
         // Since we are swallowing an error here, make sure it is at least properly logged
         expect(driver.debugger.log)
             .toHaveBeenCalledWith(new Error('Can\'t touch this'), 'DataGroup(api@42).syncLru()');
         server.assertSawRequestFor('/api/foo');
       });

    it('enters degraded mode when something goes wrong with the latest version', async () => {
      await driver.initialized;

      // Two clients on initial version.
      expect(await makeRequest(scope, '/foo.txt', 'client1')).toBe('this is foo');
      expect(await makeRequest(scope, '/foo.txt', 'client2')).toBe('this is foo');

      // Install a broken version (`bar.txt` has invalid hash).
      scope.updateServerState(brokenLazyServer);
      await driver.checkForUpdate();

      // Update `client1` but not `client2`.
      await makeNavigationRequest(scope, '/', 'client1');
      server.clearRequests();
      brokenLazyServer.clearRequests();

      expect(await makeRequest(scope, '/foo.txt', 'client1')).toBe('this is foo (broken)');
      expect(await makeRequest(scope, '/foo.txt', 'client2')).toBe('this is foo');
      server.assertNoOtherRequests();
      brokenLazyServer.assertNoOtherRequests();

      // Trying to fetch `bar.txt` (which has an invalid hash) should invalidate the latest
      // version, enter degraded mode and "forget" clients that are on that version (i.e.
      // `client1`).
      expect(await makeRequest(scope, '/bar.txt', 'client1')).toBe('this is bar (broken)');
      expect(driver.state).toBe(DriverReadyState.EXISTING_CLIENTS_ONLY);
      brokenLazyServer.sawRequestFor('/bar.txt');
      brokenLazyServer.clearRequests();

      // `client1` should not be served from the network.
      expect(await makeRequest(scope, '/foo.txt', 'client1')).toBe('this is foo (broken)');
      brokenLazyServer.sawRequestFor('/foo.txt');

      // `client2` should still be served from the old version (since it never updated).
      expect(await makeRequest(scope, '/foo.txt', 'client2')).toBe('this is foo');
      server.assertNoOtherRequests();
      brokenLazyServer.assertNoOtherRequests();
    });

    it('recovers from degraded `EXISTING_CLIENTS_ONLY` mode as soon as there is a valid update',
       async () => {
         await driver.initialized;
         expect(driver.state).toBe(DriverReadyState.NORMAL);

         // Install a broken version.
         scope.updateServerState(brokenServer);
         await driver.checkForUpdate();
         expect(driver.state).toBe(DriverReadyState.EXISTING_CLIENTS_ONLY);

         // Install a good version.
         scope.updateServerState(serverUpdate);
         await driver.checkForUpdate();
         expect(driver.state).toBe(DriverReadyState.NORMAL);
       });

    it('should not enter degraded mode if manifest for latest hash is missing upon initialization',
       async () => {
         // Initialize the SW.
         scope.handleMessage({action: 'INITIALIZE'}, null);
         await driver.initialized;
         expect(driver.state).toBe(DriverReadyState.NORMAL);

         // Ensure the data has been stored in the DB.
         const db: MockCache = await scope.caches.open('ngsw:/:db:control') as any;
         const getLatestHashFromDb = async () => (await (await db.match('/latest')).json()).latest;
         expect(await getLatestHashFromDb()).toBe(manifestHash);

         // Change the latest hash to not correspond to any manifest.
         await db.put('/latest', new MockResponse('{"latest": "wrong-hash"}'));
         expect(await getLatestHashFromDb()).toBe('wrong-hash');

         // Re-initialize the SW and ensure it does not enter a degraded mode.
         driver.initialized = null;
         scope.handleMessage({action: 'INITIALIZE'}, null);
         await driver.initialized;
         expect(driver.state).toBe(DriverReadyState.NORMAL);
         expect(await getLatestHashFromDb()).toBe(manifestHash);
       });

    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();
    });

    it('ignores passive mixed content requests ', async () => {
      const scopeFetchSpy = spyOn(scope, 'fetch').and.callThrough();
      const getRequestUrls = () =>
          (scopeFetchSpy.calls.allArgs() as [Request][]).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
      it('fills 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);
         });
    });
  });
});
})();

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;
}

function makeNavigationRequest(
    scope: SwTestHarness, url: string, clientId?: string|null,
    init: Object = {}): Promise<string|null> {
  return makeRequest(scope, url, clientId, {
    headers: {
      Accept: 'text/plain, text/html, text/css',
      ...(init as any).headers,
    },
    mode: 'navigate',
    ...init,
  });
}
