diff --git a/CHANGELOG.md b/CHANGELOG.md index c89f3fadf6..ee9f326410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ``` - Add per-class Session Replay masking via `maskedViewClasses` / `unmaskedViewClasses` on `mobileReplayIntegration` ([#6725](https://github.com/getsentry/sentry-react-native/pull/6725)) +- Add experimental `avoidForegroundResumeHang` (iOS) to `mobileReplayIntegration` to work around a fatal App Hang that can occur when Session Replay resumes capture on returning to the foreground with a heavy view hierarchy on screen ([#6727](https://github.com/getsentry/sentry-react-native/pull/6727)) ### Fixes diff --git a/packages/core/src/js/replay/foregroundReplayGuard.ts b/packages/core/src/js/replay/foregroundReplayGuard.ts new file mode 100644 index 0000000000..9d0f8569ec --- /dev/null +++ b/packages/core/src/js/replay/foregroundReplayGuard.ts @@ -0,0 +1,236 @@ +import type { Client } from '@sentry/core'; +import type { AppStateStatus } from 'react-native'; + +import { debug } from '@sentry/core'; +import { AppState, Platform } from 'react-native'; + +// iOS may suspend the JS runtime between 'inactive' and 'background', so +// 'background' can arrive late or not at all while the app is still +// responsive. Mirrors the same fallback pattern (and delay) as +// `cancelInBackground` in `../tracing/onSpanEndUtils.ts`: on 'inactive', +// schedule the action after a delay, cancelable by a subsequent 'active'. +const IOS_INACTIVE_STOP_DELAY_MS = 5_000; + +/** + * The native calls this guard needs. Callers must invalidate their own cached + * replay id inside `stopReplay`/`startReplayBuffering` - this module has no + * knowledge of that cache. + */ +export interface ForegroundReplayGuardDependencies { + getCurrentReplayId: () => string | null; + stopReplay: () => Promise; + startReplayBuffering: () => Promise; +} + +/** + * On iOS, sentry-cocoa resumes Session Replay capture synchronously on + * `UIApplicationDidBecomeActiveNotification`. On a heavy view hierarchy this + * can block the main thread past iOS's foreground-transition watchdog and get + * the app killed (`Fatal App Hang Fully Blocked`). + * + * There is no way to prevent that automatic resume from JS: it calls the same + * shared `pause`/`resume` state that our own `pauseReplay`/`resumeReplay` + * bridge calls do, so a manual `pause()` before backgrounding does not stick + * - the native resume unconditionally re-arms capture regardless of it. + * + * `stopReplay()` is different: it tears down the native replay session + * entirely, so the automatic resume becomes a no-op. This guard stops replay + * before the app backgrounds, and restarts it (in buffer mode) a short delay + * after the app returns to the foreground - safely outside the watchdog + * window. See getsentry/sentry-react-native#6701. + */ +export interface ForegroundReplayGuardState { + handleAppStateChange: (state: AppStateStatus) => void; + detach: () => void; +} + +/** + * Builds the guard's state machine without touching `AppState`, so tests can + * drive `handleAppStateChange` directly instead of going through React + * Native's `AppState` emitter. + */ +export function createForegroundReplayGuardState( + delayMs: number, + deps: ForegroundReplayGuardDependencies, +): ForegroundReplayGuardState { + // True whenever we don't currently trust a guarded replay is running: from + // a successful stop until a restart successfully completes, or after a + // stop/restart attempt failed and native state became unknown. + let stoppedByGuard = false; + // True while a scheduled restart's `startReplayBuffering()` call is in + // flight, so a background event that lands mid-restart can mark itself + // instead of missing the new (unprotected) session entirely. + let restartInFlight = false; + // The app's current state, updated on every transition - checked (not + // latched) once a restart resolves, so a background/active toggle that + // happens mid-restart is judged by where we ended up, not where we were + // when the restart started. + let isBackgrounded = false; + let detached = false; + // The in-flight `stopReplay()` call, if any. `restart()` waits for it so a + // slow stop can never overlap with `startReplayBuffering()` - the fixed + // delay alone isn't a guarantee. + let pendingStop: Promise | null = null; + let inactiveStopTimeout: ReturnType | null = null; + let resumeTimeout: ReturnType | null = null; + + function clearInactiveStopTimeout(): void { + if (inactiveStopTimeout !== null) { + clearTimeout(inactiveStopTimeout); + inactiveStopTimeout = null; + } + } + + function clearResumeTimeout(): void { + if (resumeTimeout !== null) { + clearTimeout(resumeTimeout); + resumeTimeout = null; + } + } + + function stopIfNeeded(): void { + if (detached) { + return; + } + if (restartInFlight) { + // A restart is already underway; its own resolution checks + // `isBackgrounded` and stops the just-started session if still needed. + return; + } + if (stoppedByGuard) { + return; + } + // Only stop (and later restart) a replay we actually found running. If + // the user already stopped it themselves, or it was never sampled in, + // there is nothing to protect and nothing to restart. + if (!deps.getCurrentReplayId()) { + return; + } + + stoppedByGuard = true; + pendingStop = deps.stopReplay().then(undefined, (error: unknown) => { + // Native state is unknown after a failed stop - don't act as if it's guarded. + stoppedByGuard = false; + debug.error('[Sentry] Failed to stop replay before backgrounding', error); + }); + } + + function restart(): void { + restartInFlight = true; + // Wait for any in-flight stop to actually settle first - startReplayBuffering() + // must never overlap with a still-running stopReplay() call. `pendingStop` + // always resolves (its own rejection handler never rethrows). + Promise.resolve(pendingStop) + .then(() => { + // The client may have closed while we were waiting for the prior + // stop to settle - don't start a new native session after that. + return detached ? undefined : deps.startReplayBuffering(); + }) + .then( + () => { + restartInFlight = false; + stoppedByGuard = false; + if (isBackgrounded && !detached) { + stopIfNeeded(); + } + }, + (error: unknown) => { + restartInFlight = false; + // stoppedByGuard stays true on purpose: the next 'active' event + // retries the restart. Resetting it here would suppress that retry. + debug.error('[Sentry] Failed to restart replay after returning to the foreground', error); + }, + ); + } + + function handleAppStateChange(state: AppStateStatus): void { + if (state === 'background') { + isBackgrounded = true; + clearInactiveStopTimeout(); + clearResumeTimeout(); + stopIfNeeded(); + return; + } + + if (state === 'inactive') { + if (Platform.OS === 'ios' && inactiveStopTimeout === null) { + inactiveStopTimeout = setTimeout(() => { + inactiveStopTimeout = null; + stopIfNeeded(); + }, IOS_INACTIVE_STOP_DELAY_MS); + } + return; + } + + if (state === 'active') { + isBackgrounded = false; + clearInactiveStopTimeout(); + if (stoppedByGuard && resumeTimeout === null && !restartInFlight) { + resumeTimeout = setTimeout(() => { + resumeTimeout = null; + restart(); + }, delayMs); + } + } + } + + function detach(): void { + detached = true; + clearInactiveStopTimeout(); + clearResumeTimeout(); + } + + return { handleAppStateChange, detach }; +} + +/** + * Wires {@link createForegroundReplayGuardState} to React Native's `AppState`. + * iOS-only; a no-op everywhere else. + */ +export function attachForegroundReplayGuard(delayMs: number, deps: ForegroundReplayGuardDependencies): () => void { + if (Platform.OS !== 'ios' || !AppState?.isAvailable) { + return () => {}; + } + + const { handleAppStateChange, detach } = createForegroundReplayGuardState(delayMs, deps); + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + detach(); + subscription?.remove?.(); + }; +} + +/** + * The native replay bridge calls the guard needs. Passed by reference (e.g. + * the `NATIVE` singleton) - never spread/destructured, since its methods rely + * on their receiver (`this.enableNative`, etc.) to read live state. + */ +export interface ForegroundReplayGuardNativeControls { + getCurrentReplayId: () => string | null; + stopReplay: () => Promise; + startReplayBuffering: () => Promise; +} + +/** + * Attaches the guard to `client`, composing `native`'s calls with + * `invalidateCachedReplayId` (the cache invalidation only the integration + * knows how to do), and detaches it when the client closes. + */ +export function setupForegroundReplayGuard( + client: Client, + delayMs: number = 1000, + native: ForegroundReplayGuardNativeControls, + invalidateCachedReplayId: () => void, +): void { + const detach = attachForegroundReplayGuard(delayMs, { + getCurrentReplayId: () => native.getCurrentReplayId(), + stopReplay: () => + native.stopReplay().then(invalidateCachedReplayId, (error: unknown) => { + invalidateCachedReplayId(); + throw error; + }), + startReplayBuffering: () => native.startReplayBuffering().then(invalidateCachedReplayId), + }); + client.on('close', detach); +} diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 5cf864caf5..47348754dd 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -20,6 +20,7 @@ import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { registerFeatureMarker } from '../utils/featureMarkers'; import { NATIVE } from '../wrapper'; +import { setupForegroundReplayGuard } from './foregroundReplayGuard'; import { buildResolvedNetworkBreadcrumb, makeEnrichXhrBreadcrumbsForMobileReplay, @@ -268,6 +269,34 @@ export interface MobileReplayOptions { * @default [] */ networkResponseHeaders?: string[]; + + /** + * Mitigates a fatal iOS App Hang (watchdog kill) that can occur when Session + * Replay resumes capture on returning to the foreground with a heavy view + * hierarchy on screen. When enabled, recording is stopped just before the app + * backgrounds and restarted in buffer mode shortly after it foregrounds. + * + * @note A full-session recording is downgraded to buffer mode after every + * background/foreground cycle while this is enabled. See + * https://github.com/getsentry/sentry-react-native/issues/6701. + * + * @default false + * @platform ios + * @experimental This is a stopgap mitigation and may change or be removed + * once the underlying issue is addressed upstream in sentry-cocoa. + */ + avoidForegroundResumeHang?: boolean; + + /** + * Delay, in milliseconds, before replay recording restarts after the app + * returns to the foreground, when `avoidForegroundResumeHang` is enabled. + * + * @default 1000 + * @platform ios + * @experimental This is a stopgap mitigation and may change or be removed + * once the underlying issue is addressed upstream in sentry-cocoa. + */ + avoidForegroundResumeHangDelayMs?: number; } const defaultOptions: MobileReplayOptions = { @@ -286,10 +315,7 @@ const defaultOptions: MobileReplayOptions = { }; function mergeOptions(initOptions: Partial): MobileReplayOptions { - const merged = { - ...defaultOptions, - ...initOptions, - }; + const merged = { ...defaultOptions, ...initOptions }; if (initOptions.enableViewRendererV2 === undefined && initOptions.enableExperimentalViewRenderer !== undefined) { merged.enableViewRendererV2 = initOptions.enableExperimentalViewRenderer; @@ -502,6 +528,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau // Initialize the cached replay ID on setup cachedReplayId = NATIVE.getCurrentReplayId(); + if (options.avoidForegroundResumeHang) { + setupForegroundReplayGuard(client, options.avoidForegroundResumeHangDelayMs, NATIVE, invalidateCachedReplayId); + } + client.on('createDsc', (dsc: DynamicSamplingContext) => { if (dsc.replay_id) { return; diff --git a/packages/core/test/replay/foregroundReplayGuard.test.ts b/packages/core/test/replay/foregroundReplayGuard.test.ts new file mode 100644 index 0000000000..2cee83594a --- /dev/null +++ b/packages/core/test/replay/foregroundReplayGuard.test.ts @@ -0,0 +1,358 @@ +import type { Client } from '@sentry/core'; + +import { debug } from '@sentry/core'; + +import type { + ForegroundReplayGuardDependencies, + ForegroundReplayGuardNativeControls, + setupForegroundReplayGuard, +} from '../../src/js/replay/foregroundReplayGuard'; + +import { createForegroundReplayGuardState } from '../../src/js/replay/foregroundReplayGuard'; + +function createDeps(replayId: string | null = 'active-replay-id'): ForegroundReplayGuardDependencies & { + getCurrentReplayId: jest.Mock; + stopReplay: jest.Mock; + startReplayBuffering: jest.Mock; +} { + return { + getCurrentReplayId: jest.fn(() => replayId), + stopReplay: jest.fn(() => Promise.resolve()), + startReplayBuffering: jest.fn(() => Promise.resolve()), + }; +} + +/** A promise whose resolution is controlled from outside, to pin down in-flight timing precisely. */ +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +describe('createForegroundReplayGuardState', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stops replay on background and restarts it in buffer mode after the delay on foreground', async () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + + // Act + handleAppStateChange('background'); + handleAppStateChange('background'); // repeated - must not stop twice + handleAppStateChange('active'); + handleAppStateChange('active'); // repeated - must not schedule twice + + // Assert + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1000); + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + }); + + it('does nothing when there is no active replay to protect', () => { + // Arrange + const deps = createDeps(null); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + + // Act + handleAppStateChange('background'); + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); + + it('falls back to stopping on iOS inactive when background never follows (JS may suspend), unless active cancels it', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + + // Act: inactive alone, past the fallback delay, stops replay. + handleAppStateChange('inactive'); + expect(deps.stopReplay).not.toHaveBeenCalled(); + jest.advanceTimersByTime(5000); + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); + + it('cancels the inactive fallback stop when active follows quickly (a brief interruption, not backgrounding)', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('inactive'); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.stopReplay).not.toHaveBeenCalled(); + }); + + it('cancels a pending restart if backgrounded again first, then still restarts once truly foregrounded', async () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + handleAppStateChange('background'); + jest.advanceTimersByTime(1000); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + + // Act + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); + + // Assert + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + }); + + it('stops a session restarted while backgrounding was still in flight, instead of leaving it unprotected', async () => { + // Arrange - regression for a race where the restart's in-flight state was + // dropped too early, so a background event landing mid-restart was missed. + const deps = createDeps('active-replay-id'); + const startDeferred = createDeferred(); + deps.startReplayBuffering.mockReturnValue(startDeferred.promise); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // resume timer fires; startReplayBuffering() now in flight + expect(deps.startReplayBuffering).toHaveBeenCalledTimes(1); + + // Act: background again before the in-flight restart resolves. + deps.getCurrentReplayId.mockReturnValue('new-replay-id'); + handleAppStateChange('background'); + startDeferred.resolve(); + await startDeferred.promise; + await Promise.resolve(); + + // Assert: the just-restarted session gets stopped, not left running. + expect(deps.stopReplay).toHaveBeenCalledTimes(2); + }); + + it('does not stop the newly-started replay if foregrounded again before the in-flight restart resolves', async () => { + // Arrange - regression: a background event landing while the restart was + // still waiting on the prior stop (before startReplayBuffering() was even + // called) used to latch a stale "stop it again" flag, even if the app was + // back in the foreground by the time the restart actually finished. + const deps = createDeps('active-replay-id'); + const stopDeferred = createDeferred(); + deps.stopReplay.mockReturnValue(stopDeferred.promise); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); // stopReplay() in flight + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // resume timer fires; restart() now waiting on the stop + + // Act: background then active again before the still-pending stop settles. + handleAppStateChange('background'); + handleAppStateChange('active'); + stopDeferred.resolve(); + await stopDeferred.promise; + await Promise.resolve(); + await Promise.resolve(); + + // Assert: we ended up foregrounded, so the just-started replay stays running. + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); + + it('does not restart after a failed stop, and logs the failure', async () => { + // Arrange - regression: a rejected stopReplay() must not leave the guard + // thinking it stopped, or it schedules a restart against a session that + // may never have actually stopped. + const deps = createDeps('active-replay-id'); + deps.stopReplay.mockReturnValue(Promise.reject(new Error('native error'))); + const debugErrorSpy = jest.spyOn(debug, 'error').mockImplementation(() => {}); + const { handleAppStateChange } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + await Promise.resolve(); + await Promise.resolve(); + + // Act + handleAppStateChange('active'); + jest.advanceTimersByTime(1000); + + // Assert + expect(debugErrorSpy).toHaveBeenCalled(); + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); + + it('detach cancels a pending restart', () => { + // Arrange + const deps = createDeps('active-replay-id'); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + + // Act + detach(); + jest.advanceTimersByTime(5000); + + // Assert + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); + + it('does not stop replay again after detach, even if a restart was still in flight', async () => { + // Arrange - regression: detach() only cleared timers, so an in-flight + // restart's own resolution could still fire a stopReplay() call post-close. + const deps = createDeps('active-replay-id'); + const startDeferred = createDeferred(); + deps.startReplayBuffering.mockReturnValue(startDeferred.promise); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // restart in flight + deps.getCurrentReplayId.mockReturnValue('new-replay-id'); + handleAppStateChange('background'); // marks the in-flight restart for a follow-up stop + + // Act + detach(); + startDeferred.resolve(); + await startDeferred.promise; + await Promise.resolve(); + + // Assert: no follow-up stop after detach. + expect(deps.stopReplay).toHaveBeenCalledTimes(1); + }); + + it('does not start a new replay session if detached while still waiting for the prior stop to settle', async () => { + // Arrange - regression: detach() during the pendingStop wait (before + // startReplayBuffering() was even called) didn't stop the restart from + // starting a new native session once the stop eventually resolved. + const deps = createDeps('active-replay-id'); + const stopDeferred = createDeferred(); + deps.stopReplay.mockReturnValue(stopDeferred.promise); + const { handleAppStateChange, detach } = createForegroundReplayGuardState(1000, deps); + handleAppStateChange('background'); // stopReplay() in flight + handleAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); // restart begins waiting on the still-pending stop + + // Act: close before the stop settles. + detach(); + stopDeferred.resolve(); + await stopDeferred.promise; + await Promise.resolve(); + await Promise.resolve(); + + // Assert: no session gets started after detach. + expect(deps.startReplayBuffering).not.toHaveBeenCalled(); + }); +}); + +describe('attachForegroundReplayGuard', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('subscribes to AppState on iOS, and detach unsubscribes', () => { + // Arrange + const removeMock = jest.fn(); + jest.resetModules(); + jest.doMock('react-native', () => ({ + AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: removeMock })) }, + Platform: { OS: 'ios' }, + })); + const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); + const { AppState } = require('react-native'); + + // Act + const detach = attach(1000, createDeps()); + + // Assert + expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + detach(); + expect(removeMock).toHaveBeenCalledTimes(1); + }); + + it('does not subscribe on Android', () => { + // Arrange + jest.resetModules(); + jest.doMock('react-native', () => ({ + AppState: { isAvailable: true, addEventListener: jest.fn(() => ({ remove: jest.fn() })) }, + Platform: { OS: 'android' }, + })); + const { attachForegroundReplayGuard: attach } = require('../../src/js/replay/foregroundReplayGuard'); + const { AppState } = require('react-native'); + + // Act + attach(1000, createDeps()); + + // Assert + expect(AppState.addEventListener).not.toHaveBeenCalled(); + }); +}); + +describe('setupForegroundReplayGuard', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + function setUp(): { + setup: typeof setupForegroundReplayGuard; + client: { on: jest.Mock }; + native: ForegroundReplayGuardNativeControls & { [K in keyof ForegroundReplayGuardNativeControls]: jest.Mock }; + invalidateCachedReplayId: jest.Mock; + simulateAppStateChange: (state: string) => void; + } { + jest.resetModules(); + let listener: ((state: string) => void) | undefined; + jest.doMock('react-native', () => ({ + AppState: { + isAvailable: true, + addEventListener: jest.fn((_event: string, cb: (state: string) => void) => { + listener = cb; + return { remove: jest.fn() }; + }), + }, + Platform: { OS: 'ios' }, + })); + const { setupForegroundReplayGuard: setup } = require('../../src/js/replay/foregroundReplayGuard'); + + return { + setup, + client: { on: jest.fn() }, + native: { + getCurrentReplayId: jest.fn(() => 'active-replay-id'), + stopReplay: jest.fn(() => Promise.resolve()), + startReplayBuffering: jest.fn(() => Promise.resolve()), + }, + invalidateCachedReplayId: jest.fn(), + simulateAppStateChange: (state: string) => listener?.(state), + }; + } + + it('registers detach on client close, and invalidates the cached replay id on both stop and restart', async () => { + // Arrange - regression: the guard used to call NATIVE directly, bypassing + // the integration's own cache invalidation, so getReplayId() kept + // pointing at the pre-background session. + jest.useFakeTimers(); + const { setup, client, native, invalidateCachedReplayId, simulateAppStateChange } = setUp(); + setup(client as unknown as Client, 1000, native, invalidateCachedReplayId); + expect(client.on).toHaveBeenCalledWith('close', expect.any(Function)); + + // Act: background stops replay. + simulateAppStateChange('background'); + await Promise.resolve(); + expect(invalidateCachedReplayId).toHaveBeenCalledTimes(1); + + // Act: foreground restarts it. + simulateAppStateChange('active'); + await jest.advanceTimersByTimeAsync(1000); + + // Assert + expect(invalidateCachedReplayId).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); +}); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 745e75dfaf..aed7a3e094 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -11,6 +11,7 @@ import type { import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import { debug } from '@sentry/core'; +import { setupForegroundReplayGuard } from '../../src/js/replay/foregroundReplayGuard'; import { mobileReplayIntegration, serializeNetworkDetailUrlsForNative } from '../../src/js/replay/mobilereplay'; import { REPLAY_RESOLVED_RESPONSE_BODY_HINT_KEY } from '../../src/js/replay/xhrUtils'; import * as scopeSync from '../../src/js/scopeSync'; @@ -18,6 +19,7 @@ import * as environment from '../../src/js/utils/environment'; import { NATIVE } from '../../src/js/wrapper'; jest.mock('../../src/js/wrapper'); +jest.mock('../../src/js/replay/foregroundReplayGuard'); describe('Mobile Replay Integration', () => { let mockCaptureReplay: jest.MockedFunction; @@ -813,6 +815,29 @@ describe('Mobile Replay Integration', () => { }); }); + describe('avoidForegroundResumeHang', () => { + it('sets up the foreground replay guard with the configured delay when enabled', () => { + const integration = mobileReplayIntegration({ + avoidForegroundResumeHang: true, + avoidForegroundResumeHangDelayMs: 500, + }); + integration.setup?.(mockClient); + expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, 500, NATIVE, expect.any(Function)); + }); + + it('passes the configured delay through as-is, letting setupForegroundReplayGuard default it when undefined', () => { + const integration = mobileReplayIntegration({ avoidForegroundResumeHang: true }); + integration.setup?.(mockClient); + expect(setupForegroundReplayGuard).toHaveBeenCalledWith(mockClient, undefined, NATIVE, expect.any(Function)); + }); + + it('does not set up the foreground replay guard by default', () => { + const integration = mobileReplayIntegration(); + integration.setup?.(mockClient); + expect(setupForegroundReplayGuard).not.toHaveBeenCalled(); + }); + }); + describe('network detail feature markers', () => { let mockAddIntegration: jest.Mock; let mockGetIntegrationByName: jest.Mock;