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