Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions dev-packages/e2e-tests/maestro/bufferedReplayFlush.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions dev-packages/e2e-tests/maestro/manualReplay.yml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions dev-packages/e2e-tests/maestro/replayStopResume.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions dev-packages/e2e-tests/maestro/utils/assertNoReplay.yml
Original file line number Diff line number Diff line change
@@ -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}
30 changes: 30 additions & 0 deletions dev-packages/e2e-tests/maestro/utils/assertReplayById.yml
Original file line number Diff line number Diff line change
@@ -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")}
30 changes: 30 additions & 0 deletions dev-packages/e2e-tests/maestro/utils/sentryApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`);
}
43 changes: 43 additions & 0 deletions dev-packages/e2e-tests/src/EndToEndTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);

React.useEffect(() => {
const client: Sentry.ReactNativeClient | undefined = Sentry.getClient();
Expand Down Expand Up @@ -84,9 +88,48 @@ const EndToEndTestsScreen = (): React.JSX.Element => {
<Text onPress={() => setEventId(null)}>
Clear Event Id
</Text>
{replayId ? <Text testID='replayId'>{replayId}</Text> : <Text>No replay ID</Text>}
<Text testID='replayPing' onPress={() => setReplayPingCount((count) => count + 1)}>
Replay Ping {replayPingCount}
</Text>
{/* 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. */}
<Text testID='startReplay' onPress={() => Sentry.getReplay()?.start()}>
Start Replay
</Text>
{/* 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). */}
<Text testID='pauseReplay' onPress={() => Sentry.getReplay()?.pause()}>
Pause Replay
</Text>
<Text testID='resumeReplay' onPress={() => Sentry.getReplay()?.resume()}>
Resume Replay
</Text>
<Text testID='stopReplay' onPress={() => Sentry.getReplay()?.stop()}>
Stop Replay
</Text>
{/* 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. */}
<Text testID='startBufferingReplay' onPress={() => Sentry.getReplay()?.startBuffering()}>
Start Buffering Replay
</Text>
<Text
testID='flushReplay'
onPress={async () => {
const replay = Sentry.getReplay();
await replay?.flush();
setReplayId(replay?.getReplayId() ?? null);
}}>
Flush Replay
</Text>
{testCases.map((testCase) => (
<Text key={testCase.id} onPress={testCase.action}>
{testCase.name}
Expand Down
Loading