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
This commit is contained in:
parent
9de65dbdce
commit
d546501ab5
|
@ -617,6 +617,7 @@ groups:
|
||||||
'aio/content/guide/service-worker-config.md',
|
'aio/content/guide/service-worker-config.md',
|
||||||
'aio/content/guide/service-worker-devops.md',
|
'aio/content/guide/service-worker-devops.md',
|
||||||
'aio/content/guide/service-worker-intro.md',
|
'aio/content/guide/service-worker-intro.md',
|
||||||
|
'aio/content/guide/service-worker-notifications.md',
|
||||||
'aio/content/images/guide/service-worker/**'
|
'aio/content/images/guide/service-worker/**'
|
||||||
])
|
])
|
||||||
reviewers:
|
reviewers:
|
||||||
|
|
|
@ -95,4 +95,4 @@ You can subscribe to `SwUpdate#unrecoverable` to be notified and handle these er
|
||||||
## More on Angular service workers
|
## More on Angular service workers
|
||||||
|
|
||||||
You may also be interested in the following:
|
You may also be interested in the following:
|
||||||
* [Service Worker in Production](guide/service-worker-devops).
|
* [Service Worker Notifications](guide/service-worker-notifications).
|
|
@ -66,6 +66,7 @@ The rest of the articles in this section specifically address the Angular implem
|
||||||
|
|
||||||
* [App Shell](guide/app-shell)
|
* [App Shell](guide/app-shell)
|
||||||
* [Service Worker Communication](guide/service-worker-communications)
|
* [Service Worker Communication](guide/service-worker-communications)
|
||||||
|
* [Service Worker Notifications](guide/service-worker-notifications)
|
||||||
* [Service Worker in Production](guide/service-worker-devops)
|
* [Service Worker in Production](guide/service-worker-devops)
|
||||||
* [Service Worker Configuration](guide/service-worker-config)
|
* [Service Worker Configuration](guide/service-worker-config)
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
<div class="alert is-helpful">
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
<div class="alert is-helpful">
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
<div class="alert is-important">
|
||||||
|
|
||||||
|
If an `onActionClick` item does not define a `url`, then the service worker's registration scope is used.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### 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/"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<div class="alert is-important">
|
||||||
|
|
||||||
|
If an action does not have a corresponding `onActionClick` entry, then the notification is closed and `SwPush.notificationClicks` is notified on existing clients.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## More on Angular service workers
|
||||||
|
|
||||||
|
You may also be interested in the following:
|
||||||
|
|
||||||
|
- [Service Worker in Production](guide/service-worker-devops).
|
|
@ -435,6 +435,11 @@
|
||||||
"title": "Service Worker Communication",
|
"title": "Service Worker Communication",
|
||||||
"tooltip": "Services that enable you to interact with an Angular service worker."
|
"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",
|
"url": "guide/service-worker-devops",
|
||||||
"title": "Service Worker in Production",
|
"title": "Service Worker in Production",
|
||||||
|
|
|
@ -81,6 +81,9 @@ import {ERR_SW_NOT_SUPPORTED, NgswCommChannel, PushEvent} from './low_level';
|
||||||
* <code-example path="service-worker/push/module.ts" region="subscribe-to-notification-clicks"
|
* <code-example path="service-worker/push/module.ts" region="subscribe-to-notification-clicks"
|
||||||
* header="app.component.ts"></code-example>
|
* header="app.component.ts"></code-example>
|
||||||
*
|
*
|
||||||
|
* 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 [Push Notifications](https://developers.google.com/web/fundamentals/codelabs/push-notifications/)
|
||||||
* @see [Angular Push Notifications](https://blog.angular-university.io/angular-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)
|
* @see [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
|
||||||
|
|
|
@ -348,12 +348,52 @@ export class Driver implements Debuggable, UpdateSource {
|
||||||
NOTIFICATION_OPTION_NAMES.filter(name => name in notification)
|
NOTIFICATION_OPTION_NAMES.filter(name => name in notification)
|
||||||
.forEach(name => options[name] = notification[name]);
|
.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({
|
await this.broadcast({
|
||||||
type: 'NOTIFICATION_CLICK',
|
type: 'NOTIFICATION_CLICK',
|
||||||
data: {action, notification: options},
|
data: {action, notification: options},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getLastFocusedMatchingClient(scope: ServiceWorkerGlobalScope):
|
||||||
|
Promise<WindowClient|null> {
|
||||||
|
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<void>, nonce: number): Promise<void> {
|
private async reportStatus(client: Client, promise: Promise<void>, nonce: number): Promise<void> {
|
||||||
const response = {type: 'STATUS', nonce, status: true};
|
const response = {type: 'STATUS', nonce, status: true};
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -36,9 +36,12 @@ declare class Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Clients {
|
interface Clients {
|
||||||
claim(): Promise<any>;
|
claim(): Promise<void>;
|
||||||
get(id: string): Promise<Client>;
|
get(id: string): Promise<Client>;
|
||||||
matchAll(options?: ClientMatchOptions): Promise<Array<Client>>;
|
matchAll<T extends ClientMatchOptions>(
|
||||||
|
options?: T
|
||||||
|
): Promise<ReadonlyArray<T['type'] extends 'window' ? WindowClient : Client>>;
|
||||||
|
openWindow(url: string): Promise<WindowClient | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientMatchOptions {
|
interface ClientMatchOptions {
|
||||||
|
@ -46,11 +49,12 @@ interface ClientMatchOptions {
|
||||||
type?: ClientMatchTypes;
|
type?: ClientMatchTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowClient {
|
interface WindowClient extends Client {
|
||||||
focused: boolean;
|
readonly ancestorOrigins: ReadonlyArray<string>;
|
||||||
visibilityState: WindowClientState;
|
readonly focused: boolean;
|
||||||
|
readonly visibilityState: VisibilityState;
|
||||||
focus(): Promise<WindowClient>;
|
focus(): Promise<WindowClient>;
|
||||||
navigate(url: string): Promise<WindowClient>;
|
navigate(url: string): Promise<WindowClient | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientFrameType = 'auxiliary'|'top-level'|'nested'|'none';
|
type ClientFrameType = 'auxiliary'|'top-level'|'nested'|'none';
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {sha1} from '../src/sha1';
|
||||||
import {clearAllCaches, MockCache} from '../testing/cache';
|
import {clearAllCaches, MockCache} from '../testing/cache';
|
||||||
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 {MockClient, SwTestHarness, SwTestHarnessBuilder, WindowClientImpl} from '../testing/scope';
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
||||||
|
@ -721,6 +721,7 @@ describe('Driver', () => {
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('notification click events', () => {
|
||||||
it('broadcasts notification click events with action', async () => {
|
it('broadcasts notification click events with action', async () => {
|
||||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||||
await driver.initialized;
|
await driver.initialized;
|
||||||
|
@ -737,8 +738,10 @@ describe('Driver', () => {
|
||||||
it('broadcasts notification click events without action', async () => {
|
it('broadcasts notification click events without action', async () => {
|
||||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||||
await driver.initialized;
|
await driver.initialized;
|
||||||
await scope.handleClick(
|
await scope.handleClick({
|
||||||
{title: 'This is a test without action', body: 'Test body without action'});
|
title: 'This is a test without action',
|
||||||
|
body: 'Test body without action',
|
||||||
|
});
|
||||||
const message: any = scope.clients.getMock('default')!.messages[0];
|
const message: any = scope.clients.getMock('default')!.messages[0];
|
||||||
|
|
||||||
expect(message.type).toEqual('NOTIFICATION_CLICK');
|
expect(message.type).toEqual('NOTIFICATION_CLICK');
|
||||||
|
@ -747,6 +750,337 @@ describe('Driver', () => {
|
||||||
expect(message.data.notification.body).toEqual('Test body 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 () => {
|
it('prefetches updates to lazy cache when set', async () => {
|
||||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||||
await driver.initialized;
|
await driver.initialized;
|
||||||
|
|
|
@ -31,6 +31,24 @@ export class MockClient {
|
||||||
this.queue.next(message);
|
this.queue.next(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class WindowClientImpl extends MockClient implements WindowClient {
|
||||||
|
readonly ancestorOrigins: ReadonlyArray<string> = [];
|
||||||
|
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<WindowClient> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
async navigate(url: string): Promise<WindowClient|null> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SwTestHarnessBuilder {
|
export class SwTestHarnessBuilder {
|
||||||
private origin = parseUrl(this.scopeUrl).origin;
|
private origin = parseUrl(this.scopeUrl).origin;
|
||||||
|
@ -76,8 +94,12 @@ export class MockClients implements Clients {
|
||||||
return this.clients.get(id);
|
return this.clients.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async matchAll(): Promise<Client[]> {
|
async matchAll<T extends ClientQueryOptions>(options?: T):
|
||||||
return Array.from(this.clients.values()) as any[] as Client[];
|
Promise<ReadonlyArray<T['type'] extends 'window'? WindowClient : Client>> {
|
||||||
|
return Array.from(this.clients.values()) as any[];
|
||||||
|
}
|
||||||
|
async openWindow(url: string): Promise<WindowClient|null> {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async claim(): Promise<any> {}
|
async claim(): Promise<any> {}
|
||||||
|
|
Loading…
Reference in New Issue