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/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/maestro/replayStopResume.yml b/dev-packages/e2e-tests/maestro/replayStopResume.yml new file mode 100644 index 0000000000..d2c2fd73af --- /dev/null +++ b/dev-packages/e2e-tests/maestro/replayStopResume.yml @@ -0,0 +1,42 @@ +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. +# +# 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 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/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 48908fb651..fb78ded03e 100644 --- a/dev-packages/e2e-tests/maestro/utils/sentryApi.js +++ b/dev-packages/e2e-tests/maestro/utils/sentryApi.js @@ -83,6 +83,36 @@ 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 + // 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 b37c14ad15..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,9 +88,48 @@ const EndToEndTestsScreen = (): React.JSX.Element => { setEventId(null)}> Clear Event Id + {replayId ? {replayId} : No replay ID} 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 + + {/* 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 + + {/* 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}