diff --git a/__test__/setupTests.ts b/__test__/setupTests.ts
index 1362888d6..dfd7d5f8a 100644
--- a/__test__/setupTests.ts
+++ b/__test__/setupTests.ts
@@ -60,3 +60,6 @@ export const mockJsonp = () => {
downloadSpy = vi.spyOn(OneSignalApi, 'downloadServerAppConfig');
downloadSpy.mockResolvedValue(serverConfig);
};
+
+// Polyfill Web Animations API method used by AnimatedElement
+HTMLElement.prototype.getAnimations = () => [];
diff --git a/package.json b/package.json
index b00e8f78e..51f056847 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,8 @@
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage",
"validate:build": "node build/scripts/validate.js",
- "lint": "eslint src --ext .js,.jsx,.ts,.tsx; prettylint 'src/**/*' 'test/**/*' '__test__/**/*' --no-editorconfig"
+ "lint": "eslint src --ext .js,.jsx,.ts,.tsx; prettylint 'src/**/*' 'test/**/*' '__test__/**/*' --no-editorconfig",
+ "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix; prettylint 'src/**/*' 'test/**/*' '__test__/**/*' --no-editorconfig --fix"
},
"config": {
"sdkVersion": "160510"
diff --git a/src/page/bell/Badge.test.ts b/src/page/bell/Badge.test.ts
new file mode 100644
index 000000000..79b3035a9
--- /dev/null
+++ b/src/page/bell/Badge.test.ts
@@ -0,0 +1,27 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import Badge from './Badge';
+
+describe('Badge', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ document.body.innerHTML = `
+
+ `;
+ });
+
+ test('_updateCount increments and clamps to empty when <= 0', () => {
+ const badge = new Badge();
+ // Start at empty, increment twice
+ badge._content = '';
+ badge._increment();
+ expect(badge._content).toBe('1');
+ badge._increment();
+ expect(badge._content).toBe('2');
+
+ // Decrement twice → goes to 0 and clears to empty string
+ badge._decrement();
+ expect(badge._content).toBe('1');
+ badge._decrement();
+ expect(badge._content).toBe('');
+ });
+});
diff --git a/src/page/bell/Bell.test.ts b/src/page/bell/Bell.test.ts
new file mode 100644
index 000000000..90f742d3f
--- /dev/null
+++ b/src/page/bell/Bell.test.ts
@@ -0,0 +1,50 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import OneSignalEvent from '../../shared/services/OneSignalEvent';
+import Bell from './Bell';
+import { BellEvent, BellState } from './constants';
+
+// @ts-expect-error - _installEventHooks is not assignable
+const spyInstall = vi.spyOn(Bell.prototype, '_installEventHooks');
+const updateStateSpy = vi.spyOn(Bell.prototype, '_updateState');
+describe('Bell', () => {
+ beforeEach(() => {
+ // Set up OneSignal globals/context to avoid accidental runtime lookups
+ TestEnvironment.initialize();
+ });
+
+ test('constructor early-returns when enable=false and applies defaults', () => {
+ const bell = new Bell({ enable: false });
+ expect(bell._options.size).toBe('medium');
+ expect(bell._options.position).toBe('bottom-right');
+ expect(bell._options.theme).toBe('default');
+ expect(spyInstall).not.toHaveBeenCalled();
+ expect(updateStateSpy).not.toHaveBeenCalled();
+ });
+
+ test('constructor validates and installs hooks when enable=true', () => {
+ // Valid non-defaults to ensure validation path runs
+ const bell = new Bell({
+ enable: true,
+ size: 'small',
+ position: 'bottom-left',
+ theme: 'inverse',
+ showBadgeAfter: 10,
+ showLauncherAfter: 1,
+ });
+ expect(bell).toBeTruthy();
+ expect(spyInstall).toHaveBeenCalledTimes(1);
+ expect(updateStateSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test('_setState triggers event when changed', () => {
+ const bell = new Bell({ enable: false });
+ const trigger = vi.spyOn(OneSignalEvent, '_trigger');
+ // transition should emit
+ bell._setState(BellState._Subscribed);
+
+ expect(trigger).toHaveBeenCalledWith(BellEvent._StateChanged, {
+ from: BellState._Uninitialized,
+ to: BellState._Subscribed,
+ });
+ });
+});
diff --git a/src/page/bell/Button.test.ts b/src/page/bell/Button.test.ts
new file mode 100644
index 000000000..2cf0e3543
--- /dev/null
+++ b/src/page/bell/Button.test.ts
@@ -0,0 +1,34 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import Bell from './Bell';
+import Button from './Button';
+import { MessageType } from './constants';
+
+describe('Button', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ test('_onClick concurrency guard and early-return when message showing', async () => {
+ const bell = new Bell({ enable: false });
+ const button = new Button(bell);
+
+ // Simulate message being shown of type Message by adding the show class
+ const msgEl = document.querySelector(
+ '.onesignal-bell-launcher-message',
+ ) as HTMLElement;
+ msgEl.classList.add('onesignal-bell-launcher-message-opened');
+ bell._message._contentType = MessageType._Message;
+
+ const toggleSpy = vi.spyOn(button, '_toggleDialog');
+
+ // Force concurrent scenario: set handling to true then call
+ button._isHandlingClick = false;
+ await button._onClick();
+ expect(toggleSpy).not.toHaveBeenCalled();
+ expect(button._isHandlingClick).toBe(false);
+ });
+});
diff --git a/src/page/bell/Dialog.test.ts b/src/page/bell/Dialog.test.ts
new file mode 100644
index 000000000..873746394
--- /dev/null
+++ b/src/page/bell/Dialog.test.ts
@@ -0,0 +1,48 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import type { MockInstance } from 'vitest';
+import Bell from './Bell';
+import Dialog from './Dialog';
+import { BellState } from './constants';
+
+describe('Dialog', () => {
+ let isPushNotificationsEnabledSpy: MockInstance;
+
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ document.body.innerHTML = `
+
+ `;
+ isPushNotificationsEnabledSpy = vi.spyOn(
+ OneSignal._context._subscriptionManager,
+ '_isPushNotificationsEnabled',
+ );
+ });
+
+ test('_show populates content and toggles shown flag', async () => {
+ const bell = new Bell({ enable: false });
+ // Put bell in unsubscribed state to render subscribe button
+ bell._state = BellState._Unsubscribed;
+ isPushNotificationsEnabledSpy.mockResolvedValue(false);
+
+ const dialog = new Dialog(bell);
+ expect(dialog._shown).toBe(false);
+ await dialog._show();
+ expect(dialog._shown).toBe(true);
+ // Button should be present for subscribe
+ expect(dialog._subscribeButton).not.toBeNull();
+ });
+
+ test('_hide removes shown class and keeps state consistent', async () => {
+ const bell = new Bell({ enable: false });
+ bell._state = BellState._Unsubscribed;
+ isPushNotificationsEnabledSpy.mockResolvedValue(false);
+
+ const dialog = new Dialog(bell);
+ await dialog._show();
+ expect(dialog._shown).toBe(true);
+ await dialog._hide();
+ expect(dialog._shown).toBe(false);
+ });
+});
diff --git a/src/page/bell/Launcher.test.ts b/src/page/bell/Launcher.test.ts
new file mode 100644
index 000000000..28921a332
--- /dev/null
+++ b/src/page/bell/Launcher.test.ts
@@ -0,0 +1,40 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import Bell from './Bell';
+import Launcher from './Launcher';
+
+describe('Launcher', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ document.body.innerHTML = `
+
+ `;
+ });
+
+ test('_activateIfInactive sets wasInactive and activates only when inactive', async () => {
+ const bell = new Bell({ enable: false }); // disable side-effects
+ const launcher = new Launcher(bell);
+
+ // Mark element as inactive by adding inactive class
+ launcher._element?.classList.add('onesignal-bell-launcher-inactive');
+ expect(launcher._active).toBe(false);
+ await launcher._activateIfInactive();
+ expect(launcher._wasInactive).toBe(true);
+ expect(launcher._active).toBe(true);
+
+ // Calling again should be a no-op (remains active, wasInactive unchanged)
+ await launcher._activateIfInactive();
+ expect(launcher._wasInactive).toBe(true);
+ expect(launcher._active).toBe(true);
+ });
+
+ test('_inactivateIfWasInactive only fires when previously inactive path set', async () => {
+ const bell = new Bell({ enable: false });
+ const launcher = new Launcher(bell);
+ // Mark that it was activated from inactive state
+ launcher._wasInactive = true;
+
+ await launcher._inactivateIfWasInactive();
+ expect(launcher._wasInactive).toBe(false);
+ expect(launcher._active).toBe(false);
+ });
+});
diff --git a/src/page/bell/Message.test.ts b/src/page/bell/Message.test.ts
new file mode 100644
index 000000000..aeebaca04
--- /dev/null
+++ b/src/page/bell/Message.test.ts
@@ -0,0 +1,29 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import Bell from './Bell';
+import Message from './Message';
+
+vi.useFakeTimers();
+
+describe('Message', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ test('_display shows then hides after duration and resets content type', async () => {
+ const bell = new Bell({ enable: false });
+ const message = new Message(bell);
+ const promise = message._display('message', 'Hello', 1000);
+ // advance timers to cover delay
+ await vi.runOnlyPendingTimersAsync();
+ await promise;
+ // After display finishes, contentType should reset to 'tip'
+ expect(message._contentType).toBe('tip');
+ expect(message._shown).toBe(false);
+ });
+});
diff --git a/src/page/managers/LoginManager.test.ts b/src/page/managers/LoginManager.test.ts
new file mode 100644
index 000000000..8224e7c32
--- /dev/null
+++ b/src/page/managers/LoginManager.test.ts
@@ -0,0 +1,71 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import { updateIdentityModel } from '__test__/support/helpers/setup';
+import { SubscriptionModel } from 'src/core/models/SubscriptionModel';
+import { db } from 'src/shared/database/client';
+import Log from 'src/shared/libraries/Log';
+import * as userDirector from '../../onesignal/userDirector';
+import LoginManager from './LoginManager';
+
+const createUserOnServerSpy = vi.spyOn(userDirector, 'createUserOnServer');
+
+describe('LoginManager', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ });
+
+ test('login: skips when externalId unchanged and logs debug', async () => {
+ const debugSpy = vi
+ .spyOn(Log, '_debug')
+ .mockImplementation(() => undefined);
+ await updateIdentityModel('external_id', 'same-id');
+
+ await LoginManager.login('same-id');
+ expect(debugSpy).toHaveBeenCalledWith(
+ 'Login: External ID already set, skipping login',
+ );
+ });
+
+ test('login: stores token when provided and enqueues operations', async () => {
+ const dbSpy = vi.spyOn(db, 'put');
+ // mock push subscription exists so transfer op enqueues
+ const createPushSub = () => ({
+ id: 'push-sub-id',
+ });
+ vi.spyOn(
+ OneSignal._coreDirector,
+ '_getPushSubscriptionModel',
+ ).mockResolvedValue(createPushSub() as SubscriptionModel);
+ const enqueueSpy = vi.spyOn(
+ OneSignal._coreDirector._operationRepo,
+ '_enqueue',
+ );
+ const enqueueAndWaitSpy = vi
+ .spyOn(OneSignal._coreDirector._operationRepo, '_enqueueAndWait')
+ .mockResolvedValue(undefined);
+
+ await LoginManager.login('new-external-id', 'jwt-token-123');
+ expect(dbSpy).toHaveBeenCalledWith('Ids', {
+ id: 'jwt-token-123',
+ type: 'jwtToken',
+ });
+ expect(enqueueSpy).toHaveBeenCalled();
+ expect(enqueueAndWaitSpy).toHaveBeenCalled();
+ });
+
+ test('logout: no external id logs debug and returns', async () => {
+ const debugSpy = vi
+ .spyOn(Log, '_debug')
+ .mockImplementation(() => undefined);
+ await updateIdentityModel('external_id', undefined);
+ await LoginManager.logout();
+ expect(debugSpy).toHaveBeenCalledWith(
+ 'Logout: User is not logged in, skipping logout',
+ );
+ });
+
+ test('logout: with external id resets models and creates anonymous user', async () => {
+ await updateIdentityModel('external_id', 'abc');
+ await LoginManager.logout();
+ expect(createUserOnServerSpy).toHaveBeenCalled();
+ });
+});
diff --git a/src/page/managers/PromptsManager.test.ts b/src/page/managers/PromptsManager.test.ts
new file mode 100644
index 000000000..af6a3e04b
--- /dev/null
+++ b/src/page/managers/PromptsManager.test.ts
@@ -0,0 +1,55 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import { setupLoadStylesheet } from '__test__/support/helpers/setup';
+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');
+const isTabletBrowserSpy = vi.spyOn(detect, 'isTabletBrowser');
+const requiresUserInteractionSpy = vi.spyOn(detect, 'requiresUserInteraction');
+
+describe('PromptsManager', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ });
+
+ test('_shouldForceSlidedownOverNative returns true on Chrome>=63 mobile/tablet', async () => {
+ getBrowserNameSpy.mockReturnValue(Browser._Chrome);
+ getBrowserVersionSpy.mockReturnValue(70);
+ isMobileBrowserSpy.mockReturnValue(true);
+ isTabletBrowserSpy.mockReturnValue(false);
+ requiresUserInteractionSpy.mockReturnValue(false);
+
+ const pm = new PromptsManager(OneSignal._context);
+ expect(pm['_shouldForceSlidedownOverNative']()).toBe(true);
+ });
+
+ test('_shouldForceSlidedownOverNative returns true when requiresUserInteraction', async () => {
+ getBrowserNameSpy.mockReturnValue(Browser._Firefox);
+ getBrowserVersionSpy.mockReturnValue(100);
+ isMobileBrowserSpy.mockReturnValue(false);
+ isTabletBrowserSpy.mockReturnValue(false);
+ requiresUserInteractionSpy.mockReturnValue(true);
+
+ const pm = new PromptsManager(OneSignal._context);
+ expect(pm['_shouldForceSlidedownOverNative']()).toBe(true);
+ });
+
+ test('event hooks install only once for slidedown path', async () => {
+ await setupLoadStylesheet();
+ const pm = new PromptsManager(OneSignal._context);
+
+ // stub _createSlidedown to avoid side effects
+ vi.spyOn(
+ OneSignal._context._slidedownManager,
+ '_createSlidedown',
+ ).mockResolvedValue(undefined);
+ const installSpy = vi.spyOn(pm, '_installEventHooksForSlidedown');
+
+ await pm['_internalShowSlidedownPrompt']();
+ await pm['_internalShowSlidedownPrompt']();
+ expect(installSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/page/managers/slidedownManager/SlidedownManager.test.ts b/src/page/managers/slidedownManager/SlidedownManager.test.ts
index 88163bc6c..3ce9b74fa 100644
--- a/src/page/managers/slidedownManager/SlidedownManager.test.ts
+++ b/src/page/managers/slidedownManager/SlidedownManager.test.ts
@@ -314,7 +314,7 @@ export const mockPhoneLibraryLoading = () => {
window.intlTelInputUtils = {
numberType: { MOBILE: 1 },
- // @ts-expect-error - mock intl-tel-input
+ // @ts-ignore @ts-expect-error - mock intl-tel-input
numberFormat: { E164: 0 },
};
return Promise.resolve();
diff --git a/src/page/managers/tagManager/TagManager.test.ts b/src/page/managers/tagManager/TagManager.test.ts
new file mode 100644
index 000000000..c5b2623ec
--- /dev/null
+++ b/src/page/managers/tagManager/TagManager.test.ts
@@ -0,0 +1,44 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import UserNamespace from 'src/onesignal/UserNamespace';
+import Log from 'src/shared/libraries/Log';
+import TagManager from './TagManager';
+
+const addTagsSpy = vi.spyOn(UserNamespace.prototype, 'addTags');
+describe('TagManager', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ });
+
+ test('_storeTagValuesToUpdate/_storeRemotePlayerTags set internal state', () => {
+ const tm = new TagManager(OneSignal._context);
+ tm._storeRemotePlayerTags({ a: '1' });
+ tm._storeTagValuesToUpdate({ a: true, b: false });
+ expect(OneSignal._context._tagManager._remoteTags).toEqual({ a: '1' });
+ expect(tm['_tagsFromTaggingContainer']).toEqual({
+ a: true,
+ b: false,
+ });
+ });
+
+ test('_sendTags calls addTags when diff is non-empty and returns diff', async () => {
+ const tm = new TagManager(OneSignal._context);
+ tm._storeRemotePlayerTags({ a: '1' });
+ tm._storeTagValuesToUpdate({ a: false, b: true }); // converts to { a:0, b:1 } and diff => { a:0, b:1 } vs { a:1 } -> { a:0, b:1 }
+ const result = await tm._sendTags();
+ expect(addTagsSpy).toHaveBeenCalledWith(result);
+ expect(result).toMatchObject({ a: '0', b: '1' });
+ });
+
+ test('_sendTags returns {} and warns when no change', async () => {
+ const warnSpy = vi.spyOn(Log, '_warn').mockImplementation(() => '');
+ const tm = new TagManager(OneSignal._context);
+ // Ensure this instance uses the same remote tags that will be diffed
+ tm._remoteTags = { c: '1' };
+ tm._storeTagValuesToUpdate({ c: true }); // converts to { c:'1' } -> diff {}
+
+ const result = await tm._sendTags();
+ expect(result).toEqual({});
+ expect(warnSpy).toHaveBeenCalled();
+ expect(addTagsSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/shared/context/helpers.test.ts b/src/shared/context/helpers.test.ts
new file mode 100644
index 000000000..72632b8d1
--- /dev/null
+++ b/src/shared/context/helpers.test.ts
@@ -0,0 +1,47 @@
+import { enforceAlias, enforceAppId, timeoutPromise } from './helpers';
+
+vi.useFakeTimers();
+
+describe('context/helpers', () => {
+ describe('timeoutPromise', () => {
+ test('resolves before timeout', async () => {
+ const p = new Promise((resolve) => setTimeout(() => resolve('ok'), 50));
+ const raced = timeoutPromise(p, 100);
+ vi.advanceTimersByTime(60);
+ await expect(raced).resolves.toBe('ok');
+ });
+
+ test('rejects on timeout', async () => {
+ const never = new Promise((_r) => undefined);
+ const raced = timeoutPromise(never, 10);
+ vi.advanceTimersByTime(11);
+ await expect(raced).rejects.toThrow('Async operation timed out');
+ });
+ });
+
+ describe('enforceAppId', () => {
+ test('throws on empty', () => {
+ expect(() => enforceAppId(undefined)).toThrow('App id cannot be empty');
+ expect(() => enforceAppId(null)).toThrow('App id cannot be empty');
+ });
+
+ test('does not throw on valid', () => {
+ expect(() => enforceAppId('abc')).not.toThrow();
+ });
+ });
+
+ describe('enforceAlias', () => {
+ test('throws when label or id is empty', () => {
+ expect(() => enforceAlias({ label: '', id: '' })).toThrow(
+ 'Alias label cannot be empty',
+ );
+ expect(() => enforceAlias({ label: 'x', id: '' })).toThrow(
+ 'Alias id cannot be empty',
+ );
+ });
+
+ test('does not throw on valid', () => {
+ expect(() => enforceAlias({ label: 'x', id: 'y' })).not.toThrow();
+ });
+ });
+});
diff --git a/src/shared/helpers/init.test.ts b/src/shared/helpers/init.test.ts
index 38c75260e..e4087b1e0 100644
--- a/src/shared/helpers/init.test.ts
+++ b/src/shared/helpers/init.test.ts
@@ -16,10 +16,6 @@ beforeEach(() => {
);
});
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
/** onSdkInitialized */
test('onSdkInitialized: ensure public sdk initialized triggered', async () => {
OneSignal._emitter.on(OneSignal.EVENTS.SDK_INITIALIZED_PUBLIC, () => {
diff --git a/src/shared/managers/IDManager.test.ts b/src/shared/managers/IDManager.test.ts
new file mode 100644
index 000000000..35f800e99
--- /dev/null
+++ b/src/shared/managers/IDManager.test.ts
@@ -0,0 +1,26 @@
+import { IDManager } from './IDManager';
+
+describe('IDManager', () => {
+ test('_createLocalId uses prefix and is unique-ish', () => {
+ // mock crypto.randomUUID to deterministic value
+ let i = 0;
+ const uuids = [
+ 'a-b-c-d-e',
+ 'f-g-h-i-j',
+ ] satisfies `${string}-${string}-${string}-${string}-${string}`[];
+ vi.spyOn(window.crypto, 'randomUUID').mockImplementation(() => uuids[i++]);
+
+ const a = IDManager._createLocalId();
+ const b = IDManager._createLocalId();
+ expect(a.startsWith(IDManager.LOCAL_PREFIX)).toBe(true);
+ expect(b.startsWith(IDManager.LOCAL_PREFIX)).toBe(true);
+ expect(a).not.toBe(b);
+ });
+
+ test('_isLocalId values', () => {
+ expect(IDManager._isLocalId(`${IDManager.LOCAL_PREFIX}abc`)).toBe(true);
+ expect(IDManager._isLocalId('xyz')).toBe(false);
+ expect(IDManager._isLocalId(undefined)).toBe(false);
+ expect(IDManager._isLocalId(null as unknown as string)).toBe(false);
+ });
+});
diff --git a/src/shared/managers/PermissionManager.test.ts b/src/shared/managers/PermissionManager.test.ts
new file mode 100644
index 000000000..725054352
--- /dev/null
+++ b/src/shared/managers/PermissionManager.test.ts
@@ -0,0 +1,51 @@
+import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
+import MockNotification from '__test__/support/mocks/MockNotification';
+import * as detect from '../environment/detect';
+import PermissionManager from './PermissionManager';
+
+const useSafariLegacyPushSpy = vi.spyOn(detect, 'useSafariLegacyPush');
+
+describe('PermissionManager', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ });
+
+ describe('_getPermissionStatus', () => {
+ test('resolves from context manager', async () => {
+ const pm = new PermissionManager();
+ const spy = vi
+ .spyOn(
+ OneSignal._context._permissionManager,
+ '_getNotificationPermission',
+ )
+ .mockResolvedValue('granted');
+ await expect(pm._getPermissionStatus()).resolves.toBe('granted');
+ expect(spy).toHaveBeenCalled();
+ });
+
+ test('throws if context is undefined', async () => {
+ // @ts-expect-error - testing undefined context
+ OneSignal._context = undefined;
+ const pm = new PermissionManager();
+ await expect(pm._getPermissionStatus()).rejects.toThrow(
+ 'OneSignal.context is undefined. Call init first',
+ );
+ });
+ });
+
+ test('_getNotificationPermission uses legacy Safari path and requires webId', async () => {
+ useSafariLegacyPushSpy.mockImplementation(() => true);
+ const pm = new PermissionManager();
+ await expect(pm._getNotificationPermission(undefined)).rejects.toThrow(
+ '"safariWebId" is empty',
+ );
+ });
+
+ test('_getNotificationPermission uses W3C Notification.permission when not legacy', async () => {
+ useSafariLegacyPushSpy.mockImplementation(() => false);
+ MockNotification.permission = 'default';
+
+ const pm = new PermissionManager();
+ await expect(pm['_getNotificationPermission']()).resolves?.toBe('default');
+ });
+});
diff --git a/src/shared/managers/sessionManager/SessionManager.test.ts b/src/shared/managers/sessionManager/SessionManager.test.ts
index ed9c03b20..22c7bb8de 100644
--- a/src/shared/managers/sessionManager/SessionManager.test.ts
+++ b/src/shared/managers/sessionManager/SessionManager.test.ts
@@ -2,10 +2,13 @@ import { EXTERNAL_ID } from '__test__/constants';
import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
import { setAddAliasResponse } from '__test__/support/helpers/requests';
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 { SessionManager } from './SessionManager';
+const supportsServiceWorkersSpy = vi.spyOn(detect, 'supportsServiceWorkers');
+
vi.spyOn(Log, '_error').mockImplementation(() => '');
describe('SessionManager', () => {
@@ -127,4 +130,51 @@ describe('SessionManager', () => {
expect(winner).toBe('login');
});
});
+
+ describe('Core behaviors', () => {
+ beforeEach(() => {
+ TestEnvironment.initialize();
+ });
+
+ 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);
+
+ await sm['_notifySWToUpsertSession'](
+ 'one',
+ 'sub',
+ SessionOrigin._UserCreate,
+ );
+ expect(unicastSpy).toHaveBeenCalled();
+ });
+
+ 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();
+ });
+
+ 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);
+ });
+ });
});