diff --git a/src/page/bell/Bell.test.ts b/src/page/bell/Bell.test.ts
index 90f742d3f..f2dc548f9 100644
--- a/src/page/bell/Bell.test.ts
+++ b/src/page/bell/Bell.test.ts
@@ -1,4 +1,5 @@
import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import { vi } from 'vitest';
import OneSignalEvent from '../../shared/services/OneSignalEvent';
import Bell from './Bell';
import { BellEvent, BellState } from './constants';
@@ -47,4 +48,76 @@ describe('Bell', () => {
to: BellState._Subscribed,
});
});
+
+ test('_updateState sets blocked when permission denied', async () => {
+ const bell = new Bell({ enable: false });
+ const permSpy = vi
+ .spyOn(OneSignal._context._permissionManager, '_getPermissionStatus')
+ .mockResolvedValue('denied');
+ const enabledSpy = vi
+ .spyOn(
+ OneSignal._context._subscriptionManager,
+ '_isPushNotificationsEnabled',
+ )
+ .mockResolvedValue(false);
+ bell._updateState();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(bell._blocked).toBe(true);
+ expect(permSpy).toHaveBeenCalled();
+ expect(enabledSpy).toHaveBeenCalled();
+ });
+
+ test('_setCustomColorsIfSpecified applies styles and adds CSS to head', async () => {
+ const bell = new Bell({ enable: false });
+ document.body.innerHTML = `
+
+ `;
+ bell._options.colors = {
+ 'circle.background': '#111',
+ 'circle.foreground': '#222',
+ 'badge.background': '#333',
+ 'badge.bordercolor': '#444',
+ 'badge.foreground': '#555',
+ 'dialog.button.background': '#666',
+ 'dialog.button.foreground': '#777',
+ 'dialog.button.background.hovering': '#888',
+ 'dialog.button.background.active': '#999',
+ 'pulse.color': '#abc',
+ };
+ bell._setCustomColorsIfSpecified();
+ const background = document.querySelector('.background')!;
+ expect(background.getAttribute('style')).toContain('#111');
+ const badge = document.querySelector(
+ '.onesignal-bell-launcher-badge',
+ )!;
+ expect(badge.getAttribute('style')).toContain('rgb(51, 51, 51)');
+ const styleHover = document.getElementById(
+ 'onesignal-background-hover-style',
+ );
+ expect(styleHover).not.toBeNull();
+ });
+
+ test('_addCssToHead appends once', () => {
+ const bell = new Bell({ enable: false });
+ bell._addCssToHead('x', '.a{color:red}');
+ bell._addCssToHead('x', '.b{color:blue}');
+ const style = document.getElementById('x')!;
+ expect(style.textContent).toContain('.a{color:red}');
+ });
});
diff --git a/src/page/managers/PromptsManager.test.ts b/src/page/managers/PromptsManager.test.ts
index af6a3e04b..d82aeba8c 100644
--- a/src/page/managers/PromptsManager.test.ts
+++ b/src/page/managers/PromptsManager.test.ts
@@ -1,9 +1,14 @@
import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
import { setupLoadStylesheet } from '__test__/support/helpers/setup';
+import { DelayedPromptType } from 'src/shared/prompts/constants';
+import type {
+ AppUserConfigPromptOptions,
+ DelayedPromptOptions,
+ DelayedPromptTypeValue,
+} from 'src/shared/prompts/types';
import { Browser } from 'src/shared/useragent/constants';
import * as detect from 'src/shared/useragent/detect';
import { PromptsManager } from './PromptsManager';
-
const getBrowserNameSpy = vi.spyOn(detect, 'getBrowserName');
const getBrowserVersionSpy = vi.spyOn(detect, 'getBrowserVersion');
const isMobileBrowserSpy = vi.spyOn(detect, 'isMobileBrowser');
@@ -52,4 +57,62 @@ describe('PromptsManager', () => {
await pm['_internalShowSlidedownPrompt']();
expect(installSpy).toHaveBeenCalledTimes(1);
});
+
+ test('_internalShowDelayedPrompt forces slidedown when interaction required', async () => {
+ requiresUserInteractionSpy.mockReturnValue(true);
+ const pm = new PromptsManager(OneSignal._context);
+ const nativeSpy = vi
+ .spyOn(pm, '_internalShowNativePrompt')
+ .mockResolvedValue(true);
+ const slidedownSpy = vi
+ .spyOn(
+ PromptsManager.prototype,
+ '_internalShowSlidedownPrompt' as keyof PromptsManager,
+ )
+ .mockResolvedValue(undefined as void);
+ await pm._internalShowDelayedPrompt(DelayedPromptType._Native, 0);
+ expect(nativeSpy).not.toHaveBeenCalled();
+ expect(slidedownSpy).toHaveBeenCalled();
+ });
+
+ test('_spawnAutoPrompts triggers native when condition met and not forced', async () => {
+ const pm = new PromptsManager(OneSignal._context);
+ const getOptsSpy = vi
+ .spyOn(
+ pm as unknown as {
+ _getDelayedPromptOptions: (
+ opts: AppUserConfigPromptOptions | undefined,
+ type: DelayedPromptTypeValue,
+ ) => DelayedPromptOptions;
+ },
+ '_getDelayedPromptOptions',
+ )
+ .mockImplementation(
+ (): DelayedPromptOptions => ({
+ enabled: true,
+ autoPrompt: true,
+ timeDelay: 0,
+ pageViews: 0,
+ }),
+ );
+ const condSpy = vi
+ .spyOn(
+ pm as unknown as { _isPageViewConditionMet: (o?: unknown) => boolean },
+ '_isPageViewConditionMet',
+ )
+ .mockReturnValue(true);
+ const delayedSpy = vi
+ .spyOn(
+ PromptsManager.prototype,
+ '_internalShowDelayedPrompt' as keyof PromptsManager,
+ )
+ .mockResolvedValue(undefined as void);
+ requiresUserInteractionSpy.mockReturnValue(false);
+ getBrowserNameSpy.mockReturnValue(Browser._Chrome);
+ getBrowserVersionSpy.mockReturnValue(62);
+ await pm._spawnAutoPrompts();
+ expect(getOptsSpy).toHaveBeenCalled();
+ expect(condSpy).toHaveBeenCalled();
+ expect(delayedSpy).toHaveBeenCalledWith(DelayedPromptType._Native, 0);
+ });
});
diff --git a/src/shared/managers/CustomLinkManager.test.ts b/src/shared/managers/CustomLinkManager.test.ts
new file mode 100644
index 000000000..28eb8ec89
--- /dev/null
+++ b/src/shared/managers/CustomLinkManager.test.ts
@@ -0,0 +1,105 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import { setupLoadStylesheet } from '__test__/support/helpers/setup';
+import {
+ CUSTOM_LINK_CSS_CLASSES,
+ CUSTOM_LINK_CSS_SELECTORS,
+} from 'src/shared/slidedown/constants';
+import { vi, type MockInstance } from 'vitest';
+import { ResourceLoadState } from '../../page/services/DynamicResourceLoader';
+import { CustomLinkManager } from './CustomLinkManager';
+
+describe('CustomLinkManager', () => {
+ let isPushEnabledSpy: MockInstance;
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ document.body.innerHTML = `
+
+ `;
+ isPushEnabledSpy = vi.spyOn(
+ OneSignal._context._subscriptionManager,
+ '_isPushNotificationsEnabled',
+ );
+ });
+
+ test('_initialize returns when disabled or stylesheet fails', async () => {
+ const mgrDisabled = new CustomLinkManager({ enabled: false });
+ await expect(mgrDisabled._initialize()).resolves.toBeUndefined();
+
+ // Stylesheet not loaded
+ const mgr = new CustomLinkManager({
+ enabled: true,
+ text: { explanation: 'x', subscribe: 'Sub' },
+ });
+ vi.spyOn(
+ OneSignal._context._dynamicResourceLoader,
+ '_loadSdkStylesheet',
+ ).mockResolvedValue(ResourceLoadState._Failed);
+ await mgr._initialize();
+ // nothing injected
+ const containers = document.querySelectorAll(
+ CUSTOM_LINK_CSS_SELECTORS._ContainerSelector,
+ );
+ expect(containers.length).toBe(1);
+ expect(containers[0].children.length).toBe(0);
+ });
+
+ test('_initialize hides containers when subscribed and unsubscribe disabled', async () => {
+ await setupLoadStylesheet();
+ isPushEnabledSpy.mockResolvedValue(true);
+ const mgr = new CustomLinkManager({
+ enabled: true,
+ unsubscribeEnabled: false,
+ text: {
+ explanation: 'hello',
+ subscribe: 'Subscribe',
+ unsubscribe: 'Unsubscribe',
+ },
+ });
+ await mgr._initialize();
+ const containers = document.querySelectorAll(
+ CUSTOM_LINK_CSS_SELECTORS._ContainerSelector,
+ );
+ expect(
+ containers[0].classList.contains(CUSTOM_LINK_CSS_CLASSES._Hide),
+ ).toBe(true);
+ });
+
+ test('_initialize injects markup and click toggles subscription', async () => {
+ await setupLoadStylesheet();
+ isPushEnabledSpy.mockResolvedValue(false);
+ const optInSpy = vi
+ .spyOn(OneSignal.User.PushSubscription, 'optIn')
+ .mockResolvedValue();
+ const optOutSpy = vi
+ .spyOn(OneSignal.User.PushSubscription, 'optOut')
+ .mockResolvedValue();
+ const mgr = new CustomLinkManager({
+ enabled: true,
+ unsubscribeEnabled: true,
+ text: {
+ explanation: 'hello',
+ subscribe: 'Subscribe',
+ unsubscribe: 'Unsubscribe',
+ },
+ style: 'button',
+ size: 'medium',
+ color: { text: '#fff', button: '#000' },
+ });
+ await mgr._initialize();
+ const button = document.querySelector(
+ `.${CUSTOM_LINK_CSS_CLASSES._SubscribeClass}`,
+ );
+ expect(button).not.toBeNull();
+ expect(button?.textContent).toBe('Subscribe');
+
+ await button?.click();
+ expect(optInSpy).toHaveBeenCalled();
+
+ // simulate subscribed now (set optedIn getter)
+ vi.spyOn(OneSignal.User.PushSubscription, 'optedIn', 'get').mockReturnValue(
+ true,
+ );
+ await button?.click();
+ expect(optOutSpy).toHaveBeenCalled();
+ });
+});
diff --git a/src/shared/managers/ServiceWorkerManager.test.ts b/src/shared/managers/ServiceWorkerManager.test.ts
new file mode 100644
index 000000000..ff65ef475
--- /dev/null
+++ b/src/shared/managers/ServiceWorkerManager.test.ts
@@ -0,0 +1,162 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import * as detect from 'src/shared/environment/detect';
+import * as helpers from 'src/shared/helpers/service-worker';
+import Path from 'src/shared/models/Path';
+import * as registration from 'src/sw/helpers/registration';
+import { vi, type MockInstance } from 'vitest';
+import Log from '../libraries/Log';
+import { ServiceWorkerManager } from './ServiceWorkerManager';
+
+describe('ServiceWorkerManager', () => {
+ const config = {
+ workerPath: new Path('/OneSignalSDKWorker.js'),
+ registrationOptions: { scope: '/' },
+ } as const;
+
+ let getSWRegistrationSpy: MockInstance;
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ vi.restoreAllMocks();
+ getSWRegistrationSpy = vi.spyOn(registration, 'getSWRegistration');
+ });
+
+ test('_getActiveState returns None when no registration', async () => {
+ getSWRegistrationSpy.mockResolvedValue(undefined);
+ const mgr = new ServiceWorkerManager(OneSignal._context, config);
+ await expect(mgr._getActiveState()).resolves.toBe(
+ helpers.ServiceWorkerActiveState._None,
+ );
+ });
+
+ test('_getActiveState detects OneSignal vs third-party from file name', async () => {
+ const fakeReg = {} as ServiceWorkerRegistration;
+ getSWRegistrationSpy.mockResolvedValue(fakeReg);
+ const sw = (url: string): ServiceWorker =>
+ ({
+ scriptURL: url,
+ state: 'activated',
+ onstatechange: null,
+ postMessage: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }) as unknown as ServiceWorker;
+ const swSpy = vi.spyOn(registration, 'getAvailableServiceWorker');
+ swSpy.mockReturnValue(
+ sw(new URL('https://example.com/OneSignalSDKWorker.js').toString()),
+ );
+ const mgr = new ServiceWorkerManager(OneSignal._context, config);
+ await expect(mgr._getActiveState()).resolves.toBe(
+ helpers.ServiceWorkerActiveState._OneSignalWorker,
+ );
+
+ // third-party
+ swSpy.mockReturnValue(
+ sw(new URL('https://example.com/othersw.js').toString()),
+ );
+ await expect(mgr._getActiveState()).resolves.toBe(
+ helpers.ServiceWorkerActiveState._ThirdParty,
+ );
+ });
+
+ test('_haveParamsChanged covers no registration, scope diff, missing scriptURL, href diff and same', async () => {
+ const mgr = new ServiceWorkerManager(OneSignal._context, config);
+ // no registration
+ vi.spyOn(mgr, '_getRegistration').mockResolvedValue(undefined);
+ await expect(mgr['_haveParamsChanged']()).resolves.toBe(true);
+
+ // registration with different scope
+ const regWithScope = {
+ scope: 'https://example.com/oldPath',
+ } as ServiceWorkerRegistration;
+ vi.spyOn(mgr, '_getRegistration').mockResolvedValue(regWithScope);
+ const infoSpy = vi.spyOn(Log, '_info').mockImplementation(() => undefined);
+ await expect(mgr['_haveParamsChanged']()).resolves.toBe(true);
+ expect(infoSpy).toHaveBeenCalled();
+
+ // same scope but no worker scriptURL
+ vi.spyOn(mgr, '_getRegistration').mockResolvedValue({
+ scope: 'https://example.com/',
+ } as ServiceWorkerRegistration);
+ vi.spyOn(registration, 'getAvailableServiceWorker').mockReturnValue(null);
+ await expect(mgr['_haveParamsChanged']()).resolves.toBe(true);
+
+ // different href
+ vi.mocked(registration.getAvailableServiceWorker).mockReturnValue({
+ scriptURL: 'https://example.com/old.js',
+ } as ServiceWorker);
+ vi.spyOn(helpers, 'getServiceWorkerHref').mockReturnValue(
+ 'https://example.com/new.js',
+ );
+ await expect(mgr['_haveParamsChanged']()).resolves.toBe(true);
+
+ // same href
+ vi.mocked(helpers.getServiceWorkerHref).mockReturnValue(
+ 'https://example.com/old.js',
+ );
+ await expect(mgr['_haveParamsChanged']()).resolves.toBe(false);
+ });
+
+ test('_shouldInstallWorker branches', async () => {
+ const mgr = new ServiceWorkerManager(OneSignal._context, config);
+
+ const supportsSpy = vi
+ .spyOn(detect, 'supportsServiceWorkers')
+ .mockReturnValue(false);
+ await expect(
+ (
+ mgr as unknown as { _shouldInstallWorker: () => Promise }
+ )._shouldInstallWorker(),
+ ).resolves.toBe(false);
+
+ supportsSpy.mockReturnValue(true);
+ const savedConfig = OneSignal.config;
+ OneSignal.config = null;
+ await expect(
+ (
+ mgr as unknown as { _shouldInstallWorker: () => Promise }
+ )._shouldInstallWorker(),
+ ).resolves.toBe(false);
+ OneSignal.config = savedConfig;
+
+ vi.spyOn(mgr, '_getActiveState').mockResolvedValue(
+ helpers.ServiceWorkerActiveState._None,
+ );
+ vi.spyOn(
+ OneSignal._context._permissionManager,
+ '_getNotificationPermission',
+ ).mockResolvedValue('granted');
+ await expect(
+ (
+ mgr as unknown as { _shouldInstallWorker: () => Promise }
+ )._shouldInstallWorker(),
+ ).resolves.toBe(true);
+
+ vi.spyOn(mgr, '_getActiveState').mockResolvedValue(
+ helpers.ServiceWorkerActiveState._OneSignalWorker,
+ );
+ vi.spyOn(
+ mgr as unknown as { _haveParamsChanged: () => Promise },
+ '_haveParamsChanged',
+ ).mockResolvedValue(true);
+ await expect(
+ (
+ mgr as unknown as { _shouldInstallWorker: () => Promise }
+ )._shouldInstallWorker(),
+ ).resolves.toBe(true);
+
+ vi.spyOn(
+ mgr as unknown as { _haveParamsChanged: () => Promise },
+ '_haveParamsChanged',
+ ).mockResolvedValue(false);
+ vi.spyOn(
+ mgr as unknown as { _workerNeedsUpdate: () => Promise },
+ '_workerNeedsUpdate',
+ ).mockResolvedValue(false);
+ await expect(
+ (
+ mgr as unknown as { _shouldInstallWorker: () => Promise }
+ )._shouldInstallWorker(),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/src/shared/managers/UpdateManager.test.ts b/src/shared/managers/UpdateManager.test.ts
new file mode 100644
index 000000000..a331e7bde
--- /dev/null
+++ b/src/shared/managers/UpdateManager.test.ts
@@ -0,0 +1,75 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import { SubscriptionModel } from 'src/core/models/SubscriptionModel';
+import User from 'src/onesignal/User';
+import * as pageview from 'src/shared/helpers/pageview';
+import { SessionOrigin } from 'src/shared/session/constants';
+import { NotificationType } from 'src/shared/subscriptions/constants';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { UpdateManager } from './UpdateManager';
+
+describe('UpdateManager', () => {
+ let mgr: UpdateManager;
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ vi.restoreAllMocks();
+ mgr = new UpdateManager(OneSignal._context);
+ });
+
+ test('_sendPushDeviceRecordUpdate early returns with no user, otherwise calls _sendOnSessionUpdate once', async () => {
+ // No user
+ delete User._singletonInstance;
+ const spy = vi.spyOn(mgr, '_sendOnSessionUpdate').mockResolvedValue();
+ await mgr._sendPushDeviceRecordUpdate();
+ expect(spy).not.toHaveBeenCalled();
+
+ // With user present and first call triggers onSession
+ User._createOrGetInstance();
+ await mgr._sendPushDeviceRecordUpdate();
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ test('_sendOnSessionUpdate flows: already sent, not first page, not registered, skip when unsubscribed, success path sets flag', async () => {
+ // Already sent
+ (mgr as unknown as { _onSessionSent: boolean })._onSessionSent = true;
+ await mgr._sendOnSessionUpdate();
+ // reset
+ (mgr as unknown as { _onSessionSent: boolean })._onSessionSent = false;
+
+ // Not first page
+ const firstSpy = vi
+ .spyOn(pageview, 'isFirstPageView')
+ .mockReturnValue(false);
+ await mgr._sendOnSessionUpdate();
+ firstSpy.mockReturnValue(true);
+
+ // Not registered with OneSignal
+ const alreadySpy = vi
+ .spyOn(
+ OneSignal._context._subscriptionManager,
+ '_isAlreadyRegisteredWithOneSignal',
+ )
+ .mockResolvedValue(false);
+ await mgr._sendOnSessionUpdate();
+
+ // Registered but not subscribed and enableOnSession not true -> skip
+ alreadySpy.mockResolvedValue(true);
+ const pushSpy = vi
+ .spyOn(OneSignal._coreDirector, '_getPushSubscriptionModel')
+ .mockResolvedValue({
+ _notification_types: 0,
+ } as unknown as SubscriptionModel);
+ const upsertSpy = vi
+ .spyOn(OneSignal._context._sessionManager, '_upsertSession')
+ .mockResolvedValue();
+ await mgr._sendOnSessionUpdate();
+ expect(upsertSpy).not.toHaveBeenCalled();
+
+ // Subscribed path
+ pushSpy.mockResolvedValue({
+ _notification_types: NotificationType._Subscribed,
+ id: 'sub',
+ } as unknown as SubscriptionModel);
+ await mgr._sendOnSessionUpdate();
+ expect(upsertSpy).toHaveBeenCalledWith(SessionOrigin._UserNewSession);
+ });
+});
diff --git a/src/shared/managers/sessionManager/SessionManager.test.ts b/src/shared/managers/sessionManager/SessionManager.test.ts
index 22c7bb8de..bc2555d02 100644
--- a/src/shared/managers/sessionManager/SessionManager.test.ts
+++ b/src/shared/managers/sessionManager/SessionManager.test.ts
@@ -5,8 +5,10 @@ import LoginManager from 'src/page/managers/LoginManager';
import * as detect from 'src/shared/environment/detect';
import Log from 'src/shared/libraries/Log';
import { SessionOrigin } from 'src/shared/session/constants';
+import type { SessionOriginValue } from 'src/shared/session/types';
+import { vi, type MockInstance } from 'vitest';
+import User from '../../../onesignal/User';
import { SessionManager } from './SessionManager';
-
const supportsServiceWorkersSpy = vi.spyOn(detect, 'supportsServiceWorkers');
vi.spyOn(Log, '_error').mockImplementation(() => '');
@@ -132,13 +134,30 @@ describe('SessionManager', () => {
});
describe('Core behaviors', () => {
+ let sm: SessionManager;
+ let notifySpy: MockInstance;
+ let deactSpy: MockInstance;
+
beforeEach(() => {
TestEnvironment.initialize();
+ sm = new SessionManager(OneSignal._context);
+ notifySpy = vi.spyOn(sm, '_notifySWToUpsertSession');
+ deactSpy = vi
+ .spyOn(
+ sm as unknown as {
+ _notifySWToDeactivateSession: (
+ onesignalId: string,
+ subscriptionId: string,
+ sessionOrigin: SessionOriginValue,
+ ) => Promise;
+ },
+ '_notifySWToDeactivateSession',
+ )
+ .mockResolvedValue(undefined);
});
test('_notifySWToUpsertSession posts to worker when SW supported', async () => {
supportsServiceWorkersSpy.mockReturnValue(true);
- const sm = new SessionManager(OneSignal._context);
const unicastSpy = vi
.spyOn(OneSignal._context._workerMessenger, '_unicast')
.mockResolvedValue(undefined);
@@ -153,15 +172,12 @@ describe('SessionManager', () => {
test('_upsertSession does nothing when no user is present', async () => {
supportsServiceWorkersSpy.mockReturnValue(true);
- const sm = new SessionManager(OneSignal._context);
- const notifySpy = vi.spyOn(sm, '_notifySWToUpsertSession');
await sm._upsertSession(SessionOrigin._UserCreate);
expect(notifySpy).not.toHaveBeenCalled();
});
test('_upsertSession installs listeners when SW supported', async () => {
supportsServiceWorkersSpy.mockReturnValue(true);
- const sm = new SessionManager(OneSignal._context);
const setupSpy = vi.spyOn(sm, '_setupSessionEventListeners');
await sm._upsertSession(SessionOrigin._Focus);
expect(setupSpy).toHaveBeenCalled();
@@ -169,12 +185,65 @@ describe('SessionManager', () => {
test('_upsertSession emits SESSION_STARTED when SW not supported', async () => {
supportsServiceWorkersSpy.mockReturnValue(false);
- const sm = new SessionManager(OneSignal._context);
const emitSpy = vi
.spyOn(OneSignal._emitter, '_emit')
.mockResolvedValue(OneSignal._emitter);
await sm._upsertSession(SessionOrigin._UserCreate);
expect(emitSpy).toHaveBeenCalledWith(OneSignal.EVENTS.SESSION_STARTED);
});
+
+ test('_handleVisibilityChange visible triggers upsert; hidden triggers deactivate and removes listeners', async () => {
+ // ensure user present
+ User._createOrGetInstance();
+
+ vi.spyOn(
+ sm as unknown as {
+ _getOneSignalAndSubscriptionIds: () => Promise<{
+ onesignalId: string;
+ subscriptionId: string;
+ }>;
+ },
+ '_getOneSignalAndSubscriptionIds',
+ ).mockResolvedValue({
+ onesignalId: 'o',
+ subscriptionId: 's',
+ });
+
+ // visible and focused
+ const visSpy = vi
+ .spyOn(document, 'visibilityState', 'get')
+ .mockReturnValue('visible' as DocumentVisibilityState);
+ const focusSpy = vi.spyOn(document, 'hasFocus').mockReturnValue(true);
+ notifySpy.mockResolvedValue(undefined);
+ await sm._handleVisibilityChange();
+ expect(notifySpy).toHaveBeenCalled();
+ visSpy.mockRestore();
+ focusSpy.mockRestore();
+
+ // hidden path removes listeners
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue(
+ 'hidden' as DocumentVisibilityState,
+ );
+ deactSpy.mockResolvedValue(undefined);
+ OneSignal._cache.isFocusEventSetup = true;
+ OneSignal._cache.isBlurEventSetup = true;
+ OneSignal._cache.focusHandler = () => undefined;
+ OneSignal._cache.blurHandler = () => undefined;
+ await sm._handleVisibilityChange();
+ expect(deactSpy).toHaveBeenCalled();
+ expect(OneSignal._cache.isFocusEventSetup).toBe(false);
+ expect(OneSignal._cache.isBlurEventSetup).toBe(false);
+ });
+
+ test('_handleOnFocus/Blur target guard prevents duplicate', async () => {
+ // ensure user present
+ User._createOrGetInstance();
+ notifySpy.mockResolvedValue(undefined);
+ deactSpy.mockResolvedValue(undefined);
+ await sm._handleOnFocus(new Event('focus'));
+ await sm._handleOnBlur(new Event('blur'));
+ expect(notifySpy).not.toHaveBeenCalled();
+ expect(deactSpy).not.toHaveBeenCalled();
+ });
});
});