From 03fc4a8d3065540d6a46d0e6b47c9feb1281e59c Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Mon, 27 Jul 2026 11:53:50 -0700 Subject: [PATCH 1/3] feat(analytics-core): retain events and go offline after max retries on client SDKs When the event server is unreachable (5xx / network errors / timeouts), the Browser and React Native SDKs dropped events once the retry budget was exhausted. Offline mode is only entered on a device-network signal (navigator.onLine / NetInfo), so a server-only outage never trips it. During the 2026-07-24 AWS us-west-2 outage this dropped ~0.4% of web events. Mirror the mobile SDKs (Amplitude-Swift EventPipeline), for client SDKs only: - Retry transient failures with exponential backoff (retryTimeout * 2^(attempts-1)) instead of linear. The retry count (flushMaxRetries, default 5 on browser/RN) bounds it, so no interval cap is needed. - Once the retry budget is exhausted, set config.offline = true and retain the events instead of dropping them. They stay in the queue and on storage; flushing resumes when the connectivity checker sees a navigator/NetInfo online event or the page reloads / app relaunches (which resets config.offline and, since context.attempts is not persisted, retries every stored event from scratch). No server polling. Gate this on the environment via a new isClientSide() helper (isReactNative() || isBrowser()). The Node (server) SDK is a long-lived process with no reconnect/reload recovery, so it keeps the legacy behavior of dropping events past the retry budget. Terminal per-event drops are unchanged (400 invalid API key, server-flagged events, 413 single-event). Update tests for the new client behavior: drop the browser exhaust-retries drop assertion, and stabilize RN init by disabling attribution where it caused flakes. Refs SDKW-42, SDKRN-55. Co-authored-by: Cursor --- .../analytics-browser-test/test/index.test.ts | 41 -------- packages/analytics-core/src/index.ts | 2 +- .../analytics-core/src/plugins/destination.ts | 52 +++++++++- .../analytics-core/src/utils/environment.ts | 12 +++ .../test/plugins/destination.test.ts | 98 +++++++++++++++++++ .../test/utils/environment.test.ts | 54 ++++++++++ .../migration/remnant-data-migration.test.ts | 3 + .../test/react-native-client.test.ts | 4 +- 8 files changed, 218 insertions(+), 48 deletions(-) diff --git a/packages/analytics-browser-test/test/index.test.ts b/packages/analytics-browser-test/test/index.test.ts index 5b05f7938b..3d052d601b 100644 --- a/packages/analytics-browser-test/test/index.test.ts +++ b/packages/analytics-browser-test/test/index.test.ts @@ -800,47 +800,6 @@ describe('integration', () => { second.done(); }); - test('should exhaust max retries', async () => { - const scope = nock(url).post(path).times(3).reply(500, { - code: 500, - }); - - await client.init(apiKey, { - logLevel: 0, - flushMaxRetries: 3, - defaultTracking, - fetchRemoteConfig: false, - }).promise; - const response = await client.track('test event').promise; - expect(response.event).toEqual({ - user_id: undefined, - device_id: uuid, - session_id: number, - time: number, - platform: 'Web', - language: 'en-US', - ip: '$remote', - insert_id: uuid, - partner_id: undefined, - event_type: 'test event', - event_properties: { - '[Amplitude] Page Domain': '', - '[Amplitude] Page Location': '', - '[Amplitude] Page Path': '', - '[Amplitude] Page Title': '', - '[Amplitude] Page URL': '', - '[Amplitude] Previous Page Location': '', - '[Amplitude] Previous Page Type': 'direct', - }, - event_id: 0, - library: library, - user_agent: userAgent, - }); - expect(response.code).toBe(500); - expect(response.message).toBe('Event rejected due to exceeded retry count'); - scope.done(); - }, 10000); - test('should handle missing api key', async () => { await client.init('', undefined, { logLevel: 0, diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index d4fcedb3d4..d7fda11081 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -183,5 +183,5 @@ export { ExcludeInternalReferrersOptions, EXCLUDE_INTERNAL_REFERRERS_CONDITIONS export { VideoObserver, State as VideoState, type VideoObserverParams } from './observers/video'; export { EmbeddedVideoPlayer, type Vendor as VideoVendor } from './video-analytics/types'; -export { isChromeExtension, isReactNative } from './utils/environment'; +export { isChromeExtension, isReactNative, isBrowser, isClientSide } from './utils/environment'; export { translateRemoteConfigToLocal } from './config/joined-config'; diff --git a/packages/analytics-core/src/plugins/destination.ts b/packages/analytics-core/src/plugins/destination.ts index bcf3306e66..08b1fc61e7 100644 --- a/packages/analytics-core/src/plugins/destination.ts +++ b/packages/analytics-core/src/plugins/destination.ts @@ -32,6 +32,7 @@ import { EventCallback } from '../types/event-callback'; import { IDiagnosticsClient } from '../diagnostics/diagnostics-client'; import { isSuccessStatusCode } from '../utils/status-code'; import { getStacktrace } from '../utils/debug'; +import { isClientSide } from '../utils/environment'; export interface Context { event: Event; @@ -95,6 +96,11 @@ export class Destination implements DestinationPlugin { flushId: ReturnType | null = null; queue: Context[] = []; diagnosticsClient: IDiagnosticsClient | undefined; + // True for client SDKs (browser / React Native), which can recover from an offline + // state via a network reconnect or a page reload / app relaunch. False for the Node + // (server) SDK — a long-lived process with no such recovery — where events past the + // retry budget are dropped as before. + isClientSide = isClientSide(); constructor(context?: { diagnosticsClient: IDiagnosticsClient }) { this.diagnosticsClient = context?.diagnosticsClient; @@ -390,13 +396,49 @@ export class Destination implements DestinationPlugin { this.scheduleEvents(tryable); } + getRetryBackoff(attempts: number): number { + return this.retryTimeout * Math.pow(2, Math.max(0, attempts - 1)); + } + handleOtherResponse(list: Context[]) { - const later = list.map((context) => { - context.timeout = context.attempts * this.retryTimeout; - return context; - }); + let tryable: Context[]; + + if (this.isClientSide) { + // Client SDKs (browser / React Native): mirror the mobile SDKs. Retry with + // exponential backoff, and once the retry budget is exhausted, go offline and keep + // the events instead of dropping them — this pauses all further flushing. They stay + // in the queue + storage; flushing resumes when the connectivity checker sees a + // `navigator`/NetInfo online event, or on page reload / app relaunch (which resets + // config.offline and, since `attempts` is not persisted, retries from scratch). + tryable = []; + let isExceedingMaxRetries = false; + list.forEach((context) => { + context.attempts += 1; + if (context.attempts < this.config.flushMaxRetries) { + context.timeout = this.getRetryBackoff(context.attempts); + tryable.push(context); + } else { + isExceedingMaxRetries = true; + } + }); + if (isExceedingMaxRetries) { + this.config.offline = true; + this.config.loggerProvider.debug( + `Upload failed after ${this.config.flushMaxRetries} retries; going offline and pausing flush until the SDK reconnects or the page reloads.`, + ); + this.diagnosticsClient?.increment('offline.by.max.retries'); + } + } else { + // Server (Node) SDK: a long-lived process with no reconnect/reload to recover from + // offline, so keep the legacy behavior — linear backoff and drop events past the + // retry budget. + const later = list.map((context) => { + context.timeout = context.attempts * this.retryTimeout; + return context; + }); + tryable = this.removeEventsExceedFlushMaxRetries(later); + } - const tryable = this.removeEventsExceedFlushMaxRetries(later); this.scheduleEvents(tryable); } diff --git a/packages/analytics-core/src/utils/environment.ts b/packages/analytics-core/src/utils/environment.ts index d7284fb869..0f70731d5d 100644 --- a/packages/analytics-core/src/utils/environment.ts +++ b/packages/analytics-core/src/utils/environment.ts @@ -9,3 +9,15 @@ export function isReactNative(): boolean { const globalScope = getGlobalScope() as { navigator?: { product?: string } }; return globalScope?.navigator?.product === 'ReactNative'; } + +export function isBrowser(): boolean { + const globalScope = getGlobalScope() as { document?: unknown } | undefined; + return typeof globalScope?.document !== 'undefined'; +} + +// Client SDKs (browser or React Native) can recover from an offline state on their own — +// via a network reconnect, a page reload, or an app relaunch. The Node (server) SDK is a +// long-lived process with no such recovery. +export function isClientSide(): boolean { + return isReactNative() || isBrowser(); +} diff --git a/packages/analytics-core/test/plugins/destination.test.ts b/packages/analytics-core/test/plugins/destination.test.ts index 9d3ff5748f..b0285f5971 100644 --- a/packages/analytics-core/test/plugins/destination.test.ts +++ b/packages/analytics-core/test/plugins/destination.test.ts @@ -7,6 +7,7 @@ import { Response } from '../../src/types/response'; import { API_KEY, useDefaultConfig } from '../helpers/default'; import { INVALID_API_KEY, + MAX_RETRIES_EXCEEDED_MESSAGE, MISSING_API_KEY_MESSAGE, SUCCESS_MESSAGE, UNEXPECTED_ERROR_MESSAGE, @@ -148,6 +149,103 @@ describe('destination', () => { }); }); + describe('offline on max retries (SDKW-42)', () => { + test('getRetryBackoff grows exponentially (bounded by flushMaxRetries, no interval cap)', () => { + const destination = new Destination(); + destination.retryTimeout = 1000; + expect(destination.getRetryBackoff(1)).toBe(1000); // 1000 * 2^0 + expect(destination.getRetryBackoff(2)).toBe(2000); // 1000 * 2^1 + expect(destination.getRetryBackoff(3)).toBe(4000); // 1000 * 2^2 + expect(destination.getRetryBackoff(5)).toBe(16000); // 1000 * 2^4 (last retry at default flushMaxRetries=5) + }); + + test('transient failures below the retry limit use exponential backoff and stay online', () => { + const destination = new Destination(); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 20, offline: false }; + destination.isClientSide = true; // client SDK (browser / RN) + destination.retryTimeout = 1000; + const schedule = jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + + destination.handleOtherResponse([context]); // attempts -> 1 + expect(context.timeout).toBe(1000); + destination.handleOtherResponse([context]); // attempts -> 2 + expect(context.timeout).toBe(2000); + destination.handleOtherResponse([context]); // attempts -> 3 + expect(context.timeout).toBe(4000); + + expect(destination.config.offline).toBe(false); + expect(schedule).toHaveBeenCalled(); + }); + + test('goes offline and retains events instead of dropping once the retry limit is hit', () => { + const diagnosticsClient = new DiagnosticsClient(API_KEY, getMockLogger()); + const increment = jest.spyOn(diagnosticsClient, 'increment').mockImplementation(jest.fn); + const destination = new Destination({ diagnosticsClient }); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 1, offline: false }; + destination.isClientSide = true; // client SDK (browser / RN) + const fulfillRequest = jest.spyOn(destination, 'fulfillRequest').mockImplementation(jest.fn); + jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + destination.queue = [context]; + + destination.handleOtherResponse([context]); // attempts 0 -> 1, 1 < 1 is false -> offline + + // Event is retained, not dropped. + expect(fulfillRequest).not.toHaveBeenCalled(); + expect(destination.queue).toContain(context); + // SDK went offline (mirrors mobile); attempts is left as-is (reset happens on reload + // because attempts is not persisted to storage). + expect(destination.config.offline).toBe(true); + expect(context.attempts).toBe(1); + // Diagnostics counter for going offline due to max retries. + expect(increment).toHaveBeenCalledWith('offline.by.max.retries'); + }); + + test('does not poll the server — offline stays set until reconnect or reload', () => { + jest.useFakeTimers(); + try { + const destination = new Destination(); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 1, offline: false }; + destination.isClientSide = true; // client SDK (browser / RN) + jest.spyOn(destination, 'fulfillRequest').mockImplementation(jest.fn); + jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + destination.queue = [context]; + + destination.handleOtherResponse([context]); + expect(destination.config.offline).toBe(true); + + // No self-recovery timer: advancing time does not bring the SDK back online. + jest.advanceTimersByTime(10 * 60 * 1000); + expect(destination.config.offline).toBe(true); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test('Node (server) SDK drops after max retries instead of going offline', () => { + const destination = new Destination(); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 1, offline: false }; + destination.isClientSide = false; // server SDK (Node): no reconnect/reload + const fulfillRequest = jest.spyOn(destination, 'fulfillRequest').mockImplementation(jest.fn); + jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + + destination.handleOtherResponse([context]); // attempts 0 -> 1, 1 < 1 is false -> drop + + // Legacy behavior: event is dropped, SDK stays online. + expect(fulfillRequest).toHaveBeenCalledWith([context], 500, MAX_RETRIES_EXCEEDED_MESSAGE); + expect(destination.config.offline).toBe(false); + }); + + test('detects a server (Node/jest) environment as non-recovering on construction', () => { + // jest runs analytics-core in a node testEnvironment -> not a client SDK. + expect(new Destination().isClientSide).toBe(false); + }); + }); + describe('schedule', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/packages/analytics-core/test/utils/environment.test.ts b/packages/analytics-core/test/utils/environment.test.ts index a87371d9b1..d27e170a94 100644 --- a/packages/analytics-core/test/utils/environment.test.ts +++ b/packages/analytics-core/test/utils/environment.test.ts @@ -72,3 +72,57 @@ describe('isReactNative', () => { expect(analyticsCoreModule.isReactNative()).toBe(true); }); }); + +describe('isBrowser', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(analyticsCoreModule.isBrowser()).toBe(false); + }); + + test('returns false when document is undefined', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(analyticsCoreModule.isBrowser()).toBe(false); + }); + + test('returns true when document is defined', () => { + getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); + expect(analyticsCoreModule.isBrowser()).toBe(true); + }); +}); + +describe('isClientSide', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns true in a React Native environment', () => { + getGlobalScopeSpy.mockReturnValue({ navigator: { product: 'ReactNative' } } as unknown as typeof globalThis); + expect(analyticsCoreModule.isClientSide()).toBe(true); + }); + + test('returns true in a browser environment', () => { + getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); + expect(analyticsCoreModule.isClientSide()).toBe(true); + }); + + test('returns false in a server (Node) environment', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(analyticsCoreModule.isClientSide()).toBe(false); + }); +}); diff --git a/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts b/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts index bbd3b95fa8..e1c5217079 100644 --- a/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts +++ b/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts @@ -230,6 +230,9 @@ describe('migration', () => { migrateLegacyData: false, loggerProvider, instanceName: 'test-instance', + attribution: { + disabled: true, + }, }).promise; expect(client.getDeviceId()).not.toEqual(deviceId); expect(client.getUserId()).not.toEqual(userId); diff --git a/packages/analytics-react-native/test/react-native-client.test.ts b/packages/analytics-react-native/test/react-native-client.test.ts index 04ef27f586..db6650e6fa 100644 --- a/packages/analytics-react-native/test/react-native-client.test.ts +++ b/packages/analytics-react-native/test/react-native-client.test.ts @@ -374,7 +374,9 @@ describe('react-native-client', () => { describe('reset', () => { test('should reset user id and generate new device id config', async () => { const client = new AmplitudeReactNative(); - await client.init(API_KEY).promise; + await client.init(API_KEY, undefined, { + ...attributionConfig, + }).promise; client.setUserId(USER_ID); client.setDeviceId(DEVICE_ID); expect(client.getUserId()).toBe(USER_ID); From 5597e116b7151d38b1c35dcebfd923706a902eb0 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Mon, 27 Jul 2026 13:36:27 -0700 Subject: [PATCH 2/3] chore: comment --- packages/analytics-core/src/utils/environment.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/analytics-core/src/utils/environment.ts b/packages/analytics-core/src/utils/environment.ts index 0f70731d5d..632067fab4 100644 --- a/packages/analytics-core/src/utils/environment.ts +++ b/packages/analytics-core/src/utils/environment.ts @@ -15,9 +15,6 @@ export function isBrowser(): boolean { return typeof globalScope?.document !== 'undefined'; } -// Client SDKs (browser or React Native) can recover from an offline state on their own — -// via a network reconnect, a page reload, or an app relaunch. The Node (server) SDK is a -// long-lived process with no such recovery. export function isClientSide(): boolean { return isReactNative() || isBrowser(); } From ba2a05505ba88a79d31046ba886fe10f8b6a36e0 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Mon, 27 Jul 2026 14:49:11 -0700 Subject: [PATCH 3/3] fix(analytics-core): treat browser workers and extensions as client-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isClientSide() used `document` as the browser discriminator, so a document-less browser context — notably a Manifest V3 extension background service worker, as in examples/browser/chrome-ext — fell through to the Node branch and dropped events past the retry budget instead of retaining them. Add isWebWorker(), which confirms the global scope is an instance of its own WorkerGlobalScope. That constructor is exposed only on worker globals, and the instanceof check additionally rules out non-browser runtimes that expose web constructors on a server global. OR it into isClientSide() along with the existing isChromeExtension(). Keep isBrowser/isClientSide/isWebWorker out of the public API — nothing outside analytics-core uses them. Refs SDKW-42, SDKRN-55. Co-Authored-By: Claude Opus 4.8 --- packages/analytics-core/src/index.ts | 2 +- .../analytics-core/src/plugins/destination.ts | 20 +++--- .../analytics-core/src/utils/environment.ts | 16 ++++- .../test/utils/environment.test.ts | 67 +++++++++++++++++-- 4 files changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index d7fda11081..d4fcedb3d4 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -183,5 +183,5 @@ export { ExcludeInternalReferrersOptions, EXCLUDE_INTERNAL_REFERRERS_CONDITIONS export { VideoObserver, State as VideoState, type VideoObserverParams } from './observers/video'; export { EmbeddedVideoPlayer, type Vendor as VideoVendor } from './video-analytics/types'; -export { isChromeExtension, isReactNative, isBrowser, isClientSide } from './utils/environment'; +export { isChromeExtension, isReactNative } from './utils/environment'; export { translateRemoteConfigToLocal } from './config/joined-config'; diff --git a/packages/analytics-core/src/plugins/destination.ts b/packages/analytics-core/src/plugins/destination.ts index 08b1fc61e7..5e609572ce 100644 --- a/packages/analytics-core/src/plugins/destination.ts +++ b/packages/analytics-core/src/plugins/destination.ts @@ -96,10 +96,10 @@ export class Destination implements DestinationPlugin { flushId: ReturnType | null = null; queue: Context[] = []; diagnosticsClient: IDiagnosticsClient | undefined; - // True for client SDKs (browser / React Native), which can recover from an offline - // state via a network reconnect or a page reload / app relaunch. False for the Node - // (server) SDK — a long-lived process with no such recovery — where events past the - // retry budget are dropped as before. + // True for client SDKs (browser page or worker, Chrome extension, React Native), which can + // recover from an offline state via a network reconnect or a page reload / worker restart / + // app relaunch. False for the Node (server) SDK — a long-lived process with no such + // recovery — where events past the retry budget are dropped as before. isClientSide = isClientSide(); constructor(context?: { diagnosticsClient: IDiagnosticsClient }) { @@ -404,12 +404,12 @@ export class Destination implements DestinationPlugin { let tryable: Context[]; if (this.isClientSide) { - // Client SDKs (browser / React Native): mirror the mobile SDKs. Retry with - // exponential backoff, and once the retry budget is exhausted, go offline and keep - // the events instead of dropping them — this pauses all further flushing. They stay - // in the queue + storage; flushing resumes when the connectivity checker sees a - // `navigator`/NetInfo online event, or on page reload / app relaunch (which resets - // config.offline and, since `attempts` is not persisted, retries from scratch). + // Client SDKs: mirror the mobile SDKs. Retry with exponential backoff, and once the + // retry budget is exhausted, go offline and keep the events instead of dropping + // them — this pauses all further flushing. They stay in the queue + storage; flushing + // resumes when the connectivity checker sees a `navigator`/NetInfo online event, or on + // page reload / worker restart / app relaunch (which resets config.offline and, since + // `attempts` is not persisted, retries from scratch). tryable = []; let isExceedingMaxRetries = false; list.forEach((context) => { diff --git a/packages/analytics-core/src/utils/environment.ts b/packages/analytics-core/src/utils/environment.ts index 632067fab4..64361e5839 100644 --- a/packages/analytics-core/src/utils/environment.ts +++ b/packages/analytics-core/src/utils/environment.ts @@ -10,11 +10,25 @@ export function isReactNative(): boolean { return globalScope?.navigator?.product === 'ReactNative'; } +// Main-thread browser only. Workers have no `document` — use isWebWorker() for those. export function isBrowser(): boolean { const globalScope = getGlobalScope() as { document?: unknown } | undefined; return typeof globalScope?.document !== 'undefined'; } +// True inside a dedicated / shared / service worker, e.g. a Manifest V3 extension background +// worker. `WorkerGlobalScope` is only exposed on worker globals, so its presence is already +// worker-specific; the instanceof check additionally rules out non-browser runtimes that expose +// web constructors on a server global. +export function isWebWorker(): boolean { + const globalScope = getGlobalScope() as { WorkerGlobalScope?: new () => unknown } | undefined; + if (!globalScope) { + return false; + } + const workerGlobalScope = globalScope.WorkerGlobalScope; + return typeof workerGlobalScope === 'function' && globalScope instanceof workerGlobalScope; +} + export function isClientSide(): boolean { - return isReactNative() || isBrowser(); + return isReactNative() || isBrowser() || isWebWorker() || isChromeExtension(); } diff --git a/packages/analytics-core/test/utils/environment.test.ts b/packages/analytics-core/test/utils/environment.test.ts index d27e170a94..9c33029ae9 100644 --- a/packages/analytics-core/test/utils/environment.test.ts +++ b/packages/analytics-core/test/utils/environment.test.ts @@ -1,8 +1,17 @@ import * as analyticsCoreModule from '../../src/index'; +// Not part of the public API — imported from the module under test directly. +import { isBrowser, isClientSide, isWebWorker } from '../../src/utils/environment'; import * as globalScopeModule from '../../src/global-scope'; type ChromeStub = { runtime?: { id?: string | number } }; +// Stands in for a worker global: an instance of the constructor it also exposes. +class FakeWorkerGlobalScope { + WorkerGlobalScope = FakeWorkerGlobalScope; +} + +const workerGlobalScope = () => new FakeWorkerGlobalScope() as unknown as typeof globalThis; + describe('isChromeExtension', () => { const originalChrome = (globalThis as typeof globalThis & { chrome?: ChromeStub }).chrome; @@ -86,17 +95,52 @@ describe('isBrowser', () => { test('returns false when globalScope is undefined', () => { getGlobalScopeSpy.mockReturnValue(undefined); - expect(analyticsCoreModule.isBrowser()).toBe(false); + expect(isBrowser()).toBe(false); }); test('returns false when document is undefined', () => { getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(analyticsCoreModule.isBrowser()).toBe(false); + expect(isBrowser()).toBe(false); }); test('returns true when document is defined', () => { getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); - expect(analyticsCoreModule.isBrowser()).toBe(true); + expect(isBrowser()).toBe(true); + }); +}); + +describe('isWebWorker', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isWebWorker()).toBe(false); + }); + + test('returns false when WorkerGlobalScope is not exposed', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isWebWorker()).toBe(false); + }); + + test('returns false when WorkerGlobalScope is exposed but the scope is not a worker', () => { + // e.g. a server runtime that exposes web constructors on its global. + getGlobalScopeSpy.mockReturnValue({ + WorkerGlobalScope: FakeWorkerGlobalScope, + } as unknown as typeof globalThis); + expect(isWebWorker()).toBe(false); + }); + + test('returns true inside a worker global scope', () => { + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(isWebWorker()).toBe(true); }); }); @@ -113,16 +157,27 @@ describe('isClientSide', () => { test('returns true in a React Native environment', () => { getGlobalScopeSpy.mockReturnValue({ navigator: { product: 'ReactNative' } } as unknown as typeof globalThis); - expect(analyticsCoreModule.isClientSide()).toBe(true); + expect(isClientSide()).toBe(true); }); test('returns true in a browser environment', () => { getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); - expect(analyticsCoreModule.isClientSide()).toBe(true); + expect(isClientSide()).toBe(true); + }); + + test('returns true in a document-less browser worker', () => { + // e.g. a Manifest V3 extension background service worker. + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(isClientSide()).toBe(true); + }); + + test('returns true in a Chrome extension without document', () => { + getGlobalScopeSpy.mockReturnValue({ chrome: { runtime: { id: 'ext-abc' } } } as unknown as typeof globalThis); + expect(isClientSide()).toBe(true); }); test('returns false in a server (Node) environment', () => { getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); - expect(analyticsCoreModule.isClientSide()).toBe(false); + expect(isClientSide()).toBe(false); }); });