Skip to content
46 changes: 46 additions & 0 deletions __test__/support/mocks/MockCacheStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// In-memory stand-in for the Cache API, which jsdom does not provide.
// Entries keep insertion order, and `put` on an existing key moves it to the end.
export class MockCache {
entries = new Map<string, Response>();

match(key: string): Promise<Response | undefined> {
return Promise.resolve(this.entries.get(key)?.clone());
}

put(key: string, response: Response): Promise<void> {
this.entries.delete(key);
this.entries.set(key, response);
return Promise.resolve();
}

delete(key: string): Promise<boolean> {
return Promise.resolve(this.entries.delete(key));
}

keys(): Promise<string[]> {
return Promise.resolve(Array.from(this.entries.keys()));
}
}

export class MockCacheStorage {
caches = new Map<string, MockCache>();

open(name: string): Promise<MockCache> {
let cache = this.caches.get(name);
if (!cache) {
cache = new MockCache();
this.caches.set(name, cache);
}
return Promise.resolve(cache);
}
}

export const installMockCacheStorage = (): MockCacheStorage => {
const storage = new MockCacheStorage();
Object.defineProperty(globalThis, 'caches', {
value: storage,
configurable: true,
writable: true,
});
return storage;
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,12 @@
},
{
"path": "./build/releases/OneSignalSDK.page.es6.js",
"limit": "43.5 kB",
"limit": "43.6 kB",
"gzip": true
},
{
"path": "./build/releases/OneSignalSDK.sw.js",
"limit": "12.5 kB",
"limit": "13.2 kB",
"gzip": true
},
{
Expand Down
16 changes: 16 additions & 0 deletions src/shared/config/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ describe('Config Helpers', () => {
expect(config).toEqual(mergedConfig);
});

test('copies the site default icon url into the app config', () => {
const userConfig = TestContext.getFakeAppUserConfig();
const withIcon = { ...serverConfig, config: { ...serverConfig.config } };
withIcon.config.siteInfo = {
...serverConfig.config.siteInfo,
defaultIconUrl: 'https://example.com/icon.png',
};

expect(getMergedConfig(userConfig, withIcon).defaultIconUrl).toBe(
'https://example.com/icon.png',
);

withIcon.config.siteInfo = { ...serverConfig.config.siteInfo, defaultIconUrl: null };
expect(getMergedConfig(userConfig, withIcon).defaultIconUrl).toBeNull();
});

test('should limit categories to max count', () => {
const categories: TagCategory[] = [
{ tag: 'tag1', label: 'label1' },
Expand Down
1 change: 1 addition & 0 deletions src/shared/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function getMergedConfig(
appId: serverConfig.app_id,
hasUnsupportedSubdomain,
siteName: serverConfig.config.siteInfo.name,
defaultIconUrl: serverConfig.config.siteInfo.defaultIconUrl,
origin: serverConfig.config.origin,
restrictedOriginEnabled:
serverConfig.features.restrict_origin && serverConfig.features.restrict_origin.enable,
Expand Down
1 change: 1 addition & 0 deletions src/shared/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface AppConfig {
sessionThreshold?: number;

siteName: string;
defaultIconUrl: string | null;
}

export interface AppUserConfig {
Expand Down
1 change: 1 addition & 0 deletions src/shared/features/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { VERSION } from '../utils/env';

export const FeatureFlag = {
_IdentityVerification: 'sdk_identity_verification',
_InlineNotificationImages: 'sdk_web_inline_notification_images',
} as const;

export type FeatureFlagValue = (typeof FeatureFlag)[keyof typeof FeatureFlag];
Expand Down
48 changes: 47 additions & 1 deletion src/shared/helpers/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ import { TestEnvironment } from '__test__/support/environment/TestEnvironment';
import { setupSubModelStore } from '__test__/support/environment/TestEnvironmentHelpers';
import Context from 'src/page/models/Context';
import { type AppConfig } from 'src/shared/config/types';
import { beforeEach, describe, expect, test, vi, type MockInstance } from 'vite-plus/test';
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
type MockInstance,
} from 'vite-plus/test';

import * as clientModule from '../database/client';
import { db } from '../database/client';
import { getAppState } from '../database/config';
import { FeatureFlag } from '../features/featureFlags';
import * as InitHelper from './init';
import { setFeatureFlags } from './localStorage';

vi.mock('../database/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../database/client')>();
Expand Down Expand Up @@ -110,6 +120,42 @@ test('correct degree of persistNotification setting should be stored', async ()
expect(persistNotification).toBe(true);
});

describe('initSaveState: default icon', () => {
const enableInlineImages = () => setFeatureFlags(APP_ID, [FeatureFlag._InlineNotificationImages]);

afterEach(() => {
localStorage.clear();
});

test('stores the site default icon url for the worker when the flag is on', async () => {
enableInlineImages();
OneSignal.config!.defaultIconUrl = 'https://example.com/icon.png';

await InitHelper.initSaveState();

expect((await db.get('Options', 'defaultIcon'))?.value).toBe('https://example.com/icon.png');
});

test('clears a stale default icon when the app has none', async () => {
enableInlineImages();
await db.put('Options', { key: 'defaultIcon', value: 'https://example.com/old.png' });
OneSignal.config!.defaultIconUrl = null;

await InitHelper.initSaveState();

expect((await db.get('Options', 'defaultIcon'))?.value).toBeNull();
});

test('clears the default icon when the flag is off', async () => {
await db.put('Options', { key: 'defaultIcon', value: 'https://example.com/old.png' });
OneSignal.config!.defaultIconUrl = 'https://example.com/icon.png';

await InitHelper.initSaveState();

expect((await db.get('Options', 'defaultIcon'))?.value).toBeNull();
});
});

/** initSaveState – App ID migration */
describe('initSaveState: App ID migration', () => {
const OLD_APP_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
Expand Down
8 changes: 8 additions & 0 deletions src/shared/helpers/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ContextInterface } from '../context/types';
import { db, getIdsValue, isReadwriteWedged } from '../database/client';
import { getSubscription, setSubscription } from '../database/subscription';
import type { OptionKey } from '../database/types';
import { FeatureFlag, isFeatureEnabled } from '../features/featureFlags';
import Log from '../libraries/Log';
import { CustomLinkManager } from '../managers/CustomLinkManager';
import { SubscriptionStrategyKind } from '../models/SubscriptionStrategyKind';
Expand Down Expand Up @@ -368,6 +369,13 @@ export async function initSaveState(overridingPageTitle?: string) {
overridingPageTitle || config.siteName || document.title || 'Notification';
await db.put('Options', { key: 'pageTitle', value: pageTitle });
Log._info(`Set pageTitle to be '${pageTitle}'.`);
// The worker also uses this key to fill a missing icon, so it is only set
// while the inline images flag is on.
const inlineImages = isFeatureEnabled(FeatureFlag._InlineNotificationImages);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFeatureEnabled reads the previous localStorage list here, while refreshFeatureFlags is fire-and-forget from OneSignal.init and get() retries 10s/20s/30s. On the load that turns sdk_web_inline_notification_images on, this write typically stores defaultIcon: null and nothing updates it after the features response lands. The worker then cannot fill a missing icon or treat the Site Setup icon as default when image is present. The off-transition is the reverse: defaultIcon stays set after the worker mirror is cleared.

Write defaultIcon from the flag list refreshFeatureFlags actually stored, or await that refresh (including its failure path) before this put.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: refreshFeatureFlags isn't awaited (OneSignal.ts:139), so this reads last visit's localStorage list. The first load after the flag turns on writes null while the worker already inlines, so an icon on an image notification never matches and stays remote. After the flag turns off it can write the URL again. Could we always store defaultIconUrl here and gate its use in the worker instead?

await db.put('Options', {
key: 'defaultIcon',
value: inlineImages ? (config.defaultIconUrl ?? null) : null,
});
}

async function handleAutoResubscribe(isOptedOut: boolean) {
Expand Down
192 changes: 192 additions & 0 deletions src/sw/helpers/inlineImage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { installMockCacheStorage, type MockCache } from '__test__/support/mocks/MockCacheStorage';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test';

import { inlineImage } from './inlineImage';

const ICON_URL = 'https://img.example.com/icon.png';
const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const PNG_DATA_URL = `data:image/png;base64,${btoa('\x89PNG')}`;

const imageResponse = (bytes: Uint8Array<ArrayBuffer> = PNG_BYTES, type = 'image/png') =>
new Response(bytes, { status: 200, headers: { 'content-type': type } });

const fetchMock = vi.fn<typeof fetch>();

const openCache = async (): Promise<MockCache> =>
(await caches.open('onesignal-notification-images')) as unknown as MockCache;

describe('inlineImage', () => {
beforeEach(() => {
installMockCacheStorage();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockReset();
});

afterEach(() => {
vi.unstubAllGlobals();
});

test('returns an empty or data URL unchanged without a fetch', async () => {
expect(await inlineImage(undefined)).toBeUndefined();
expect(await inlineImage('')).toBe('');
expect(await inlineImage(PNG_DATA_URL)).toBe(PNG_DATA_URL);
expect(fetchMock).not.toHaveBeenCalled();
});

test('returns the fetched bytes as a data URL and caches them', async () => {
fetchMock.mockResolvedValue(imageResponse());

expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);

expect(fetchMock).toHaveBeenCalledWith(ICON_URL, { signal: expect.any(AbortSignal) });
const cached = await (await openCache()).match(ICON_URL);
expect(new Uint8Array(await cached!.arrayBuffer())).toEqual(PNG_BYTES);
expect(cached!.headers.get('content-type')).toBe('image/png');
});

test('drops content-type parameters from the data URL', async () => {
fetchMock.mockResolvedValue(imageResponse(PNG_BYTES, 'image/webp; charset=binary'));

expect(await inlineImage(ICON_URL)).toBe(`data:image/webp;base64,${btoa('\x89PNG')}`);
});

test('accepts an upper-case content-type', async () => {
fetchMock.mockResolvedValue(imageResponse(PNG_BYTES, 'IMAGE/PNG'));

expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);
});

test('returns the remote URL when the response has no content-type and caches nothing', async () => {
fetchMock.mockResolvedValue(new Response(PNG_BYTES, { status: 200 }));

expect(await inlineImage(ICON_URL)).toBe(ICON_URL);
expect((await openCache()).entries.size).toBe(0);
});

test('returns the remote URL for a non-image content type and caches nothing', async () => {
const cancel = vi.fn();
fetchMock.mockResolvedValue(
new Response(new ReadableStream({ cancel }), {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8' },
}),
);

expect(await inlineImage(ICON_URL)).toBe(ICON_URL);
expect((await openCache()).entries.size).toBe(0);
expect(cancel).toHaveBeenCalled();
});

test('uses the cached copy when the response is not an image', async () => {
fetchMock.mockResolvedValueOnce(imageResponse());
await inlineImage(ICON_URL);

fetchMock.mockResolvedValueOnce(imageResponse(PNG_BYTES, 'text/html'));
expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);
});

test('uses the cached copy when the fetch rejects', async () => {
fetchMock.mockResolvedValueOnce(imageResponse());
await inlineImage(ICON_URL);

fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);
});

test('uses the cached copy when the response is not ok', async () => {
fetchMock.mockResolvedValueOnce(imageResponse());
await inlineImage(ICON_URL);

fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 }));
expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);
});

test('returns the remote URL when the fetch fails and nothing is cached', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));

expect(await inlineImage(ICON_URL)).toBe(ICON_URL);
expect((await openCache()).entries.size).toBe(0);
});

test('returns the remote URL for an image over the inline limit and does not cache it', async () => {
fetchMock.mockResolvedValue(imageResponse(new Uint8Array(128 * 1024 + 1)));

expect(await inlineImage(ICON_URL)).toBe(ICON_URL);
expect((await openCache()).entries.size).toBe(0);
});

test('skips the body when Content-Length is over the inline limit', async () => {
const cancel = vi.fn();
fetchMock.mockResolvedValue(
new Response(new ReadableStream({ cancel }), {
status: 200,
headers: { 'content-type': 'image/png', 'content-length': String(128 * 1024 + 1) },
}),
);

expect(await inlineImage(ICON_URL)).toBe(ICON_URL);
expect(cancel).toHaveBeenCalled();
expect((await openCache()).entries.size).toBe(0);
});

test('stops reading a stream once it passes the inline limit', async () => {
const chunk = new Uint8Array(64 * 1024);
let pulls = 0;
const cancel = vi.fn();
fetchMock.mockResolvedValue(
new Response(
new ReadableStream<Uint8Array<ArrayBuffer>>({
pull(controller) {
pulls++;
controller.enqueue(chunk);
},
cancel,
}),
{ status: 200, headers: { 'content-type': 'image/png' } },
),
);

expect(await inlineImage(ICON_URL)).toBe(ICON_URL);
expect(cancel).toHaveBeenCalled();
expect(pulls).toBeLessThan(6);
expect((await openCache()).entries.size).toBe(0);
});

test('returns the fetched bytes when Cache Storage cannot be opened', async () => {
vi.stubGlobal('caches', { open: () => Promise.reject(new Error('quota')) });
fetchMock.mockResolvedValue(imageResponse());

expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);
});

test('returns the fetched bytes when the cache write throws', async () => {
const cache = await openCache();
vi.spyOn(cache, 'put').mockRejectedValue(new DOMException('quota', 'QuotaExceededError'));
fetchMock.mockResolvedValue(imageResponse());

expect(await inlineImage(ICON_URL)).toBe(PNG_DATA_URL);
expect(cache.entries.size).toBe(0);
});

test('keeps only the 10 most recent images in the cache', async () => {
fetchMock.mockImplementation(() => Promise.resolve(imageResponse()));
for (let i = 0; i < 12; i++) {
await inlineImage(`${ICON_URL}?v=${i}`);
}

const keys = await (await openCache()).keys();
expect(keys).toHaveLength(10);
expect(keys[0]).toBe(`${ICON_URL}?v=2`);
expect(keys[9]).toBe(`${ICON_URL}?v=11`);
});

test('encodes an image larger than one base64 chunk', async () => {
const bytes = new Uint8Array(0x8000 + 3).map((_, i) => i % 256);
fetchMock.mockResolvedValue(imageResponse(bytes));

const dataUrl = await inlineImage(ICON_URL);

const base64 = dataUrl!.slice('data:image/png;base64,'.length);
expect(Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))).toEqual(bytes);
});
});
Loading
Loading