diff --git a/CHANGELOG.md b/CHANGELOG.md index 592d58402e..4037478b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Add `pauseTouchTracking()` and `resumeTouchTracking()` to the Session Replay API ([#7030](https://github.com/getsentry/sentry-react-native/pull/7030)) + - Use them to stop recording touch positions on sensitive screens, for example a custom PIN pad. Screenshots and breadcrumbs continue to be recorded. + - They are independent of `pause()` and `resume()`. Touch tracking stays paused across new replays until you call `resumeTouchTracking()`. + - Requires sentry-android 8.65.0 and sentry-cocoa 9.35.0 or newer. On older native SDKs and on Web, the calls do nothing. + + ```js + Sentry.getReplay()?.pauseTouchTracking(); + // Show the PIN pad. + Sentry.getReplay()?.resumeTouchTracking(); + ``` + ## 8.35.0 ### Features diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m index afafba5b41..c4b517d4ee 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayControlTests.m @@ -4,7 +4,8 @@ /** * Coverage for the Session Replay runtime controls bridged through * `RNSentryInternal` (`startReplay`, `startReplayBuffering`, `stopReplay`, - * `pauseReplay`, `resumeReplay`, `flushReplay`). + * `pauseReplay`, `resumeReplay`, `pauseReplayTouchTracking`, + * `resumeReplayTouchTracking`, `flushReplay`). * * These forward to `SentrySDK.internal.replay.*`. Without a started SDK there is * no active replay, so every control must be a safe no-op that does not crash, @@ -47,6 +48,18 @@ - (void)testResumeReplayDoesNotCrashWhenNotRunning XCTAssertNil(RNSentryInternal.replayId); } +- (void)testPauseReplayTouchTrackingDoesNotCrashWhenNotRunning +{ + [RNSentryInternal pauseReplayTouchTracking]; + XCTAssertNil(RNSentryInternal.replayId); +} + +- (void)testResumeReplayTouchTrackingDoesNotCrashWhenNotRunning +{ + [RNSentryInternal resumeReplayTouchTracking]; + XCTAssertNil(RNSentryInternal.replayId); +} + - (void)testFlushReplayDoesNotCrashWhenNotRunning { [RNSentryInternal flushReplay]; diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 3c0beea631..cafd576add 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -565,6 +565,26 @@ public void resumeReplay(Promise promise) { } } + public void pauseReplayTouchTracking(Promise promise) { + try { + Sentry.replay().pauseTouchTracking(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to pause replay touch tracking", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + + public void resumeReplayTouchTracking(Promise promise) { + try { + Sentry.replay().resumeTouchTracking(); + promise.resolve(null); + } catch (Throwable e) { // NOPMD - degrade at the bridge boundary, never crash the host app + logger.log(SentryLevel.ERROR, "Failed to resume replay touch tracking", e); + promise.reject("SentryReactNative", e.getMessage(), e); + } + } + public void flushReplay(Promise promise) { try { Sentry.replay().flush(); diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index e9d3874bd9..c822e23c85 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -230,6 +230,16 @@ public void resumeReplay(Promise promise) { this.impl.resumeReplay(promise); } + @Override + public void pauseReplayTouchTracking(Promise promise) { + this.impl.pauseReplayTouchTracking(promise); + } + + @Override + public void resumeReplayTouchTracking(Promise promise) { + this.impl.resumeReplayTouchTracking(promise); + } + @Override public void flushReplay(Promise promise) { this.impl.flushReplay(promise); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 6c87070c4f..07589558d8 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -230,6 +230,16 @@ public void resumeReplay(Promise promise) { this.impl.resumeReplay(promise); } + @ReactMethod + public void pauseReplayTouchTracking(Promise promise) { + this.impl.pauseReplayTouchTracking(promise); + } + + @ReactMethod + public void resumeReplayTouchTracking(Promise promise) { + this.impl.resumeReplayTouchTracking(promise); + } + @ReactMethod public void flushReplay(Promise promise) { this.impl.flushReplay(promise); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java index 2d6ab0d1c1..4ff171d191 100644 --- a/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryReplayControlTest.java @@ -24,7 +24,8 @@ /** * Coverage for the Session Replay runtime controls exposed on {@link RNSentryModuleImpl} ({@code * startReplay}, {@code startReplayBuffering}, {@code stopReplay}, {@code pauseReplay}, {@code - * resumeReplay}, {@code flushReplay}). + * resumeReplay}, {@code pauseReplayTouchTracking}, {@code resumeReplayTouchTracking}, {@code + * flushReplay}). * *

Each control forwards to the corresponding {@link IReplayApi} method returned by {@code * Sentry.replay()} and then resolves the promise with {@code null}. @@ -113,6 +114,34 @@ public void resumeReplayCallsSdkAndResolvesNull() { } } + @Test + public void pauseReplayTouchTrackingCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.pauseReplayTouchTracking(promise); + + verify(replay).pauseTouchTracking(); + verify(promise).resolve(null); + } + } + + @Test + public void resumeReplayTouchTrackingCallsSdkAndResolvesNull() { + try (MockedStatic sentry = mockStatic(Sentry.class)) { + final IReplayApi replay = mock(IReplayApi.class); + sentry.when(Sentry::replay).thenReturn(replay); + + final Promise promise = mock(Promise.class); + module.resumeReplayTouchTracking(promise); + + verify(replay).resumeTouchTracking(); + verify(promise).resolve(null); + } + } + @Test public void flushReplayCallsSdkAndResolvesNull() { try (MockedStatic sentry = mockStatic(Sentry.class)) { diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 9e90bdfe9f..b67d925c72 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -967,7 +967,9 @@ export interface Replay extends Integration { getRecordingMode?(): ReplayRecordingMode | undefined; getReplayId(): string | undefined | null; pause(): void; + pauseTouchTracking(): void; resume(): void; + resumeTouchTracking(): void; start(): void; startBuffering(): void; stop(): Promise; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index 55a906648a..0f04e8cad9 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -1020,6 +1020,38 @@ + (BOOL)captureReplayWithReturnValue #endif } +RCT_EXPORT_METHOD(pauseReplayTouchTracking : (RCTPromiseResolveBlock)resolve rejecter : ( + RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternalWrapper pauseReplayTouchTracking]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call pauseReplayTouchTracking: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + +RCT_EXPORT_METHOD(resumeReplayTouchTracking : (RCTPromiseResolveBlock)resolve rejecter : ( + RCTPromiseRejectBlock)reject) +{ +#if SENTRY_TARGET_REPLAY_SUPPORTED + @try { + [RNSentryInternalWrapper resumeReplayTouchTracking]; + resolve(nil); + } @catch (NSException *exception) { + NSLog(@"[RNSentry] Failed to call resumeReplayTouchTracking: %@", exception); + reject(@"SentryReactNative", exception.reason, nil); + } +#else + resolve(nil); +#endif +} + RCT_EXPORT_METHOD( flushReplay : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { diff --git a/packages/core/ios/RNSentryInternalWrapper.h b/packages/core/ios/RNSentryInternalWrapper.h index 9bb94c3c82..5b7b60ec3f 100644 --- a/packages/core/ios/RNSentryInternalWrapper.h +++ b/packages/core/ios/RNSentryInternalWrapper.h @@ -82,6 +82,10 @@ NS_ASSUME_NONNULL_BEGIN + (void)resumeReplay; ++ (void)pauseReplayTouchTracking; + ++ (void)resumeReplayTouchTracking; + + (void)flushReplay; + (void)registerReplayTraceId:(NSString *)traceId; diff --git a/packages/core/ios/RNSentryInternalWrapper.m b/packages/core/ios/RNSentryInternalWrapper.m index d1b40c0714..1bee43c281 100644 --- a/packages/core/ios/RNSentryInternalWrapper.m +++ b/packages/core/ios/RNSentryInternalWrapper.m @@ -121,6 +121,16 @@ + (void)resumeReplay [RNSentryInternal resumeReplay]; } ++ (void)pauseReplayTouchTracking +{ + [RNSentryInternal pauseReplayTouchTracking]; +} + ++ (void)resumeReplayTouchTracking +{ + [RNSentryInternal resumeReplayTouchTracking]; +} + + (void)flushReplay { [RNSentryInternal flushReplay]; diff --git a/packages/core/ios/Swift/RNSentryInternal.swift b/packages/core/ios/Swift/RNSentryInternal.swift index f8a4750321..7c73fc6aa5 100644 --- a/packages/core/ios/Swift/RNSentryInternal.swift +++ b/packages/core/ios/Swift/RNSentryInternal.swift @@ -172,6 +172,14 @@ import Foundation SentrySDK.internal.replay.resume() } + @_spi(Private) @objc public static func pauseReplayTouchTracking() { + SentrySDK.internal.replay.pauseTouchTracking() + } + + @_spi(Private) @objc public static func resumeReplayTouchTracking() { + SentrySDK.internal.replay.resumeTouchTracking() + } + @_spi(Private) @objc public static func flushReplay() { SentrySDK.internal.replay.flush() } @@ -207,6 +215,8 @@ import Foundation @_spi(Private) @objc public static func stopReplay() {} @_spi(Private) @objc public static func pauseReplay() {} @_spi(Private) @objc public static func resumeReplay() {} + @_spi(Private) @objc public static func pauseReplayTouchTracking() {} + @_spi(Private) @objc public static func resumeReplayTouchTracking() {} @_spi(Private) @objc public static func flushReplay() {} @_spi(Private) @objc public static var replayId: String? { nil } @_spi(Private) @objc public static func setReplayRedactContainerClass(_ containerClass: AnyClass) {} diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 7710b3432f..b4dfe3806a 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -63,6 +63,8 @@ export interface Spec extends TurboModule { stopReplay(): Promise; pauseReplay(): Promise; resumeReplay(): Promise; + pauseReplayTouchTracking(): Promise; + resumeReplayTouchTracking(): Promise; flushReplay(): Promise; registerReplayTraceId(traceId: string): void; crashedLastRun(): Promise; diff --git a/packages/core/src/js/replay/browserReplay.ts b/packages/core/src/js/replay/browserReplay.ts index a2fd9286c5..c2ab9d0eee 100644 --- a/packages/core/src/js/replay/browserReplay.ts +++ b/packages/core/src/js/replay/browserReplay.ts @@ -33,18 +33,21 @@ const browserReplayIntegration = (options: ReplayConfiguration = {}): Replay => // `stop`, `flush`, …) and lifecycle hooks (`afterAllSetup`, `processSpan`) // live on the prototype. It must NOT be spread — a spread would drop every // prototype method and break the integration. We attach `pause`/`resume` - // directly on the instance instead. + // (and the touch tracking controls) directly on the instance instead. const integration = replayIntegration({ ...options, mask: ['.sentry-react-native-mask', ...(options.mask || [])], unmask: ['.sentry-react-native-unmask:not(.sentry-react-native-mask *) > *', ...(options.unmask || [])], }) as unknown as Replay; - // `pause`/`resume` are part of the shared `Replay` interface (native-backed on - // mobile) but the browser Session Replay SDK does not expose them, so they are - // no-ops that log on Web to keep the cross-platform API interchangeable. + // `pause`/`resume` and `pauseTouchTracking`/`resumeTouchTracking` are part of + // the shared `Replay` interface (native-backed on mobile) but the browser + // Session Replay SDK does not expose them, so they are no-ops that log on Web + // to keep the cross-platform API interchangeable. integration.pause = pauseNoop; integration.resume = resumeNoop; + integration.pauseTouchTracking = pauseTouchTrackingNoop; + integration.resumeTouchTracking = resumeTouchTrackingNoop; return integration; }; @@ -56,6 +59,14 @@ const resumeNoop = (): void => { debug.log(`[${INTEGRATION_NAME}] \`resume()\` is not supported on Web. No-op.`); }; +const pauseTouchTrackingNoop = (): void => { + debug.log(`[${INTEGRATION_NAME}] \`pauseTouchTracking()\` is not supported on Web. No-op.`); +}; + +const resumeTouchTrackingNoop = (): void => { + debug.log(`[${INTEGRATION_NAME}] \`resumeTouchTracking()\` is not supported on Web. No-op.`); +}; + const browserReplayIntegrationNoop = (): Replay => { return { name: INTEGRATION_NAME, @@ -64,6 +75,8 @@ const browserReplayIntegrationNoop = (): Replay => { stop: () => Promise.resolve(), pause: () => {}, resume: () => {}, + pauseTouchTracking: () => {}, + resumeTouchTracking: () => {}, flush: () => Promise.resolve(), getReplayId: () => undefined, }; diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 0275957452..3835986a54 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -754,6 +754,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau stop: () => settleAndInvalidateReplayId(NATIVE.stopReplay()), pause: () => fireReplayControl(NATIVE.pauseReplay(), 'pause'), resume: () => fireReplayControl(NATIVE.resumeReplay(), 'resume'), + pauseTouchTracking: () => fireReplayControl(NATIVE.pauseReplayTouchTracking(), 'pauseTouchTracking'), + resumeTouchTracking: () => fireReplayControl(NATIVE.resumeReplayTouchTracking(), 'resumeTouchTracking'), flush: (options?: { continueRecording?: boolean }) => { // The native `flushReplay()` always keeps recording after the flush (a // buffered replay is converted to a session and continues), which matches @@ -768,8 +770,8 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau /** * Runs a fire-and-forget replay control (`start`/`startBuffering`/`pause`/ - * `resume`) whose public signature is synchronous (`void`) to match the web - * Replay API. The underlying native call is async, so we swallow and log any + * `resume`/`pauseTouchTracking`/`resumeTouchTracking`) whose public signature + * is synchronous (`void`) to match the web Replay API. The underlying native call is async, so we swallow and log any * rejection here to avoid an unhandled promise rejection. */ function fireReplayControl(promise: Promise, method: string): void { @@ -788,6 +790,8 @@ const mobileReplayIntegrationNoop = (): MobileReplayIntegration => { stop: () => Promise.resolve(), pause: () => {}, resume: () => {}, + pauseTouchTracking: () => {}, + resumeTouchTracking: () => {}, flush: () => Promise.resolve(), }; }; diff --git a/packages/core/src/js/replay/replayInterface.ts b/packages/core/src/js/replay/replayInterface.ts index 5c748fb00f..607ae05a5a 100644 --- a/packages/core/src/js/replay/replayInterface.ts +++ b/packages/core/src/js/replay/replayInterface.ts @@ -9,9 +9,10 @@ import type { Integration, ReplayRecordingMode } from '@sentry/core'; * can control the replay with the same API regardless of platform. * * The shape mirrors the native (iOS/Android) Session Replay runtime controls. - * On Web, `pause()` and `resume()` are no-ops (logged) because the browser - * Session Replay SDK does not expose them; every other method is fully - * supported on all platforms. + * On Web, `pause()`, `resume()`, `pauseTouchTracking()` and + * `resumeTouchTracking()` are no-ops (logged) because the browser Session + * Replay SDK does not expose them; every other method is fully supported on + * all platforms. */ export interface Replay extends Integration { /** @@ -49,6 +50,34 @@ export interface Replay extends Integration { */ resume(): void; + /** + * Stop recording touch positions in the replay. Screenshots continue to be + * recorded, so a masked screen (for example a PIN pad) stays visible without + * revealing where the user tapped. Breadcrumbs are not affected. + * + * Independent of `pause()`/`resume()`: `resume()` does not resume touch + * tracking. The state persists across new replays and `stop()`/`start()` + * until `resumeTouchTracking()` is called, and it can be set before a replay + * starts. + * + * @note Requires a native SDK version that supports it (sentry-android + * 8.65.0, sentry-cocoa 9.35.0); no-op on older native SDKs. + * @note No-op on Web (logged) - the browser Session Replay SDK does not + * expose touch tracking controls. + */ + pauseTouchTracking(): void; + + /** + * Resume recording touch positions after `pauseTouchTracking()`. Touches are + * still not recorded while the replay itself is paused with `pause()`. + * + * @note Requires a native SDK version that supports it (sentry-android + * 8.65.0, sentry-cocoa 9.35.0); no-op on older native SDKs. + * @note No-op on Web (logged) - the browser Session Replay SDK does not + * expose touch tracking controls. + */ + resumeTouchTracking(): void; + /** * Flush the current replay data to Sentry, or start a full-session replay if * recording is stopped. diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 778fa003d4..816151eae3 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -83,7 +83,15 @@ interface SentryNativeWrapper { _serializeObject(data: { [key: string]: unknown }): { [key: string]: string }; _isModuleLoaded(module: Spec | undefined): module is Spec; _callReplayControl( - method: 'startReplay' | 'startReplayBuffering' | 'stopReplay' | 'pauseReplay' | 'resumeReplay' | 'flushReplay', + method: + | 'startReplay' + | 'startReplayBuffering' + | 'stopReplay' + | 'pauseReplay' + | 'resumeReplay' + | 'pauseReplayTouchTracking' + | 'resumeReplayTouchTracking' + | 'flushReplay', ): Promise; isNativeAvailable(): boolean; @@ -159,6 +167,8 @@ interface SentryNativeWrapper { stopReplay(): Promise; pauseReplay(): Promise; resumeReplay(): Promise; + pauseReplayTouchTracking(): Promise; + resumeReplayTouchTracking(): Promise; flushReplay(): Promise; registerReplayTraceId(traceId: string): void; @@ -1025,6 +1035,14 @@ export const NATIVE: SentryNativeWrapper = { return this._callReplayControl('resumeReplay'); }, + pauseReplayTouchTracking(): Promise { + return this._callReplayControl('pauseReplayTouchTracking'); + }, + + resumeReplayTouchTracking(): Promise { + return this._callReplayControl('resumeReplayTouchTracking'); + }, + flushReplay(): Promise { return this._callReplayControl('flushReplay'); }, @@ -1035,7 +1053,15 @@ export const NATIVE: SentryNativeWrapper = { * cached, older) native binary predates the method - it never throws. */ _callReplayControl( - method: 'startReplay' | 'startReplayBuffering' | 'stopReplay' | 'pauseReplay' | 'resumeReplay' | 'flushReplay', + method: + | 'startReplay' + | 'startReplayBuffering' + | 'stopReplay' + | 'pauseReplay' + | 'resumeReplay' + | 'pauseReplayTouchTracking' + | 'resumeReplayTouchTracking' + | 'flushReplay', ): Promise { if (!this.enableNative) { debug.warn(`[NATIVE] \`${method}\` is not available when native is disabled.`); diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index dabc4555f9..e00f5f8488 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -75,6 +75,8 @@ const NATIVE: MockInterface = { stopReplay: jest.fn(), pauseReplay: jest.fn(), resumeReplay: jest.fn(), + pauseReplayTouchTracking: jest.fn(), + resumeReplayTouchTracking: jest.fn(), flushReplay: jest.fn(), registerReplayTraceId: jest.fn(), @@ -118,6 +120,8 @@ NATIVE.startReplayBuffering.mockResolvedValue(undefined); NATIVE.stopReplay.mockResolvedValue(undefined); NATIVE.pauseReplay.mockResolvedValue(undefined); NATIVE.resumeReplay.mockResolvedValue(undefined); +NATIVE.pauseReplayTouchTracking.mockResolvedValue(undefined); +NATIVE.resumeReplayTouchTracking.mockResolvedValue(undefined); NATIVE.flushReplay.mockResolvedValue(undefined); NATIVE.crashedLastRun.mockResolvedValue(false); NATIVE.popTimeToDisplayFor.mockResolvedValue(null); diff --git a/packages/core/test/replay/browserReplay.test.ts b/packages/core/test/replay/browserReplay.test.ts index a22449c0ef..3ecacca372 100644 --- a/packages/core/test/replay/browserReplay.test.ts +++ b/packages/core/test/replay/browserReplay.test.ts @@ -23,7 +23,7 @@ describe('Browser Replay', () => { expect(SentryReact.replayIntegration).not.toHaveBeenCalled(); }); - describe('pause/resume no-op on Web', () => { + describe('pause/resume and touch tracking no-op on Web', () => { const mockUpstreamIntegration = (): void => { spyOn(environment, 'notWeb').mockReturnValue(false); spyOn(SentryReact, 'replayIntegration').mockReturnValue({ @@ -48,5 +48,23 @@ describe('Browser Replay', () => { expect(() => integration.resume()).not.toThrow(); expect(debugLog).toHaveBeenCalledWith(expect.stringContaining('`resume()` is not supported on Web')); }); + + test('pauseTouchTracking() is a no-op that logs', () => { + mockUpstreamIntegration(); + const debugLog = spyOn(debug, 'log').mockImplementation(() => {}); + + const integration = browserReplayIntegration(); + expect(() => integration.pauseTouchTracking()).not.toThrow(); + expect(debugLog).toHaveBeenCalledWith(expect.stringContaining('`pauseTouchTracking()` is not supported on Web')); + }); + + test('resumeTouchTracking() is a no-op that logs', () => { + mockUpstreamIntegration(); + const debugLog = spyOn(debug, 'log').mockImplementation(() => {}); + + const integration = browserReplayIntegration(); + expect(() => integration.resumeTouchTracking()).not.toThrow(); + expect(debugLog).toHaveBeenCalledWith(expect.stringContaining('`resumeTouchTracking()` is not supported on Web')); + }); }); }); diff --git a/packages/core/test/replay/mobilereplay.test.ts b/packages/core/test/replay/mobilereplay.test.ts index 0c73a0f0ac..360c5c7666 100644 --- a/packages/core/test/replay/mobilereplay.test.ts +++ b/packages/core/test/replay/mobilereplay.test.ts @@ -717,6 +717,8 @@ describe('Mobile Replay Integration', () => { (NATIVE.stopReplay as jest.Mock).mockResolvedValue(undefined as never); (NATIVE.pauseReplay as jest.Mock).mockResolvedValue(undefined as never); (NATIVE.resumeReplay as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.pauseReplayTouchTracking as jest.Mock).mockResolvedValue(undefined as never); + (NATIVE.resumeReplayTouchTracking as jest.Mock).mockResolvedValue(undefined as never); (NATIVE.flushReplay as jest.Mock).mockResolvedValue(undefined as never); }); @@ -750,6 +752,32 @@ describe('Mobile Replay Integration', () => { expect(NATIVE.resumeReplay).toHaveBeenCalledTimes(1); }); + it('pauseTouchTracking() calls the native pauseReplayTouchTracking control', () => { + const integration = mobileReplayIntegration(); + integration.pauseTouchTracking(); + expect(NATIVE.pauseReplayTouchTracking).toHaveBeenCalledTimes(1); + expect(NATIVE.pauseReplay).not.toHaveBeenCalled(); + }); + + it('resumeTouchTracking() calls the native resumeReplayTouchTracking control', () => { + const integration = mobileReplayIntegration(); + integration.resumeTouchTracking(); + expect(NATIVE.resumeReplayTouchTracking).toHaveBeenCalledTimes(1); + expect(NATIVE.resumeReplay).not.toHaveBeenCalled(); + }); + + it('swallows and logs a rejected pauseTouchTracking()', async () => { + const error = new Error('native boom'); + (NATIVE.pauseReplayTouchTracking as jest.Mock).mockRejectedValue(error as never); + const debugError = jest.spyOn(debug, 'error').mockImplementation(() => {}); + + const integration = mobileReplayIntegration(); + expect(() => integration.pauseTouchTracking()).not.toThrow(); + + await new Promise(resolve => setImmediate(resolve)); + expect(debugError).toHaveBeenCalledWith(expect.stringContaining('Failed to pauseTouchTracking replay'), error); + }); + it('flush() calls the native flushReplay control and resolves', async () => { const integration = mobileReplayIntegration(); await integration.flush(); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index 24a0b60edf..41267ff090 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -70,6 +70,8 @@ jest.mock('react-native', () => { stopReplay: jest.fn(() => Promise.resolve()), pauseReplay: jest.fn(() => Promise.resolve()), resumeReplay: jest.fn(() => Promise.resolve()), + pauseReplayTouchTracking: jest.fn(() => Promise.resolve()), + resumeReplayTouchTracking: jest.fn(() => Promise.resolve()), flushReplay: jest.fn(() => Promise.resolve()), registerReplayTraceId: jest.fn(), startProfiler: jest.fn(() => Promise.resolve('profiler-id')), @@ -1712,7 +1714,15 @@ describe('Tests Native Wrapper', () => { describe('replay controls', () => { const controls: { - name: 'start' | 'startBuffering' | 'stop' | 'pause' | 'resume' | 'flush'; + name: + | 'start' + | 'startBuffering' + | 'stop' + | 'pause' + | 'resume' + | 'pauseTouchTracking' + | 'resumeTouchTracking' + | 'flush'; call: () => Promise; native: jest.Mock; }[] = [ @@ -1725,6 +1735,16 @@ describe('Tests Native Wrapper', () => { { name: 'stop', call: () => NATIVE.stopReplay(), native: RNSentry.stopReplay as jest.Mock }, { name: 'pause', call: () => NATIVE.pauseReplay(), native: RNSentry.pauseReplay as jest.Mock }, { name: 'resume', call: () => NATIVE.resumeReplay(), native: RNSentry.resumeReplay as jest.Mock }, + { + name: 'pauseTouchTracking', + call: () => NATIVE.pauseReplayTouchTracking(), + native: RNSentry.pauseReplayTouchTracking as jest.Mock, + }, + { + name: 'resumeTouchTracking', + call: () => NATIVE.resumeReplayTouchTracking(), + native: RNSentry.resumeReplayTouchTracking as jest.Mock, + }, { name: 'flush', call: () => NATIVE.flushReplay(), native: RNSentry.flushReplay as jest.Mock }, ]; @@ -1769,6 +1789,8 @@ describe('Tests Native Wrapper', () => { stop: 'stopReplay', pause: 'pauseReplay', resume: 'resumeReplay', + pauseTouchTracking: 'pauseReplayTouchTracking', + resumeTouchTracking: 'resumeReplayTouchTracking', flush: 'flushReplay', } as const )[name]; diff --git a/samples/react-native/src/tabs/PlaygroundTab.tsx b/samples/react-native/src/tabs/PlaygroundTab.tsx index 6bd49544a6..ca6fd5b45a 100644 --- a/samples/react-native/src/tabs/PlaygroundTab.tsx +++ b/samples/react-native/src/tabs/PlaygroundTab.tsx @@ -157,6 +157,22 @@ export default function getPlaygroundTab() { }} /> +