test(service-worker): better align mock client implementations with actual implementations (#42736)

This commit better aligns the mock client implementations used in
ServiceWorker tests (and the associated typings) with the actual
implementations (and the official TypeScript typings). This allows
verifying the ServiceWorker behavior in a slightly more realistic
environment.

This is in preparation of switching from our custom typings to the
official TypeScript typings (`lib.webworker.d.ts`).

PR Close #42736
This commit is contained in:
George Kalpakas 2021-07-08 16:25:16 +03:00 committed by atscott
parent ad9085f3d6
commit 7c2f80067a
6 changed files with 104 additions and 67 deletions

View File

@ -89,7 +89,7 @@ describe('ngsw + companion lib', () => {
scope = new SwTestHarnessBuilder().withServerState(server).build(); scope = new SwTestHarnessBuilder().withServerState(server).build();
driver = new Driver(scope, scope, new CacheDatabase(scope)); driver = new Driver(scope, scope, new CacheDatabase(scope));
scope.clients.add('default'); scope.clients.add('default', scope.registration.scope);
scope.clients.getMock('default')!.queue.subscribe(msg => { scope.clients.getMock('default')!.queue.subscribe(msg => {
mock.sendMessage(msg); mock.sendMessage(msg);
}); });

View File

@ -673,7 +673,7 @@ export class Driver implements Debuggable, UpdateSource {
const client = await this.scope.clients.get(clientId); const client = await this.scope.clients.get(clientId);
await this.updateClient(client); await this.updateClient(client!);
appVersion = this.lookupVersionByHash(this.latestHash, 'assignVersion'); appVersion = this.lookupVersionByHash(this.latestHash, 'assignVersion');
} }
@ -1050,7 +1050,7 @@ export class Driver implements Debuggable, UpdateSource {
await Promise.all(affectedClients.map(async clientId => { await Promise.all(affectedClients.map(async clientId => {
const client = await this.scope.clients.get(clientId); const client = await this.scope.clients.get(clientId);
client.postMessage({type: 'UNRECOVERABLE_STATE', reason}); client!.postMessage({type: 'UNRECOVERABLE_STATE', reason});
})); }));
} }

View File

@ -29,15 +29,16 @@ interface ExtendableEvent extends Event {
// Client API // Client API
declare class Client { declare class Client {
frameType: ClientFrameType; readonly frameType: FrameType;
id: string; readonly id: string;
url: string; readonly type: ClientTypes;
postMessage(message: any): void; readonly url: string;
postMessage(message: any): void;
} }
interface Clients { interface Clients {
claim(): Promise<void>; claim(): Promise<void>;
get(id: string): Promise<Client>; get(id: string): Promise<Client | undefined>;
matchAll<T extends ClientMatchOptions>( matchAll<T extends ClientMatchOptions>(
options?: T options?: T
): Promise<ReadonlyArray<T['type'] extends 'window' ? WindowClient : Client>>; ): Promise<ReadonlyArray<T['type'] extends 'window' ? WindowClient : Client>>;
@ -46,20 +47,19 @@ interface Clients {
interface ClientMatchOptions { interface ClientMatchOptions {
includeUncontrolled?: boolean; includeUncontrolled?: boolean;
type?: ClientMatchTypes; type?: ClientTypes;
} }
interface WindowClient extends Client { interface WindowClient extends Client {
readonly ancestorOrigins: ReadonlyArray<string>;
readonly focused: boolean; readonly focused: boolean;
readonly visibilityState: VisibilityState; readonly visibilityState: VisibilityState;
focus(): Promise<WindowClient>; focus(): Promise<WindowClient>;
navigate(url: string): Promise<WindowClient | null>; navigate(url: string): Promise<WindowClient | null>;
} }
type ClientFrameType = 'auxiliary'|'top-level'|'nested'|'none'; type FrameType = 'auxiliary'|'top-level'|'nested'|'none';
type ClientMatchTypes = 'window'|'worker'|'sharedworker'|'all'; type ClientTypes = 'window'|'worker'|'sharedworker'|'all';
type WindowClientState = 'hidden'|'visible'|'prerender'|'unloaded'; type VisibilityState = 'hidden'|'visible';
// Fetch API // Fetch API

View File

@ -12,7 +12,7 @@ import {Driver, DriverReadyState} from '../src/driver';
import {AssetGroupConfig, DataGroupConfig, Manifest} from '../src/manifest'; 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 {WindowClientImpl} from '../testing/clients'; import {MockWindowClient} from '../testing/clients';
import {MockRequest, MockResponse} from '../testing/fetch'; import {MockRequest, MockResponse} from '../testing/fetch';
import {MockFileSystem, MockFileSystemBuilder, MockServerState, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock'; import {MockFileSystem, MockFileSystemBuilder, MockServerState, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
@ -766,7 +766,7 @@ describe('Driver', () => {
await driver.initialized; await driver.initialized;
await scope.handleClick( await scope.handleClick(
{title: 'This is a test with action', body: 'Test body with action'}, 'button'); {title: 'This is a test with action', body: 'Test body with action'}, 'button');
const message: any = scope.clients.getMock('default')!.messages[0]; const message = scope.clients.getMock('default')!.messages[0];
expect(message.type).toEqual('NOTIFICATION_CLICK'); expect(message.type).toEqual('NOTIFICATION_CLICK');
expect(message.data.action).toEqual('button'); expect(message.data.action).toEqual('button');
@ -781,7 +781,7 @@ describe('Driver', () => {
title: 'This is a test without action', title: 'This is a test without action',
body: 'Test body without action', body: 'Test body without action',
}); });
const message: any = scope.clients.getMock('default')!.messages[0]; const message = scope.clients.getMock('default')!.messages[0];
expect(message.type).toEqual('NOTIFICATION_CLICK'); expect(message.type).toEqual('NOTIFICATION_CLICK');
expect(message.data.action).toBe(''); expect(message.data.action).toBe('');
@ -837,12 +837,14 @@ describe('Driver', () => {
describe('`focusLastFocusedOrOpen` operation', () => { describe('`focusLastFocusedOrOpen` operation', () => {
it('focuses last client keeping previous url', async () => { it('focuses last client keeping previous url', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
const mockClient = new WindowClientImpl('fooBar');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient])); scope.clients.add('fooBar', 'http://localhost/unique', 'window');
spyOn(mockClient, 'focus'); const mockClient = scope.clients.getMock('fooBar') as MockWindowClient;
spyOn(mockClient, 'navigate');
const url = 'foo'; const url = 'foo';
expect(mockClient.url).toBe('http://localhost/unique');
expect(mockClient.focused).toBeFalse();
await driver.initialized; await driver.initialized;
await scope.handleClick( await scope.handleClick(
{ {
@ -855,9 +857,8 @@ describe('Driver', () => {
}, },
}, },
'foo'); 'foo');
expect(mockClient.navigate).not.toHaveBeenCalled(); expect(mockClient.url).toBe('http://localhost/unique');
expect(mockClient.url).toEqual('http://localhost/unique'); expect(mockClient.focused).toBeTrue();
expect(mockClient.focus).toHaveBeenCalled();
}); });
it('falls back to openWindow at url when no last client to focus', async () => { it('falls back to openWindow at url when no last client to focus', async () => {
@ -905,13 +906,15 @@ describe('Driver', () => {
describe('`navigateLastFocusedOrOpen` operation', () => { describe('`navigateLastFocusedOrOpen` operation', () => {
it('navigates last client to `url`', async () => { it('navigates last client to `url`', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo');
const mockClient = new WindowClientImpl('fooBar');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient])); scope.clients.add('fooBar', 'http://localhost/unique', 'window');
spyOn(mockClient, 'focus'); const mockClient = scope.clients.getMock('fooBar') as MockWindowClient;
spyOn(mockClient, 'navigate').and.returnValue(Promise.resolve(mockClient));
const url = 'foo'; const url = 'foo';
expect(mockClient.url).toBe('http://localhost/unique');
expect(mockClient.focused).toBeFalse();
await driver.initialized; await driver.initialized;
await scope.handleClick( await scope.handleClick(
{ {
@ -924,16 +927,18 @@ describe('Driver', () => {
}, },
}, },
'foo'); 'foo');
expect(mockClient.navigate).toHaveBeenCalledWith(`${scope.registration.scope}${url}`); expect(mockClient.url).toBe(`${scope.registration.scope}${url}`);
expect(mockClient.focus).toHaveBeenCalled(); expect(mockClient.focused).toBeTrue();
}); });
it('navigates last client to `/` if no `url', async () => { it('navigates last client to `/` if no `url`', async () => {
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); expect(await makeRequest(scope, '/foo.txt')).toBe('this is foo');
const mockClient = new WindowClientImpl('fooBar');
spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient])); scope.clients.add('fooBar', 'http://localhost/unique', 'window');
spyOn(mockClient, 'focus'); const mockClient = scope.clients.getMock('fooBar') as MockWindowClient;
spyOn(mockClient, 'navigate').and.returnValue(Promise.resolve(mockClient));
expect(mockClient.url).toBe('http://localhost/unique');
expect(mockClient.focused).toBeFalse();
await driver.initialized; await driver.initialized;
await scope.handleClick( await scope.handleClick(
@ -947,8 +952,8 @@ describe('Driver', () => {
}, },
}, },
'foo'); 'foo');
expect(mockClient.navigate).toHaveBeenCalledWith(`${scope.registration.scope}`); expect(mockClient.url).toBe(scope.registration.scope);
expect(mockClient.focus).toHaveBeenCalled(); expect(mockClient.focused).toBeTrue();
}); });
it('falls back to openWindow at url when no last client to focus', async () => { it('falls back to openWindow at url when no last client to focus', async () => {

View File

@ -9,35 +9,39 @@
import {Subject} from 'rxjs'; import {Subject} from 'rxjs';
export class MockClient { export class MockClient implements Client {
queue = new Subject<Object>(); readonly messages: any[] = [];
readonly queue = new Subject<any>();
lastFocusedAt = 0;
constructor(readonly id: string) {} constructor(
readonly id: string, readonly url: string, readonly type: ClientTypes = 'all',
readonly frameType: FrameType = 'top-level') {}
readonly messages: Object[] = []; postMessage(message: any): void {
postMessage(message: Object): void {
this.messages.push(message); this.messages.push(message);
this.queue.next(message); this.queue.next(message);
} }
} }
export class WindowClientImpl extends MockClient implements WindowClient { export class MockWindowClient extends MockClient implements WindowClient {
readonly ancestorOrigins: ReadonlyArray<string> = [];
readonly focused: boolean = false; readonly focused: boolean = false;
readonly visibilityState: VisibilityState = 'hidden'; readonly visibilityState: VisibilityState = 'visible';
frameType: ClientFrameType = 'top-level';
url = 'http://localhost/unique';
constructor(readonly id: string) { constructor(id: string, url: string, frameType: FrameType = 'top-level') {
super(id); super(id, url, 'window', frameType);
} }
async focus(): Promise<WindowClient> { async focus(): Promise<WindowClient> {
// This is only used for relatively ordering clients based on focus order, so we don't need to
// use `Adapter#time`.
this.lastFocusedAt = Date.now();
(this.focused as boolean) = true;
return this; return this;
} }
async navigate(url: string): Promise<WindowClient|null> { async navigate(url: string): Promise<WindowClient|null> {
(this.url as string) = url;
return this; return this;
} }
} }
@ -45,11 +49,20 @@ export class WindowClientImpl extends MockClient implements WindowClient {
export class MockClients implements Clients { export class MockClients implements Clients {
private clients = new Map<string, MockClient>(); private clients = new Map<string, MockClient>();
add(clientId: string): void { add(clientId: string, url: string, type: ClientTypes = 'window'): void {
if (this.clients.has(clientId)) { if (this.clients.has(clientId)) {
return; const existingClient = this.clients.get(clientId)!;
if (existingClient.url === url) {
return;
}
throw new Error(
`Trying to add mock client with same ID (${existingClient.id}) and different URL ` +
`(${existingClient.url} --> ${url})`);
} }
this.clients.set(clientId, new MockClient(clientId));
const client = (type === 'window') ? new MockWindowClient(clientId, url) :
new MockClient(clientId, url, type);
this.clients.set(clientId, client);
} }
remove(clientId: string): void { remove(clientId: string): void {
@ -57,7 +70,7 @@ export class MockClients implements Clients {
} }
async get(id: string): Promise<Client> { async get(id: string): Promise<Client> {
return this.clients.get(id)! as any as Client; return this.clients.get(id)!;
} }
getMock(id: string): MockClient|undefined { getMock(id: string): MockClient|undefined {
@ -66,7 +79,25 @@ export class MockClients implements Clients {
async matchAll<T extends ClientQueryOptions>(options?: T): async matchAll<T extends ClientQueryOptions>(options?: T):
Promise<ReadonlyArray<T['type'] extends 'window'? WindowClient : Client>> { Promise<ReadonlyArray<T['type'] extends 'window'? WindowClient : Client>> {
return Array.from(this.clients.values()) as any[]; const type = options?.type ?? 'window';
const allClients = Array.from(this.clients.values());
const matchedClients =
(type === 'all') ? allClients : allClients.filter(client => client.type === type);
// Order clients according to the [spec](https://w3c.github.io/ServiceWorker/#clients-matchall):
// In most recently focused then most recently created order, with windows clients before other
// clients.
return matchedClients
// Sort in most recently created order.
.reverse()
// Sort in most recently focused order.
.sort((a, b) => b.lastFocusedAt - a.lastFocusedAt)
// Sort windows clients before other clients (otherwise leave existing order).
.sort((a, b) => {
const aScore = (a.type === 'window') ? 1 : 0;
const bScore = (b.type === 'window') ? 1 : 0;
return bScore - aScore;
}) as any;
} }
async openWindow(url: string): Promise<WindowClient|null> { async openWindow(url: string): Promise<WindowClient|null> {

View File

@ -164,14 +164,15 @@ export class SwTestHarness extends Adapter<MockCacheStorage> implements ServiceW
} }
const isNavigation = req.mode === 'navigate'; const isNavigation = req.mode === 'navigate';
if (clientId && !this.clients.getMock(clientId)) {
this.clients.add(clientId, isNavigation ? req.url : this.scopeUrl);
}
const event = isNavigation ? new MockFetchEvent(req, '', clientId) : const event = isNavigation ? new MockFetchEvent(req, '', clientId) :
new MockFetchEvent(req, clientId, ''); new MockFetchEvent(req, clientId, '');
this.eventHandlers.get('fetch')!.call(this, event); this.eventHandlers.get('fetch')!.call(this, event);
if (clientId) {
this.clients.add(clientId);
}
return [event.response, event.ready]; return [event.response, event.ready];
} }
@ -179,15 +180,15 @@ export class SwTestHarness extends Adapter<MockCacheStorage> implements ServiceW
if (!this.eventHandlers.has('message')) { if (!this.eventHandlers.has('message')) {
throw new Error('No message handler registered'); throw new Error('No message handler registered');
} }
let event: MockExtendableMessageEvent;
if (clientId === null) { if (clientId && !this.clients.getMock(clientId)) {
event = new MockExtendableMessageEvent(data, null); this.clients.add(clientId, this.scopeUrl);
} else {
this.clients.add(clientId);
event = new MockExtendableMessageEvent(
data, this.clients.getMock(clientId) as unknown as Client || null);
} }
const event =
new MockExtendableMessageEvent(data, clientId && this.clients.getMock(clientId) || null);
this.eventHandlers.get('message')!.call(this, event); this.eventHandlers.get('message')!.call(this, event);
return event.ready; return event.ready;
} }