From 38ec3b45a07ad6730617b0c477c9ded465cb9f21 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 11 Sep 2026 14:04:10 +0200 Subject: [PATCH 1/4] test(e2e): Verify replay runtime controls drive native recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a manualReplay Maestro flow that exercises the Session Replay runtime controls end-to-end. It launches with buffer-mode replay off (replaysOnErrorSampleRate: 0) and no session sampling, taps a new "Start Replay" button that calls Sentry.getReplay().start(), then captures an exception and reuses assertReplay.yml to assert a real replay (replay_id, duration, segments, valid MP4) came back from the Sentry API. With both sample rates off, the replay can only exist if start() actually drove native recording across the bridge, so this closes the gap the Jest and native unit tests can't — they mock the bridge and stop there. The flow is auto-discovered by cli.mjs and runs on both platforms, reusing the existing prime + assert utilities. Co-Authored-By: Claude Opus 4.8 --- .../e2e-tests/maestro/manualReplay.yml | 22 +++++++++++++++++++ dev-packages/e2e-tests/src/EndToEndTests.tsx | 7 ++++++ 2 files changed, 29 insertions(+) create mode 100644 dev-packages/e2e-tests/maestro/manualReplay.yml diff --git a/dev-packages/e2e-tests/maestro/manualReplay.yml b/dev-packages/e2e-tests/maestro/manualReplay.yml new file mode 100644 index 0000000000..e6fbfbb553 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/manualReplay.yml @@ -0,0 +1,22 @@ +appId: ${APP_ID} +jsEngine: graaljs +--- +# Session Replay runtime-controls e2e. Launches with buffer-mode replay OFF +# (replaysOnErrorSampleRate: 0) and no session sampling, so nothing is recorded +# automatically — a replay_id can only be attached to the error event if +# Sentry.getReplay().start() actually drove native recording. This exercises the +# full manual-control chain (JS getReplay() -> bridge -> native SDK -> Sentry +# ingest) that the Jest/native unit tests can't cover, because they stop at the +# bridge. Runs on both platforms, reusing the existing prime + assert utilities. +- runFlow: + file: utils/launchTestAppClear.yml + env: + replaysOnErrorSampleRate: 0 +- tapOn: + id: 'startReplay' +# Churn the view hierarchy so the now-active session replay records frames before +# the exception is captured (same rationale as buffer-mode priming). +- runFlow: utils/primeReplayBuffer.yml +- tapOn: 'Capture Exception' +- runFlow: utils/assertEventIdVisible.yml +- runFlow: utils/assertReplay.yml diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx index b37c14ad15..100f5ffb8e 100644 --- a/dev-packages/e2e-tests/src/EndToEndTests.tsx +++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx @@ -87,6 +87,13 @@ const EndToEndTestsScreen = (): React.JSX.Element => { setReplayPingCount((count) => count + 1)}> Replay Ping {replayPingCount} + {/* Manually starts a session replay via the runtime controls. The + manualReplay e2e flow taps this with both sample rates off, so a + replay_id can only reach the error event if getReplay().start() + actually drove native recording across the bridge. */} + Sentry.getReplay()?.start()}> + Start Replay + {testCases.map((testCase) => ( {testCase.name} From d63b2873148a08ce249bfb5a5047964d22b3de18 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 11 Sep 2026 14:40:02 +0200 Subject: [PATCH 2/4] test(e2e): Cover replay stop/pause/resume runtime controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a replayStopResume Maestro flow that exercises the remaining Session Replay runtime controls end-to-end, building on the start() coverage in manualReplay: - pause() + resume() must not crash and must leave recording active — after resuming, a captured error still carries a replay. - stop() must halt recording — a subsequent error carries no replay, asserted via a new "noReplay" sentryApi case + assertNoReplay utility (the positive assertReplay throws when a replay_id is absent, so it can't prove halting on its own). Buffer-mode replay is off and there is no session sampling, so a replay can only exist while a manually-started session is actively recording, which lets each assertion isolate what the control did. flush() and startBuffering() are left for a follow-up (no error event to key the assertion off standalone). Co-Authored-By: Claude Opus 4.8 --- .../e2e-tests/maestro/replayStopResume.yml | 33 +++++++++++++++++++ .../maestro/utils/assertNoReplay.yml | 23 +++++++++++++ .../e2e-tests/maestro/utils/sentryApi.js | 14 ++++++++ dev-packages/e2e-tests/src/EndToEndTests.tsx | 13 ++++++++ 4 files changed, 83 insertions(+) create mode 100644 dev-packages/e2e-tests/maestro/replayStopResume.yml create mode 100644 dev-packages/e2e-tests/maestro/utils/assertNoReplay.yml diff --git a/dev-packages/e2e-tests/maestro/replayStopResume.yml b/dev-packages/e2e-tests/maestro/replayStopResume.yml new file mode 100644 index 0000000000..c53473a857 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/replayStopResume.yml @@ -0,0 +1,33 @@ +appId: ${APP_ID} +jsEngine: graaljs +--- +# Exercises the pause / resume / stop Session Replay runtime controls end-to-end, +# building on the start() coverage in manualReplay.yml. Buffer-mode replay is off +# (replaysOnErrorSampleRate: 0) and there is no session sampling, so a replay can +# only exist while a manually-started session is actively recording — which lets +# the assertions isolate what each control did. +- runFlow: + file: utils/launchTestAppClear.yml + env: + replaysOnErrorSampleRate: 0 + +# pause() then resume() must not crash the app and must leave recording active: +# after resuming, a captured error still carries a replay. +- tapOn: + id: 'startReplay' +- tapOn: + id: 'pauseReplay' +- tapOn: + id: 'resumeReplay' +- runFlow: utils/primeReplayBuffer.yml +- tapOn: 'Capture Exception' +- runFlow: utils/assertEventIdVisible.yml +- runFlow: utils/assertReplay.yml + +# stop() must halt recording: a subsequent error carries no replay at all. +- tapOn: + id: 'stopReplay' +- tapOn: 'Clear Event Id' +- runFlow: utils/primeReplayBuffer.yml +- tapOn: 'Capture Exception' +- runFlow: utils/assertNoReplay.yml diff --git a/dev-packages/e2e-tests/maestro/utils/assertNoReplay.yml b/dev-packages/e2e-tests/maestro/utils/assertNoReplay.yml new file mode 100644 index 0000000000..5fb13ef09a --- /dev/null +++ b/dev-packages/e2e-tests/maestro/utils/assertNoReplay.yml @@ -0,0 +1,23 @@ +appId: ${APP_ID} +jsEngine: graaljs +--- +# Inverse of assertReplay.yml: waits for the most recently captured event, then +# asserts it carries NO replay association. Used to prove a runtime control +# (e.g. stop()) actually halted recording, rather than only not crashing. +- extendedWaitUntil: + visible: + id: "eventId" + timeout: 60_000 # 60 seconds + +- copyTextFrom: + id: "eventId" +- assertTrue: ${maestro.copiedText} + +- runScript: + file: sentryApi.js + env: + fetch: noReplay + eventId: ${maestro.copiedText} + sentryAuthToken: ${SENTRY_AUTH_TOKEN} + +- assertTrue: ${output.noReplay} diff --git a/dev-packages/e2e-tests/maestro/utils/sentryApi.js b/dev-packages/e2e-tests/maestro/utils/sentryApi.js index 48908fb651..061128787f 100644 --- a/dev-packages/e2e-tests/maestro/utils/sentryApi.js +++ b/dev-packages/e2e-tests/maestro/utils/sentryApi.js @@ -83,6 +83,20 @@ switch (fetch) { }); break; } + case 'noReplay': { + // Inverse of 'replay': assert the event carries NO replay association, i.e. + // recording was not active when it was captured (e.g. after stop()). Used to + // prove a runtime control actually halted recording, not just that it did + // not crash. + const event = json(fetchFromSentry(`${baseUrl}/events/${eventId}/json/`)); + const rawReplayId = (event.contexts && event.contexts.replay && event.contexts.replay.replay_id) + || (event._dsc && event._dsc.replay_id); + if (rawReplayId) { + throw new Error(`Expected no replay on the event, but found replay_id ${rawReplayId}`); + } + setOutput({ noReplay: true }); + break; + } default: throw new Error(`Unknown "fetch" value: '${fetch}'`); } diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx index 100f5ffb8e..e7f1520b7b 100644 --- a/dev-packages/e2e-tests/src/EndToEndTests.tsx +++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx @@ -94,6 +94,19 @@ const EndToEndTestsScreen = (): React.JSX.Element => { Sentry.getReplay()?.start()}> Start Replay + {/* The replayStopResume e2e flow drives pause/resume/stop through the + same runtime controls. pause() + resume() must not crash and must + leave recording active; stop() must halt it (a later error then + carries no replay). */} + Sentry.getReplay()?.pause()}> + Pause Replay + + Sentry.getReplay()?.resume()}> + Resume Replay + + Sentry.getReplay()?.stop()}> + Stop Replay + {testCases.map((testCase) => ( {testCase.name} From 5629d6a799c81f0e52416ba8260bc52483cf7550 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 11 Sep 2026 15:51:03 +0200 Subject: [PATCH 3/4] test(e2e): Cover startBuffering() + flush() replay controls Add a no-error end-to-end path for the buffered-flush runtime controls: startBuffering() records a buffer regardless of sample rate, and flush() converts it to a session replay and uploads it immediately. The app surfaces getReplay().getReplayId(), so the replay is asserted by querying it directly (replayById), rather than discovering a replay_id on a captured error event. Co-Authored-By: Claude Opus 4.8 --- .../e2e-tests/maestro/bufferedReplayFlush.yml | 22 ++++++++++++++ .../maestro/utils/assertReplayById.yml | 30 +++++++++++++++++++ .../e2e-tests/maestro/utils/sentryApi.js | 16 ++++++++++ dev-packages/e2e-tests/src/EndToEndTests.tsx | 23 ++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 dev-packages/e2e-tests/maestro/bufferedReplayFlush.yml create mode 100644 dev-packages/e2e-tests/maestro/utils/assertReplayById.yml diff --git a/dev-packages/e2e-tests/maestro/bufferedReplayFlush.yml b/dev-packages/e2e-tests/maestro/bufferedReplayFlush.yml new file mode 100644 index 0000000000..1177d3298e --- /dev/null +++ b/dev-packages/e2e-tests/maestro/bufferedReplayFlush.yml @@ -0,0 +1,22 @@ +appId: ${APP_ID} +jsEngine: graaljs +--- +# Exercises the startBuffering() + flush() runtime controls end-to-end WITHOUT +# capturing an error event. Launches with buffer-mode replay OFF +# (replaysOnErrorSampleRate: 0) and no session sampling, so nothing records +# automatically. A manual startBuffering() records a buffer regardless of sample +# rate; flush() then converts that buffer to a session replay and uploads it — +# no error required. The app surfaces getReplay().getReplayId(), so the replay is +# asserted by querying it directly, not by finding a replay_id on a sent error. +- runFlow: + file: utils/launchTestAppClear.yml + env: + replaysOnErrorSampleRate: 0 +- tapOn: + id: 'startBufferingReplay' +# Churn the view hierarchy so the buffer records frames before it is flushed +# (same rationale as buffer-mode priming for the error path). +- runFlow: utils/primeReplayBuffer.yml +- tapOn: + id: 'flushReplay' +- runFlow: utils/assertReplayById.yml diff --git a/dev-packages/e2e-tests/maestro/utils/assertReplayById.yml b/dev-packages/e2e-tests/maestro/utils/assertReplayById.yml new file mode 100644 index 0000000000..3ab0548cc0 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/utils/assertReplayById.yml @@ -0,0 +1,30 @@ +appId: ${APP_ID} +jsEngine: graaljs +--- +# Like assertReplay.yml, but keyed on a replay id rendered directly by the app +# (from getReplay().getReplayId()) instead of a replay_id discovered on a sent +# error event. This is what lets the flush path be verified without ever +# capturing an error: the app hands us the id, we query the replay itself. +- extendedWaitUntil: + visible: + id: "replayId" + timeout: 60_000 # 60 seconds + +- copyTextFrom: + id: "replayId" +- assertTrue: ${maestro.copiedText} + +- runScript: + file: sentryApi.js + env: + fetch: replayById + replayId: ${maestro.copiedText} + sentryAuthToken: ${SENTRY_AUTH_TOKEN} + +- assertTrue: ${output.replayId} +- assertTrue: ${output.replayDuration} +- assertTrue: ${output.replaySegments} +# Assert a valid MP4 container was produced (the "ftyp" box at byte offset 4) +# rather than a platform-specific major brand: iOS (AVAssetWriter) emits "mp42" +# while Android (MediaMuxer) may emit a different brand such as "isom". +- assertTrue: ${output.replayCodec.startsWith("ftyp")} diff --git a/dev-packages/e2e-tests/maestro/utils/sentryApi.js b/dev-packages/e2e-tests/maestro/utils/sentryApi.js index 061128787f..fb78ded03e 100644 --- a/dev-packages/e2e-tests/maestro/utils/sentryApi.js +++ b/dev-packages/e2e-tests/maestro/utils/sentryApi.js @@ -83,6 +83,22 @@ switch (fetch) { }); break; } + case 'replayById': { + // Assert a replay directly by its id, without going through an event. Used + // by the flush path (startBuffering() + flush()), where the app surfaces + // getReplay().getReplayId() itself, so no error event carries a replay_id. + const normalizedReplayId = replayId.replace(/\-/g, ''); + const replay = json(fetchFromSentry(`${baseUrl}/replays/${normalizedReplayId}/`)); + const segment = fetchFromSentry(`${baseUrl}/replays/${normalizedReplayId}/videos/0/`); + + setOutput({ + replayId: replay.data.id, + replayDuration: replay.data.duration, + replaySegments: replay.data.count_segments, + replayCodec: segment.slice(4, 12) + }); + break; + } case 'noReplay': { // Inverse of 'replay': assert the event carries NO replay association, i.e. // recording was not active when it was captured (e.g. after stop()). Used to diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx index e7f1520b7b..36581a493c 100644 --- a/dev-packages/e2e-tests/src/EndToEndTests.tsx +++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx @@ -14,6 +14,10 @@ const EndToEndTestsScreen = (): React.JSX.Element => { // flow to mutate the view hierarchy (side-effect free, no events sent) so the // buffer records frames before the exception is captured. const [replayPingCount, setReplayPingCount] = React.useState(0); + // Surfaced by the flush path (startBuffering() + flush()) so the + // bufferedReplayFlush e2e flow can query the replay directly by id, instead + // of discovering it through a replay_id attached to a sent error event. + const [replayId, setReplayId] = React.useState(null); React.useEffect(() => { const client: Sentry.ReactNativeClient | undefined = Sentry.getClient(); @@ -84,6 +88,7 @@ const EndToEndTestsScreen = (): React.JSX.Element => { setEventId(null)}> Clear Event Id + {replayId ? {replayId} : No replay ID} setReplayPingCount((count) => count + 1)}> Replay Ping {replayPingCount} @@ -107,6 +112,24 @@ const EndToEndTestsScreen = (): React.JSX.Element => { Sentry.getReplay()?.stop()}> Stop Replay + {/* The bufferedReplayFlush e2e flow taps these to exercise the no-error + path: startBuffering() records a buffer regardless of sample rate, and + flush() converts it to a session replay and uploads it immediately, + without an error event ever being captured. The flush handler then + renders getReplay().getReplayId() so the flow can query that replay + directly, instead of discovering it through a sent error. */} + Sentry.getReplay()?.startBuffering()}> + Start Buffering Replay + + { + const replay = Sentry.getReplay(); + await replay?.flush(); + setReplayId(replay?.getReplayId() ?? null); + }}> + Flush Replay + {testCases.map((testCase) => ( {testCase.name} From 45050bacf1cd40a8f5a78482e542dbf5a35f662d Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Fri, 11 Sep 2026 16:21:50 +0200 Subject: [PATCH 4/4] test(e2e): Gate replay stop() assertion to Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stop() "subsequent error carries no replay" assertion fails on iOS: sentry-cocoa's stop() does not clear scope.replayId, so an error captured after stop() still carries the stopped replay's id — unlike Android/sentry-java, which reset it to EMPTY_ID. Gate the assertion to Android via when.platform until the cocoa fix lands, then re-enable it cross-platform. pause()/resume() coverage stays on both platforms. Co-Authored-By: Claude Opus 4.8 --- .../e2e-tests/maestro/replayStopResume.yml | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/maestro/replayStopResume.yml b/dev-packages/e2e-tests/maestro/replayStopResume.yml index c53473a857..d2c2fd73af 100644 --- a/dev-packages/e2e-tests/maestro/replayStopResume.yml +++ b/dev-packages/e2e-tests/maestro/replayStopResume.yml @@ -25,9 +25,18 @@ jsEngine: graaljs - runFlow: utils/assertReplay.yml # stop() must halt recording: a subsequent error carries no replay at all. -- tapOn: - id: 'stopReplay' -- tapOn: 'Clear Event Id' -- runFlow: utils/primeReplayBuffer.yml -- tapOn: 'Capture Exception' -- runFlow: utils/assertNoReplay.yml +# +# Android-only for now: sentry-cocoa's stop() does not clear scope.replayId, so +# on iOS an error captured after stop() still carries the stopped replay's id +# (unlike Android/sentry-java, which reset it to EMPTY_ID). Gate this assertion +# to Android until the cocoa fix lands, then re-enable it cross-platform. +- runFlow: + when: + platform: Android + commands: + - tapOn: + id: 'stopReplay' + - tapOn: 'Clear Event Id' + - runFlow: utils/primeReplayBuffer.yml + - tapOn: 'Capture Exception' + - runFlow: utils/assertNoReplay.yml