UNPKG

onesignal-web-sdk

Version:

Web push notifications from OneSignal.

605 lines (467 loc) 24 kB
import '../../support/polyfills/polyfills'; import test from 'ava'; import sinon, { SinonSandbox, SinonStub } from 'sinon'; import nock from "nock"; import { ServiceWorkerManager} from '../../../src/managers/ServiceWorkerManager'; import { ServiceWorkerActiveState } from '../../../src/helpers/ServiceWorkerHelper'; import Path from '../../../src/models/Path'; import { BrowserUserAgent, HttpHttpsEnvironment, TestEnvironment } from '../../support/sdk/TestEnvironment'; import ServiceWorkerRegistration from '../../support/mocks/service-workers/models/ServiceWorkerRegistration'; import ServiceWorker from '../../support/mocks/service-workers/ServiceWorker'; import Context from '../../../src/models/Context'; import SdkEnvironment from "../../../src/managers/SdkEnvironment"; import { WindowEnvironmentKind } from "../../../src/models/WindowEnvironmentKind"; import OneSignal from '../../../src/OneSignal'; import Random from '../../support/tester/Random'; import { WorkerMessenger, WorkerMessengerCommand, WorkerMessengerReplyBuffer } from "../../../src/libraries/WorkerMessenger"; import Event from "../../../src/Event"; import { ServiceWorkerRegistrationError } from '../../../src/errors/ServiceWorkerRegistrationError'; import OneSignalUtils from "../../../src/utils/OneSignalUtils"; import Database from "../../../src/services/Database"; import { Subscription } from "../../../src/models/Subscription"; import { ServiceWorker as ServiceWorkerReal } from "../../../src/service-worker/ServiceWorker"; import MockNotification from "../../support/mocks/MockNotification"; import { setUserAgent } from "../../support/tester/browser"; class LocalHelpers { static getServiceWorkerManager(): ServiceWorkerManager { return new ServiceWorkerManager(OneSignal.context, { workerAPath: new Path('/Worker-A.js'), workerBPath: new Path('/Worker-B.js'), registrationOptions: { scope: '/' } }); } } // manually create and restore the sandbox let sandbox: SinonSandbox; let getRegistrationStub: SinonStub; test.beforeEach(async function() { sandbox = sinon.sandbox.create(); await TestEnvironment.stubDomEnvironment(); getRegistrationStub = sandbox.stub(navigator.serviceWorker, 'getRegistration').callThrough(); const appConfig = TestEnvironment.getFakeAppConfig(); appConfig.appId = Random.getRandomUuid(); OneSignal.context = new Context(appConfig); // global assign required for TestEnvironment.stubDomEnvironment() (global as any).OneSignal = { context: OneSignal.context }; }); test.afterEach(function () { if (getRegistrationStub.callCount > 0) sandbox.assert.alwaysCalledWithExactly(getRegistrationStub, location.href); sandbox.restore(); }); test('getActiveState() detects no installed worker', async t => { const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.None); }); test('getActiveState() detects worker A, case sensitive', async t => { await navigator.serviceWorker.register('/Worker-A.js'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); }); test('getActiveState() detects worker B, case sensitive', async t => { await navigator.serviceWorker.register('/Worker-B.js'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerB); }); test('getActiveState() detects worker A, even when worker filename uses query parameters', async t => { await navigator.serviceWorker.register('/Worker-A.js?appId=12345'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); }); test('getActiveState() detects worker B, even when worker filename uses query parameters', async t => { await navigator.serviceWorker.register('/Worker-B.js?appId=12345'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerB); }); test('getActiveState() detects an installing worker (not active)', async t => { const mockWorkerRegistration = new ServiceWorkerRegistration(); const mockInstallingWorker = new ServiceWorker(); mockInstallingWorker.state = 'installing'; mockWorkerRegistration.installing = mockInstallingWorker; getRegistrationStub.resolves(mockWorkerRegistration); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.Installing); }); test('getActiveState() detects a 3rd party worker, a worker that is activated but has an unrecognized script URL', async t => { await navigator.serviceWorker.register('/Worker-C.js'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.ThirdParty); }); test('getActiveState() detects a page loaded by hard-refresh with our service worker as bypassed', async t => { const mockWorkerRegistration = new ServiceWorkerRegistration(); const mockInstallingWorker = new ServiceWorker(); mockInstallingWorker.state = 'activated'; mockInstallingWorker.scriptURL = 'https://site.com/Worker-A.js'; mockWorkerRegistration.active = mockInstallingWorker; getRegistrationStub.resolves(mockWorkerRegistration); sandbox.stub(navigator.serviceWorker, 'controller').resolves(null); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.Bypassed); }); test('getActiveState() detects an activated third-party service worker not controlling the page as third-party and not bypassed', async t => { const mockWorkerRegistration = new ServiceWorkerRegistration(); const mockInstallingWorker = new ServiceWorker(); mockInstallingWorker.state = 'activated'; mockInstallingWorker.scriptURL = 'https://site.com/another-worker.js'; mockWorkerRegistration.active = mockInstallingWorker; getRegistrationStub.resolves(mockWorkerRegistration); sandbox.stub(navigator.serviceWorker, 'controller').resolves(null); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.ThirdParty); }); test('getActiveState() should detect Akamai akam-sw.js?othersw= when our is contain within', async t => { await navigator.serviceWorker.register('/akam-sw.js?othersw=https://domain.com/Worker-A.js?appId=12345'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); }); test('getActiveState() should detect Akamai akam-sw.js as 3rd party if no othersw=', async t => { await navigator.serviceWorker.register('/akam-sw.js?othersw=https://domain.com/someothersw.js'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.ThirdParty); }); test('getActiveState() should detect Akamai akam-sw.js as 3rd party if othersw= is not our worker', async t => { await navigator.serviceWorker.register('/akam-sw.js'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.ThirdParty); }); test('notification clicked - While page is opened in background', async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https, initOptions: { pageUrl: "https://localhost:3001/" } }); const mockInstallingWorker = new ServiceWorker(); mockInstallingWorker.state = 'activated'; mockInstallingWorker.scriptURL = 'https://site.com/Worker-A.js'; const mockWorkerRegistration = new ServiceWorkerRegistration(); mockWorkerRegistration.active = mockInstallingWorker; sandbox.stub(navigator.serviceWorker, 'controller').resolves(null); const manager = LocalHelpers.getServiceWorkerManager(); const workerMessageReplyBuffer = new WorkerMessengerReplyBuffer(); OneSignal.context.workerMessenger = new WorkerMessenger(OneSignal.context, workerMessageReplyBuffer); sandbox.stub(Event, 'trigger').callsFake(function(event: string) { if (event === OneSignal.EVENTS.NOTIFICATION_CLICKED) t.pass(); }); // Add addListenerForNotificationOpened so service worker fires event instead of storing it await OneSignal.addListenerForNotificationOpened(function () {}); manager.establishServiceWorkerChannel(); const listeners = workerMessageReplyBuffer.findListenersForMessage(WorkerMessengerCommand.NotificationClicked); for (const listenerRecord of listeners) listenerRecord.callback.apply(null, ['test']); }); /*************************************************** * onNotificationClicked() ****************************************************/ async function onNotificationClickedEnvSetup() { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); await TestEnvironment.stubServiceWorkerEnvironment(); } async function setupFakeAppId(): Promise<string> { const appConfig = TestEnvironment.getFakeAppConfig(); await Database.setAppConfig(appConfig); return appConfig.appId; } async function setupFakePlayerId(): Promise<string> { const subscription: Subscription = new Subscription(); subscription.deviceId = Random.getRandomUuid(); await OneSignal.database.setSubscription(subscription); return subscription.deviceId; } function mockNotificationNotificationEventInit(id: string): NotificationEventInit { const notificationOptions: NotificationOptions = { data: { id: id } }; const notification = new MockNotification("Title", notificationOptions); return { notification: notification }; } test('onNotificationClicked - notification click sends PUT api/v1/notification', async t => { await onNotificationClickedEnvSetup(); const appId = await setupFakeAppId(); const playerId = await setupFakePlayerId(); const notificationId = Random.getRandomUuid(); const notificationPutCall = nock("https://onesignal.com") .put(`/api/v1/notifications/${notificationId}`) .reply(200, (_uri: string, requestBody: string) => { t.deepEqual(JSON.parse(requestBody), { app_id: appId, opened: true, player_id: playerId }); return { success: true }; }); const notificationEvent = mockNotificationNotificationEventInit(notificationId); await ServiceWorkerReal.onNotificationClicked(notificationEvent); t.true(notificationPutCall.isDone()); }); test('onNotificationClicked - notification click count omitted when appId is null', async t => { await onNotificationClickedEnvSetup(); const notificationId = Random.getRandomUuid(); const notificationPutCall = nock("https://onesignal.com") .put(`/api/v1/notifications/${notificationId}`) .reply(200); const notificationEvent = mockNotificationNotificationEventInit(notificationId); await ServiceWorkerReal.onNotificationClicked(notificationEvent); t.false(notificationPutCall.isDone()); }); function addNotificationPutNock(notificationId: string) { nock("https://onesignal.com") .put(`/api/v1/notifications/${notificationId}`) .reply(200); } test('onNotificationClicked - sends webhook', async t => { await onNotificationClickedEnvSetup(); const notificationId = Random.getRandomUuid(); addNotificationPutNock(notificationId); const executeWebhooksSpy = sandbox.stub(ServiceWorkerReal, "executeWebhooks"); const notificationEvent = mockNotificationNotificationEventInit(notificationId); await ServiceWorkerReal.onNotificationClicked(notificationEvent); t.true(executeWebhooksSpy.calledWithExactly('notification.clicked', notificationEvent.notification.data)); }); test('onNotificationClicked - openWindow', async t => { await onNotificationClickedEnvSetup(); const notificationId = Random.getRandomUuid(); addNotificationPutNock(notificationId); const openWindowMock = sandbox.stub(self.clients, "openWindow"); const notificationEvent = mockNotificationNotificationEventInit(notificationId); await ServiceWorkerReal.onNotificationClicked(notificationEvent); t.true(openWindowMock.calledWithExactly('https://site.com')); }); /* Order is important on Chrome for Android when the site is added to the HomeScreen as a PWA app. - A correctly configured manifest.json file is required for it to become a PWA. We must make sure the network call is kicked off before opening a page as the ServiceWorker stops executing as soon as openWindow is called, before the onNotificationClicked function finishes. */ test('onNotificationClicked - notification PUT Before openWindow', async t => { await onNotificationClickedEnvSetup(); await setupFakeAppId(); const notificationId = Random.getRandomUuid(); const callOrder: string[] = []; sandbox.stub(self.clients, "openWindow", function() { callOrder.push("openWindow"); }); nock("https://onesignal.com") .put(`/api/v1/notifications/${notificationId}`) .reply(200, (_uri: string, _requestBody: string) => { callOrder.push("notificationPut"); return { success: true }; }); const notificationEvent = mockNotificationNotificationEventInit(notificationId); await ServiceWorkerReal.onNotificationClicked(notificationEvent); t.deepEqual(callOrder, ["notificationPut", "openWindow"]); }); test('getActiveState() returns an indeterminate status for insecure HTTP pages', async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Http }); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.Indeterminate); }); /*************************************************** * installWorker() *************************************************** */ test('installWorker() installs worker A with the correct file name and query parameter when no service worker exists', async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.None); await manager.installWorker(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); t.not(navigator.serviceWorker.controller, null); if (navigator.serviceWorker.controller !== null) { t.true(navigator.serviceWorker.controller.scriptURL.endsWith( `/Worker-A.js?appId=${OneSignal.context.appConfig.appId}`) ); } }); test('installWorker() installs worker A when a third party service worker exists', async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); await navigator.serviceWorker.register('/another-service-worker.js'); const manager = LocalHelpers.getServiceWorkerManager(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.ThirdParty); await manager.installWorker(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); }); test('installWorker() installs Worker B and then A when Worker A exists', async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); const manager = new ServiceWorkerManager(OneSignal.context, { workerAPath: new Path('/Worker-A.js'), workerBPath: new Path('/Worker-B.js'), registrationOptions: { scope: '/' } }); await manager.installWorker(); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); const spy = sandbox.spy(navigator.serviceWorker, 'register'); const appConfig = OneSignal.context.appConfig; await manager.installWorker(); const registerOptions = { scope: `${location.origin}/` }; const serviceWorkerAPath = `${location.origin}/Worker-A.js?appId=${appConfig.appId}`; const serviceWorkerBPath = `${location.origin}/Worker-B.js?appId=${appConfig.appId}`; t.true(spy.getCall(0).calledWithExactly(serviceWorkerBPath, registerOptions)); t.true(spy.getCall(1).calledWithExactly(serviceWorkerAPath, registerOptions)); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); await manager.installWorker(); t.true(spy.getCall(2).calledWithExactly(serviceWorkerBPath, registerOptions)); t.true(spy.getCall(3).calledWithExactly(serviceWorkerAPath, registerOptions)); t.is(await manager.getActiveState(), ServiceWorkerActiveState.WorkerA); t.is(spy.callCount, 4); }); test('Server worker register URL correct when service worker path is a absolute URL', async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); const manager = new ServiceWorkerManager(OneSignal.context, { workerAPath: new Path(`${location.origin}/Worker-A.js`), workerBPath: new Path(`${location.origin}/Worker-B.js`), registrationOptions: { scope: '/' } }); const serviceWorkerStub = sandbox.spy(navigator.serviceWorker, 'register'); await manager.installWorker(); sandbox.assert.alwaysCalledWithExactly(serviceWorkerStub, `${location.origin}/Worker-A.js?appId=${OneSignal.context.appConfig.appId}`, { scope: `${location.origin}/` } ); t.pass(); }); test("Service worker failed to install due to 404 on host page. Send notification to OneSignal api", async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); const context = OneSignal.context; const workerPath = "Worker-does-not-exist.js"; const manager = new ServiceWorkerManager(context, { workerAPath: new Path(workerPath), workerBPath: new Path(workerPath), registrationOptions: { scope: '/' } }); const origin = "https://onesignal.com"; nock(origin) .get(function(uri) { return uri.indexOf(workerPath) !== -1; }) .reply(404, (_uri: string, _requestBody: any) => { return { status: 404, statusText: "404 Not Found" }; }); const workerRegistrationError = new Error("Registration failed"); sandbox.stub(navigator.serviceWorker, "register").throws(workerRegistrationError); sandbox.stub(OneSignalUtils, "getBaseUrl").returns(origin); sandbox.stub(SdkEnvironment, "getWindowEnv").returns(WindowEnvironmentKind.Host); await t.throws(manager.installWorker(), ServiceWorkerRegistrationError); }); test("Service worker failed to install in popup. No handling.", async t => { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); const context = OneSignal.context; const workerPath = "Worker-does-not-exist.js"; const manager = new ServiceWorkerManager(context, { workerAPath: new Path(workerPath), workerBPath: new Path(workerPath), registrationOptions: { scope: '/' } }); const origin = "https://onesignal.com"; nock(origin) .get(function(uri) { return uri.indexOf(workerPath) !== -1; }) .reply(404, (_uri: string, _requestBody: any) => { return { status: 404, statusText: "404 Not Found" }; }); const workerRegistrationError = new Error("Registration failed"); sandbox.stub(navigator.serviceWorker, "register").throws(workerRegistrationError); sandbox.stub(location, "origin").returns(origin); sandbox.stub(SdkEnvironment, "getWindowEnv").returns(WindowEnvironmentKind.OneSignalSubscriptionPopup); const error = await t.throws(manager.installWorker(), Error); t.is(error.message, workerRegistrationError.message); }); test('ServiceWorkerManager.getRegistration() handles throws by returning null', async t => { getRegistrationStub.restore(); getRegistrationStub = sandbox.stub(navigator.serviceWorker, 'getRegistration'); getRegistrationStub.returns(new Promise(() => { throw new Error("HTTP NOT SUPPORTED"); })); const result = await ServiceWorkerManager.getRegistration(); t.is(result, null); }); /*************************************************** * displayNotification() ****************************************************/ async function displayNotificationEnvSetup() { await TestEnvironment.initialize({ httpOrHttps: HttpHttpsEnvironment.Https }); await TestEnvironment.stubServiceWorkerEnvironment(); setUserAgent(BrowserUserAgent.ChromeWindowsSupported); } // Start - displayNotification - persistNotification test('displayNotification - persistNotification - true', async t => { await displayNotificationEnvSetup(); await Database.put('Options', { key: 'persistNotification', value: true }); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, true); }); test('displayNotification - persistNotification - undefined', async t => { await displayNotificationEnvSetup(); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, true); }); test('displayNotification - persistNotification - force', async t => { await displayNotificationEnvSetup(); // "force isn't set any more but for legacy users it still results in true await Database.put('Options', { key: 'persistNotification', value: "force" }); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, true); }); test('displayNotification - persistNotification - true - Chrome macOS 10.15', async t => { await displayNotificationEnvSetup(); setUserAgent(BrowserUserAgent.ChromeMac10_15); await Database.put('Options', { key: 'persistNotification', value: true }); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, false); }); test('displayNotification - persistNotification - true - Chrome macOS pre-10.15', async t => { await displayNotificationEnvSetup(); setUserAgent(BrowserUserAgent.ChromeMacSupported); await Database.put('Options', { key: 'persistNotification', value: true }); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, true); }); test('displayNotification - persistNotification - true - Opera macOS 10.14', async t => { await displayNotificationEnvSetup(); setUserAgent(BrowserUserAgent.OperaMac10_14); await Database.put('Options', { key: 'persistNotification', value: true }); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, false); }); test('displayNotification - persistNotification - false', async t => { await displayNotificationEnvSetup(); await Database.put('Options', { key: 'persistNotification', value: false }); const showNotificationSpy = sandbox.spy(self.registration, "showNotification"); await ServiceWorkerReal.displayNotification({}); t.is(showNotificationSpy.getCall(0).args[1].requireInteraction, false); }); // End - displayNotification - persistNotification