angular/packages/service-worker/test/comm_spec.ts
Joey Perrott bd04fbc05b feat(service-worker): emit a notification when the service worker is already up to date after check (#45216)
When the service worker checks for an update and finds that the version on the server is the same as
the version locally installed, it currently noops.  This change introduces an event which it emits
in this situation which notifies clients a check has occurred without error and no update was found.

PR Close #45216
2022-03-03 12:29:04 -08:00

549 lines
20 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {PLATFORM_ID} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {NgswCommChannel, NoNewVersionDetectedEvent, VersionDetectedEvent} from '@angular/service-worker/src/low_level';
import {ngswCommChannelFactory, SwRegistrationOptions} from '@angular/service-worker/src/module';
import {SwPush} from '@angular/service-worker/src/push';
import {SwUpdate} from '@angular/service-worker/src/update';
import {MockPushManager, MockPushSubscription, MockServiceWorkerContainer, MockServiceWorkerRegistration, patchDecodeBase64} from '@angular/service-worker/testing/mock';
{
describe('ServiceWorker library', () => {
let mock: MockServiceWorkerContainer;
let comm: NgswCommChannel;
beforeEach(() => {
mock = new MockServiceWorkerContainer();
comm = new NgswCommChannel(mock as any);
});
describe('NgswCommsChannel', () => {
it('can access the registration when it comes before subscription', done => {
const mock = new MockServiceWorkerContainer();
const comm = new NgswCommChannel(mock as any);
const regPromise = mock.getRegistration() as any as MockServiceWorkerRegistration;
mock.setupSw();
(comm as any).registration.subscribe((reg: any) => {
done();
});
});
it('can access the registration when it comes after subscription', done => {
const mock = new MockServiceWorkerContainer();
const comm = new NgswCommChannel(mock as any);
const regPromise = mock.getRegistration() as any as MockServiceWorkerRegistration;
(comm as any).registration.subscribe((reg: any) => {
done();
});
mock.setupSw();
});
});
describe('ngswCommChannelFactory', () => {
it('gives disabled NgswCommChannel for platform-server', () => {
TestBed.configureTestingModule({
providers: [
{provide: PLATFORM_ID, useValue: 'server'},
{provide: SwRegistrationOptions, useValue: {enabled: true}}, {
provide: NgswCommChannel,
useFactory: ngswCommChannelFactory,
deps: [SwRegistrationOptions, PLATFORM_ID]
}
]
});
expect(TestBed.inject(NgswCommChannel).isEnabled).toEqual(false);
});
it('gives disabled NgswCommChannel when \'enabled\' option is false', () => {
TestBed.configureTestingModule({
providers: [
{provide: PLATFORM_ID, useValue: 'browser'},
{provide: SwRegistrationOptions, useValue: {enabled: false}}, {
provide: NgswCommChannel,
useFactory: ngswCommChannelFactory,
deps: [SwRegistrationOptions, PLATFORM_ID]
}
]
});
expect(TestBed.inject(NgswCommChannel).isEnabled).toEqual(false);
});
it('gives disabled NgswCommChannel when navigator.serviceWorker is undefined', () => {
TestBed.configureTestingModule({
providers: [
{provide: PLATFORM_ID, useValue: 'browser'},
{provide: SwRegistrationOptions, useValue: {enabled: true}},
{
provide: NgswCommChannel,
useFactory: ngswCommChannelFactory,
deps: [SwRegistrationOptions, PLATFORM_ID],
},
],
});
const context: any = globalThis;
const originalDescriptor = Object.getOwnPropertyDescriptor(context, 'navigator');
const patchedDescriptor = {value: {serviceWorker: undefined}, configurable: true};
try {
// Set `navigator` to `{serviceWorker: undefined}`.
Object.defineProperty(context, 'navigator', patchedDescriptor);
expect(TestBed.inject(NgswCommChannel).isEnabled).toBe(false);
} finally {
if (originalDescriptor) {
Object.defineProperty(context, 'navigator', originalDescriptor);
} else {
delete context.navigator;
}
}
});
it('gives enabled NgswCommChannel when browser supports SW and enabled option is true',
() => {
TestBed.configureTestingModule({
providers: [
{provide: PLATFORM_ID, useValue: 'browser'},
{provide: SwRegistrationOptions, useValue: {enabled: true}}, {
provide: NgswCommChannel,
useFactory: ngswCommChannelFactory,
deps: [SwRegistrationOptions, PLATFORM_ID]
}
]
});
const context: any = globalThis;
const originalDescriptor = Object.getOwnPropertyDescriptor(context, 'navigator');
const patchedDescriptor = {value: {serviceWorker: mock}, configurable: true};
try {
// Set `navigator` to `{serviceWorker: mock}`.
Object.defineProperty(context, 'navigator', patchedDescriptor);
expect(TestBed.inject(NgswCommChannel).isEnabled).toBe(true);
} finally {
if (originalDescriptor) {
Object.defineProperty(context, 'navigator', originalDescriptor);
} else {
delete context.navigator;
}
}
});
});
describe('SwPush', () => {
let unpatchDecodeBase64: () => void;
let push: SwPush;
// Patch `SwPush.decodeBase64()` in Node.js (where `atob` is not available).
beforeAll(() => unpatchDecodeBase64 = patchDecodeBase64(SwPush.prototype as any));
afterAll(() => unpatchDecodeBase64());
beforeEach(() => {
push = new SwPush(comm);
mock.setupSw();
});
it('is injectable', () => {
TestBed.configureTestingModule({
providers: [
SwPush,
{provide: NgswCommChannel, useValue: comm},
]
});
expect(() => TestBed.inject(SwPush)).not.toThrow();
});
describe('requestSubscription()', () => {
it('returns a promise that resolves to the subscription', async () => {
const promise = push.requestSubscription({serverPublicKey: 'test'});
expect(promise).toEqual(jasmine.any(Promise));
const sub = await promise;
expect(sub).toEqual(jasmine.any(MockPushSubscription));
});
it('calls `PushManager.subscribe()` (with appropriate options)', async () => {
const decode = (charCodeArr: Uint8Array) =>
Array.from(charCodeArr).map(c => String.fromCharCode(c)).join('');
// atob('c3ViamVjdHM/') === 'subjects?'
const serverPublicKey = 'c3ViamVjdHM_';
const appServerKeyStr = 'subjects?';
const pmSubscribeSpy = spyOn(MockPushManager.prototype, 'subscribe').and.callThrough();
await push.requestSubscription({serverPublicKey});
expect(pmSubscribeSpy).toHaveBeenCalledTimes(1);
expect(pmSubscribeSpy).toHaveBeenCalledWith({
applicationServerKey: jasmine.any(Uint8Array) as any,
userVisibleOnly: true,
});
const actualAppServerKey = pmSubscribeSpy.calls.first().args[0]!.applicationServerKey;
const actualAppServerKeyStr = decode(actualAppServerKey as Uint8Array);
expect(actualAppServerKeyStr).toBe(appServerKeyStr);
});
it('emits the new `PushSubscription` on `SwPush.subscription`', async () => {
const subscriptionSpy = jasmine.createSpy('subscriptionSpy');
push.subscription.subscribe(subscriptionSpy);
const sub = await push.requestSubscription({serverPublicKey: 'test'});
expect(subscriptionSpy).toHaveBeenCalledWith(sub);
});
});
describe('unsubscribe()', () => {
let psUnsubscribeSpy: jasmine.Spy;
beforeEach(() => {
psUnsubscribeSpy = spyOn(MockPushSubscription.prototype, 'unsubscribe').and.callThrough();
});
it('rejects if currently not subscribed to push notifications', async () => {
try {
await push.unsubscribe();
throw new Error('`unsubscribe()` should fail');
} catch (err) {
expect((err as Error).message).toBe('Not subscribed to push notifications.');
}
});
it('calls `PushSubscription.unsubscribe()`', async () => {
await push.requestSubscription({serverPublicKey: 'test'});
await push.unsubscribe();
expect(psUnsubscribeSpy).toHaveBeenCalledTimes(1);
});
it('rejects if `PushSubscription.unsubscribe()` fails', async () => {
psUnsubscribeSpy.and.callFake(() => {
throw new Error('foo');
});
try {
await push.requestSubscription({serverPublicKey: 'test'});
await push.unsubscribe();
throw new Error('`unsubscribe()` should fail');
} catch (err) {
expect((err as Error).message).toBe('foo');
}
});
it('rejects if `PushSubscription.unsubscribe()` returns false', async () => {
psUnsubscribeSpy.and.returnValue(Promise.resolve(false));
try {
await push.requestSubscription({serverPublicKey: 'test'});
await push.unsubscribe();
throw new Error('`unsubscribe()` should fail');
} catch (err) {
expect((err as Error).message).toBe('Unsubscribe failed!');
}
});
it('emits `null` on `SwPush.subscription`', async () => {
const subscriptionSpy = jasmine.createSpy('subscriptionSpy');
push.subscription.subscribe(subscriptionSpy);
await push.requestSubscription({serverPublicKey: 'test'});
await push.unsubscribe();
expect(subscriptionSpy).toHaveBeenCalledWith(null);
});
it('does not emit on `SwPush.subscription` on failure', async () => {
const subscriptionSpy = jasmine.createSpy('subscriptionSpy');
const initialSubEmit = new Promise(resolve => subscriptionSpy.and.callFake(resolve));
push.subscription.subscribe(subscriptionSpy);
await initialSubEmit;
subscriptionSpy.calls.reset();
// Error due to no subscription.
await push.unsubscribe().catch(() => undefined);
expect(subscriptionSpy).not.toHaveBeenCalled();
// Subscribe.
await push.requestSubscription({serverPublicKey: 'test'});
subscriptionSpy.calls.reset();
// Error due to `PushSubscription.unsubscribe()` error.
psUnsubscribeSpy.and.callFake(() => {
throw new Error('foo');
});
await push.unsubscribe().catch(() => undefined);
expect(subscriptionSpy).not.toHaveBeenCalled();
// Error due to `PushSubscription.unsubscribe()` failure.
psUnsubscribeSpy.and.returnValue(Promise.resolve(false));
await push.unsubscribe().catch(() => undefined);
expect(subscriptionSpy).not.toHaveBeenCalled();
});
});
describe('messages', () => {
it('receives push messages', () => {
const sendMessage = (type: string, message: string) =>
mock.sendMessage({type, data: {message}});
const receivedMessages: string[] = [];
push.messages.subscribe((msg: any) => receivedMessages.push(msg.message));
sendMessage('PUSH', 'this was a push message');
sendMessage('NOTPUSH', 'this was not a push message');
sendMessage('PUSH', 'this was a push message too');
sendMessage('HSUP', 'this was a HSUP message');
expect(receivedMessages).toEqual([
'this was a push message',
'this was a push message too',
]);
});
});
describe('notificationClicks', () => {
it('receives notification clicked messages', () => {
const sendMessage = (type: string, action: string) =>
mock.sendMessage({type, data: {action}});
const receivedMessages: string[] = [];
push.notificationClicks.subscribe(
(msg: {action: string}) => receivedMessages.push(msg.action));
sendMessage('NOTIFICATION_CLICK', 'this was a click');
sendMessage('NOT_IFICATION_CLICK', 'this was not a click');
sendMessage('NOTIFICATION_CLICK', 'this was a click too');
sendMessage('KCILC_NOITACIFITON', 'this was a KCILC_NOITACIFITON message');
expect(receivedMessages).toEqual([
'this was a click',
'this was a click too',
]);
});
});
describe('subscription', () => {
let nextSubEmitResolve: () => void;
let nextSubEmitPromise: Promise<void>;
let subscriptionSpy: jasmine.Spy;
beforeEach(() => {
nextSubEmitPromise = new Promise(resolve => nextSubEmitResolve = resolve);
subscriptionSpy = jasmine.createSpy('subscriptionSpy').and.callFake(() => {
nextSubEmitResolve();
nextSubEmitPromise = new Promise(resolve => nextSubEmitResolve = resolve);
});
push.subscription.subscribe(subscriptionSpy);
});
it('emits on worker-driven changes (i.e. when the controller changes)', async () => {
// Initial emit for the current `ServiceWorkerController`.
await nextSubEmitPromise;
expect(subscriptionSpy).toHaveBeenCalledTimes(1);
expect(subscriptionSpy).toHaveBeenCalledWith(null);
subscriptionSpy.calls.reset();
// Simulate a `ServiceWorkerController` change.
mock.setupSw();
await nextSubEmitPromise;
expect(subscriptionSpy).toHaveBeenCalledTimes(1);
expect(subscriptionSpy).toHaveBeenCalledWith(null);
});
it('emits on subscription changes (i.e. when subscribing/unsubscribing)', async () => {
await nextSubEmitPromise;
subscriptionSpy.calls.reset();
// Subscribe.
await push.requestSubscription({serverPublicKey: 'test'});
expect(subscriptionSpy).toHaveBeenCalledTimes(1);
expect(subscriptionSpy).toHaveBeenCalledWith(jasmine.any(MockPushSubscription));
subscriptionSpy.calls.reset();
// Subscribe again.
await push.requestSubscription({serverPublicKey: 'test'});
expect(subscriptionSpy).toHaveBeenCalledTimes(1);
expect(subscriptionSpy).toHaveBeenCalledWith(jasmine.any(MockPushSubscription));
subscriptionSpy.calls.reset();
// Unsubscribe.
await push.unsubscribe();
expect(subscriptionSpy).toHaveBeenCalledTimes(1);
expect(subscriptionSpy).toHaveBeenCalledWith(null);
});
});
describe('with no SW', () => {
beforeEach(() => {
comm = new NgswCommChannel(undefined);
push = new SwPush(comm);
});
it('does not crash on subscription to observables', () => {
push.messages.toPromise().catch(err => fail(err));
push.notificationClicks.toPromise().catch(err => fail(err));
push.subscription.toPromise().catch(err => fail(err));
});
it('gives an error when registering', done => {
push.requestSubscription({serverPublicKey: 'test'}).catch(err => {
done();
});
});
it('gives an error when unsubscribing', done => {
push.unsubscribe().catch(err => {
done();
});
});
});
});
describe('SwUpdate', () => {
let update: SwUpdate;
beforeEach(() => {
update = new SwUpdate(comm);
mock.setupSw();
});
it('processes update availability notifications when sent', done => {
update.available.subscribe(event => {
expect(event.current).toEqual({hash: 'A'});
expect(event.available).toEqual({hash: 'B'});
expect(event.type).toEqual('UPDATE_AVAILABLE');
done();
});
mock.sendMessage({
type: 'VERSION_READY',
currentVersion: {
hash: 'A',
},
latestVersion: {
hash: 'B',
},
});
});
it('processes unrecoverable notifications when sent', done => {
update.unrecoverable.subscribe(event => {
expect(event.reason).toEqual('Invalid Resource');
expect(event.type).toEqual('UNRECOVERABLE_STATE');
done();
});
mock.sendMessage({type: 'UNRECOVERABLE_STATE', reason: 'Invalid Resource'});
});
it('processes update activation notifications when sent', done => {
update.activated.subscribe(event => {
expect(event.previous).toEqual({hash: 'A'});
expect(event.current).toEqual({hash: 'B'});
expect(event.type).toEqual('UPDATE_ACTIVATED');
done();
});
mock.sendMessage({
type: 'UPDATE_ACTIVATED',
previous: {
hash: 'A',
},
current: {
hash: 'B',
},
});
});
it('processes a no new version event when sent', done => {
update.versionUpdates.subscribe(event => {
expect(event.type).toEqual('NO_NEW_VERSION_DETECTED');
expect((event as NoNewVersionDetectedEvent).version).toEqual({hash: 'A'});
done();
});
mock.sendMessage({
type: 'NO_NEW_VERSION_DETECTED',
version: {
hash: 'A',
},
});
});
it('process any version update event when sent', done => {
update.versionUpdates.subscribe(event => {
expect(event.type).toEqual('VERSION_DETECTED');
expect((event as VersionDetectedEvent).version).toEqual({hash: 'A'});
done();
});
mock.sendMessage({
type: 'VERSION_DETECTED',
version: {
hash: 'A',
},
});
});
it('activates updates when requested', async () => {
mock.messages.subscribe((msg: {action: string, nonce: number}) => {
expect(msg.action).toEqual('ACTIVATE_UPDATE');
mock.sendMessage({
type: 'OPERATION_COMPLETED',
nonce: msg.nonce,
result: true,
});
});
expect(await update.activateUpdate()).toBeTruthy();
});
it('reports activation failure when requested', async () => {
mock.messages.subscribe((msg: {action: string, nonce: number}) => {
expect(msg.action).toEqual('ACTIVATE_UPDATE');
mock.sendMessage({
type: 'OPERATION_COMPLETED',
nonce: msg.nonce,
error: 'Failed to activate',
});
});
await expectAsync(update.activateUpdate()).toBeRejectedWithError('Failed to activate');
});
it('is injectable', () => {
TestBed.configureTestingModule({
providers: [
SwUpdate,
{provide: NgswCommChannel, useValue: comm},
]
});
expect(() => TestBed.inject(SwUpdate)).not.toThrow();
});
describe('with no SW', () => {
beforeEach(() => {
comm = new NgswCommChannel(undefined);
});
it('can be instantiated', () => {
update = new SwUpdate(comm);
});
it('does not crash on subscription to observables', () => {
update = new SwUpdate(comm);
update.available.toPromise().catch(err => fail(err));
update.activated.toPromise().catch(err => fail(err));
update.unrecoverable.toPromise().catch(err => fail(err));
update.versionUpdates.toPromise().catch(err => fail(err));
});
it('gives an error when checking for updates', done => {
update = new SwUpdate(comm);
update.checkForUpdate().catch(err => {
done();
});
});
it('gives an error when activating updates', done => {
update = new SwUpdate(comm);
update.activateUpdate().catch(err => {
done();
});
});
});
});
});
}