From d546501ab5df6d738bede85ea949053513e02849 Mon Sep 17 00:00:00 2001 From: codingnuclei Date: Tue, 8 Jun 2021 16:59:33 +0100 Subject: [PATCH] feat(service-worker): add `openWindow`, `focusLastFocusedOrOpen` and `navigateLastFocusedOrOpen` (#42520) Add `openWindow`, `focusLastFocusedOrOpen` and `navigateLastFocusedOrOpen` abilty to the notificationclick handler such that when either a notification or notification action is clicked the service-worker can act accordinly without the need for the app to be open PR Close #26907 PR Close #42520 --- .pullapprove.yml | 1 + .../guide/service-worker-communications.md | 2 +- aio/content/guide/service-worker-intro.md | 1 + .../guide/service-worker-notifications.md | 106 +++++ aio/content/navigation.json | 5 + packages/service-worker/src/push.ts | 3 + packages/service-worker/worker/src/driver.ts | 40 ++ .../worker/src/service-worker.d.ts | 16 +- .../service-worker/worker/test/happy_spec.ts | 378 +++++++++++++++++- .../service-worker/worker/testing/scope.ts | 26 +- 10 files changed, 547 insertions(+), 31 deletions(-) create mode 100644 aio/content/guide/service-worker-notifications.md diff --git a/.pullapprove.yml b/.pullapprove.yml index 190ac38f6e..2f1d22fb80 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -617,6 +617,7 @@ groups: 'aio/content/guide/service-worker-config.md', 'aio/content/guide/service-worker-devops.md', 'aio/content/guide/service-worker-intro.md', + 'aio/content/guide/service-worker-notifications.md', 'aio/content/images/guide/service-worker/**' ]) reviewers: diff --git a/aio/content/guide/service-worker-communications.md b/aio/content/guide/service-worker-communications.md index 70cc7750be..953b42c2bc 100644 --- a/aio/content/guide/service-worker-communications.md +++ b/aio/content/guide/service-worker-communications.md @@ -95,4 +95,4 @@ You can subscribe to `SwUpdate#unrecoverable` to be notified and handle these er ## More on Angular service workers You may also be interested in the following: -* [Service Worker in Production](guide/service-worker-devops). +* [Service Worker Notifications](guide/service-worker-notifications). \ No newline at end of file diff --git a/aio/content/guide/service-worker-intro.md b/aio/content/guide/service-worker-intro.md index 65b274ac66..b3fe86ccba 100644 --- a/aio/content/guide/service-worker-intro.md +++ b/aio/content/guide/service-worker-intro.md @@ -66,6 +66,7 @@ The rest of the articles in this section specifically address the Angular implem * [App Shell](guide/app-shell) * [Service Worker Communication](guide/service-worker-communications) +* [Service Worker Notifications](guide/service-worker-notifications) * [Service Worker in Production](guide/service-worker-devops) * [Service Worker Configuration](guide/service-worker-config) diff --git a/aio/content/guide/service-worker-notifications.md b/aio/content/guide/service-worker-notifications.md new file mode 100644 index 0000000000..4575e337c6 --- /dev/null +++ b/aio/content/guide/service-worker-notifications.md @@ -0,0 +1,106 @@ +# Service worker notifications + +Push notifications are a compelling way to engage users. Through the power of service workers, notifications can be delivered to a device even when your application is not in focus. + +The Angular service worker enables the display of push notifications and the handling of notification click events. + +
+ + When using the Angular service worker, push notification interactions are handled using the `SwPush` service. + To learn more about the native APIs involved see [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) and [Using the Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API). + +
+ +#### Prerequisites + +We recommend you have a basic understanding of the following: + +- [Getting Started with Service Workers](guide/service-worker-getting-started). + +## Notification payload + +Invoke push notifications by pushing a message with a valid payload. See `SwPush` for guidance. + +
+ + In Chrome, you can test push notifications without a backend. + Open Devtools -> Application -> Service Workers and use the `Push` input to send a JSON notification payload. + +
+ +## Notification click handling + +The default behavior for the `notificationclick` event is to close the notification and notify `SwPush.notificationClicks`. + +You can specify an additional operation to be executed on `notificationclick` by adding an `onActionClick` property to the `data` object, and providing a `default` entry. This is especially useful for when there are no open clients when a notification is clicked. + +```json +{ + "notification": { + "title": "New Notification!", + "data": { + "onActionClick": { + "default": {"operation": "openWindow", "url": "foo"} + } + } + } +} +``` + +### Operations + +The Angular service worker supports the following operations: + +- `openWindow`: Opens a new tab at the specified URL, which is resolved relative to the service worker scope. + +- `focusLastFocusedOrOpen`: Focuses the last focused client. If there is no client open, then it opens a new tab at the specified URL, which is resolved relative to the service worker scope. + +- `navigateLastFocusedOrOpen`: Focuses the last focused client and navigates it to the specified URL, which is resolved relative to the service worker scope. If there is no client open, then it opens a new tab at the specified URL. + +
+ + If an `onActionClick` item does not define a `url`, then the service worker's registration scope is used. + +
+ +### Actions + +Actions offer a way to customize how the user can interact with a notification. + +Using the `actions` property, you can define a set of available actions. Each action is represented as an action button that the user can click to interact with the notification. + +In addition, using the `onActionClick` property on the `data` object, you can tie each action to an operation to be performed when the corresponding action button is clicked: + +```ts +{ + "notification": { + "title": "New Notification!", + "actions": [ + {"action": "foo", "title": "Open new tab"}, + {"action": "bar", "title": "Focus last"}, + {"action": "baz", "title": "Navigate last"}, + {"action": "qux", "title": "Just notify existing clients"} + ], + "data": { + "onActionClick": { + "default": {"operation": "openWindow"}, + "foo": {"operation": "openWindow", "url": "/absolute/path"}, + "bar": {"operation": "focusLastFocusedOrOpen", "url": "relative/path"}, + "baz": {"operation": "navigateLastFocusedOrOpen", "url": "https://other.domain.com/"} + } + } + } +} +``` + +
+ + If an action does not have a corresponding `onActionClick` entry, then the notification is closed and `SwPush.notificationClicks` is notified on existing clients. + +
+ +## More on Angular service workers + +You may also be interested in the following: + +- [Service Worker in Production](guide/service-worker-devops). diff --git a/aio/content/navigation.json b/aio/content/navigation.json index 938c05ffc2..c04a75cf01 100644 --- a/aio/content/navigation.json +++ b/aio/content/navigation.json @@ -435,6 +435,11 @@ "title": "Service Worker Communication", "tooltip": "Services that enable you to interact with an Angular service worker." }, + { + "url": "guide/service-worker-notifications", + "title": "Service Worker Notifications", + "tooltip": "Configuring service worker notification behavior." + }, { "url": "guide/service-worker-devops", "title": "Service Worker in Production", diff --git a/packages/service-worker/src/push.ts b/packages/service-worker/src/push.ts index 2be7bd443d..a9e90928d9 100644 --- a/packages/service-worker/src/push.ts +++ b/packages/service-worker/src/push.ts @@ -81,6 +81,9 @@ import {ERR_SW_NOT_SUPPORTED, NgswCommChannel, PushEvent} from './low_level'; * * + * You can read more on handling notification clicks in the [Service worker notifications + * guide](guide/service-worker-notifications). + * * @see [Push Notifications](https://developers.google.com/web/fundamentals/codelabs/push-notifications/) * @see [Angular Push Notifications](https://blog.angular-university.io/angular-push-notifications/) * @see [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts index bcecf3b002..8c1a8c93b5 100644 --- a/packages/service-worker/worker/src/driver.ts +++ b/packages/service-worker/worker/src/driver.ts @@ -348,12 +348,52 @@ export class Driver implements Debuggable, UpdateSource { NOTIFICATION_OPTION_NAMES.filter(name => name in notification) .forEach(name => options[name] = notification[name]); + const notificationAction = action === '' || action === undefined ? 'default' : action; + + const onActionClick = notification?.data?.onActionClick[notificationAction]; + + const urlToOpen = new URL(onActionClick?.url ?? '', this.scope.registration.scope).href; + + switch (onActionClick?.operation) { + case 'openWindow': + await this.scope.clients.openWindow(urlToOpen); + break; + case 'focusLastFocusedOrOpen': { + let matchingClient = await this.getLastFocusedMatchingClient(this.scope); + if (matchingClient) { + await matchingClient?.focus(); + } else { + await this.scope.clients.openWindow(urlToOpen); + } + break; + } + case 'navigateLastFocusedOrOpen': { + let matchingClient = await this.getLastFocusedMatchingClient(this.scope); + if (matchingClient) { + matchingClient = await matchingClient.navigate(urlToOpen); + await matchingClient?.focus(); + } else { + await this.scope.clients.openWindow(urlToOpen); + } + break; + } + default: + break; + } + await this.broadcast({ type: 'NOTIFICATION_CLICK', data: {action, notification: options}, }); } + private async getLastFocusedMatchingClient(scope: ServiceWorkerGlobalScope): + Promise { + const windowClients = await scope.clients.matchAll({type: 'window'}); + + // As per the spec windowClients are `sorted in the most recently focused order` + return windowClients[0]; + } private async reportStatus(client: Client, promise: Promise, nonce: number): Promise { const response = {type: 'STATUS', nonce, status: true}; try { diff --git a/packages/service-worker/worker/src/service-worker.d.ts b/packages/service-worker/worker/src/service-worker.d.ts index 71281d3679..1b2a1de010 100644 --- a/packages/service-worker/worker/src/service-worker.d.ts +++ b/packages/service-worker/worker/src/service-worker.d.ts @@ -36,9 +36,12 @@ declare class Client { } interface Clients { - claim(): Promise; + claim(): Promise; get(id: string): Promise; - matchAll(options?: ClientMatchOptions): Promise>; + matchAll( + options?: T + ): Promise>; + openWindow(url: string): Promise; } interface ClientMatchOptions { @@ -46,11 +49,12 @@ interface ClientMatchOptions { type?: ClientMatchTypes; } -interface WindowClient { - focused: boolean; - visibilityState: WindowClientState; +interface WindowClient extends Client { + readonly ancestorOrigins: ReadonlyArray; + readonly focused: boolean; + readonly visibilityState: VisibilityState; focus(): Promise; - navigate(url: string): Promise; + navigate(url: string): Promise; } type ClientFrameType = 'auxiliary'|'top-level'|'nested'|'none'; diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts index 5f191e7204..8abef97725 100644 --- a/packages/service-worker/worker/test/happy_spec.ts +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -14,7 +14,7 @@ import {sha1} from '../src/sha1'; import {clearAllCaches, MockCache} from '../testing/cache'; import {MockRequest, MockResponse} from '../testing/fetch'; import {MockFileSystem, MockFileSystemBuilder, MockServerState, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock'; -import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; +import {MockClient, SwTestHarness, SwTestHarnessBuilder, WindowClientImpl} from '../testing/scope'; (function() { // Skip environments that don't support the minimum APIs needed to run the SW tests. @@ -721,30 +721,364 @@ describe('Driver', () => { }]); }); - 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]; + describe('notification click events', () => { + 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'); - }); + 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]; + 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'); + 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'); + }); + + describe('Client interactions', () => { + describe('`openWindow` operation', () => { + it('opens a new client window at url', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + + spyOn(scope.clients, 'openWindow'); + const url = 'foo'; + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with url', + body: 'Test body with url', + data: { + onActionClick: { + foo: {operation: 'openWindow', url}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow) + .toHaveBeenCalledWith(`${scope.registration.scope}${url}`); + }); + + it('opens a new client window with `/` when no `url`', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + + spyOn(scope.clients, 'openWindow'); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test without url', + body: 'Test body without url', + data: { + onActionClick: { + foo: {operation: 'openWindow'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).toHaveBeenCalledWith(`${scope.registration.scope}`); + }); + }); + + describe('`focusLastFocusedOrOpen` operation', () => { + it('focuses last client keeping previous url', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + const mockClient = new WindowClientImpl('fooBar'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient])); + spyOn(mockClient, 'focus'); + spyOn(mockClient, 'navigate'); + const url = 'foo'; + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation focusLastFocusedOrOpen', + body: 'Test body with operation focusLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'focusLastFocusedOrOpen', url}, + }, + }, + }, + 'foo'); + expect(mockClient.navigate).not.toHaveBeenCalled(); + expect(mockClient.url).toEqual('http://localhost/unique'); + expect(mockClient.focus).toHaveBeenCalled(); + }); + + it('falls back to openWindow at url when no last client to focus', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([])); + const url = 'foo'; + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation focusLastFocusedOrOpen', + body: 'Test body with operation focusLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'focusLastFocusedOrOpen', url}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow) + .toHaveBeenCalledWith(`${scope.registration.scope}${url}`); + }); + + it('falls back to openWindow at `/` when no last client and no `url`', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([])); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation focusLastFocusedOrOpen', + body: 'Test body with operation focusLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'focusLastFocusedOrOpen'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).toHaveBeenCalledWith(`${scope.registration.scope}`); + }); + }); + + describe('`navigateLastFocusedOrOpen` operation', () => { + it('navigates last client to `url`', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + const mockClient = new WindowClientImpl('fooBar'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient])); + spyOn(mockClient, 'focus'); + spyOn(mockClient, 'navigate').and.returnValue(Promise.resolve(mockClient)); + const url = 'foo'; + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation navigateLastFocusedOrOpen', + body: 'Test body with operation navigateLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'navigateLastFocusedOrOpen', url}, + }, + }, + }, + 'foo'); + expect(mockClient.navigate).toHaveBeenCalledWith(`${scope.registration.scope}${url}`); + expect(mockClient.focus).toHaveBeenCalled(); + }); + + it('navigates last client to `/` if no `url', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + const mockClient = new WindowClientImpl('fooBar'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([mockClient])); + spyOn(mockClient, 'focus'); + spyOn(mockClient, 'navigate').and.returnValue(Promise.resolve(mockClient)); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation navigateLastFocusedOrOpen', + body: 'Test body with operation navigateLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'navigateLastFocusedOrOpen'}, + }, + }, + }, + 'foo'); + expect(mockClient.navigate).toHaveBeenCalledWith(`${scope.registration.scope}`); + expect(mockClient.focus).toHaveBeenCalled(); + }); + + it('falls back to openWindow at url when no last client to focus', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([])); + const url = 'foo'; + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation navigateLastFocusedOrOpen', + body: 'Test body with operation navigateLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'navigateLastFocusedOrOpen', url}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow) + .toHaveBeenCalledWith(`${scope.registration.scope}${url}`); + }); + + it('falls back to openWindow at `/` when no last client and no `url`', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + spyOn(scope.clients, 'matchAll').and.returnValue(Promise.resolve([])); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with operation navigateLastFocusedOrOpen', + body: 'Test body with operation navigateLastFocusedOrOpen', + data: { + onActionClick: { + foo: {operation: 'navigateLastFocusedOrOpen'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).toHaveBeenCalledWith(`${scope.registration.scope}`); + }); + }); + + describe('No matching onActionClick field', () => { + it('no client interaction', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test without onActionClick field', + body: 'Test body without onActionClick field', + data: { + onActionClick: { + fooz: {operation: 'focusLastFocusedOrOpen', url: 'fooz'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).not.toHaveBeenCalled(); + }); + }); + + describe('no action', () => { + it('uses onActionClick default when no specific action is clicked', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + const url = 'fooz'; + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test without action', + body: 'Test body without action', + data: { + onActionClick: { + default: {operation: 'openWindow', url}, + }, + }, + }, + ''); + expect(scope.clients.openWindow) + .toHaveBeenCalledWith(`${scope.registration.scope}${url}`); + }); + + describe('no onActionClick default', () => { + it('has no client interaction', async () => { + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + spyOn(scope.clients, 'openWindow'); + + await driver.initialized; + await scope.handleClick( + {title: 'This is a test without action', body: 'Test body without action'}); + expect(scope.clients.openWindow).not.toHaveBeenCalled(); + }); + }); + }); + + describe('URL resolution', () => { + it('should resolve relative to service worker scope', async () => { + (scope.registration.scope as string) = 'http://localhost/foo/bar/'; + + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + + spyOn(scope.clients, 'openWindow'); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with a relative url', + body: 'Test body with a relative url', + data: { + onActionClick: { + foo: {operation: 'openWindow', url: 'baz/qux'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).toHaveBeenCalledWith('http://localhost/foo/bar/baz/qux'); + }); + + it('should resolve with an absolute path', async () => { + (scope.registration.scope as string) = 'http://localhost/foo/bar/'; + + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + + spyOn(scope.clients, 'openWindow'); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with an absolute path url', + body: 'Test body with an absolute path url', + data: { + onActionClick: { + foo: {operation: 'openWindow', url: '/baz/qux'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).toHaveBeenCalledWith('http://localhost/baz/qux'); + }); + + it('should resolve other origins', async () => { + (scope.registration.scope as string) = 'http://localhost/foo/bar/'; + + expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo'); + + spyOn(scope.clients, 'openWindow'); + + await driver.initialized; + await scope.handleClick( + { + title: 'This is a test with external origin', + body: 'Test body with external origin', + data: { + onActionClick: { + foo: {operation: 'openWindow', url: 'http://other.host/baz/qux'}, + }, + }, + }, + 'foo'); + expect(scope.clients.openWindow).toHaveBeenCalledWith('http://other.host/baz/qux'); + }); + }); + }); }); it('prefetches updates to lazy cache when set', async () => { diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts index 96eb23d5c9..e865db5c2a 100644 --- a/packages/service-worker/worker/testing/scope.ts +++ b/packages/service-worker/worker/testing/scope.ts @@ -31,6 +31,24 @@ export class MockClient { this.queue.next(message); } } +export class WindowClientImpl extends MockClient implements WindowClient { + readonly ancestorOrigins: ReadonlyArray = []; + readonly focused: boolean = false; + readonly visibilityState: VisibilityState = 'hidden'; + frameType: ClientFrameType = 'top-level'; + url = 'http://localhost/unique'; + + constructor(readonly id: string) { + super(id); + } + + async focus(): Promise { + return this; + } + async navigate(url: string): Promise { + return this; + } +} export class SwTestHarnessBuilder { private origin = parseUrl(this.scopeUrl).origin; @@ -76,8 +94,12 @@ export class MockClients implements Clients { return this.clients.get(id); } - async matchAll(): Promise { - return Array.from(this.clients.values()) as any[] as Client[]; + async matchAll(options?: T): + Promise> { + return Array.from(this.clients.values()) as any[]; + } + async openWindow(url: string): Promise { + return null; } async claim(): Promise {}