diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt index 5b5ac47444..424572aa38 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/MainApplication.kt @@ -41,10 +41,27 @@ class MainApplication : RNSentrySDK.init(this) } + // Check for crash-on-start intent for testing + if (shouldCrashOnStart()) { + throw RuntimeException("This was intentional test crash before JS started.") + } + SoLoader.init(this, OpenSourceMergedSoMapping) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { // If you opted-in for the New Architecture, we load the native entry point for this app. load() } } + + private fun shouldCrashOnStart(): Boolean { + // Check if crash flag file exists (for E2E testing) + val crashFile = getFileStreamPath(".sentry_crash_on_start") + if (crashFile.exists()) { + // Delete the flag immediately so we only crash once + // This allows the next launch to succeed and send the crash report + crashFile.delete() + return true + } + return false + } } diff --git a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java index 8dfbd44c8b..434bc8927a 100644 --- a/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java +++ b/samples/react-native/android/app/src/main/java/io/sentry/reactnative/sample/SamplePackage.java @@ -77,6 +77,38 @@ private void crashNow() { } }); + modules.add( + new ReactContextBaseJavaModule(reactContext) { + @Override + public String getName() { + return "TestControlModule"; + } + + @ReactMethod + public void enableCrashOnStart(Promise promise) { + try { + // Create flag file to trigger crash on next app start + getReactApplicationContext() + .openFileOutput(".sentry_crash_on_start", ReactApplicationContext.MODE_PRIVATE) + .close(); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", "Failed to enable crash on start", e); + } + } + + @ReactMethod + public void disableCrashOnStart(Promise promise) { + try { + // Delete flag file + getReactApplicationContext().deleteFile(".sentry_crash_on_start"); + promise.resolve(true); + } catch (Exception e) { + promise.reject("ERROR", "Failed to disable crash on start", e); + } + } + }); + return modules; } } diff --git a/samples/react-native/e2e/jest.config.android.auto.js b/samples/react-native/e2e/jest.config.android.auto.js new file mode 100644 index 0000000000..db4de3a8fd --- /dev/null +++ b/samples/react-native/e2e/jest.config.android.auto.js @@ -0,0 +1,13 @@ +const path = require('path'); +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + globalSetup: path.resolve(__dirname, 'setup.android.auto.ts'), + testMatch: [ + ...baseConfig.testMatch, + '/e2e/**/*.test.android.ts', + '/e2e/**/*.test.android.auto.ts', + ], +}; diff --git a/samples/react-native/e2e/setup.android.auto.ts b/samples/react-native/e2e/setup.android.auto.ts new file mode 100644 index 0000000000..4a840f16a0 --- /dev/null +++ b/samples/react-native/e2e/setup.android.auto.ts @@ -0,0 +1,7 @@ +import { setAutoInitTest } from './utils/environment'; + +function setupAuto() { + setAutoInitTest(); +} + +export default setupAuto; diff --git a/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts new file mode 100644 index 0000000000..6faae67695 --- /dev/null +++ b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.ts @@ -0,0 +1,118 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; + +import { + createSentryServer, + containingEvent, +} from '../../utils/mockedSentryServer'; +import { getItemOfTypeFrom } from '../../utils/event'; +import { maestro } from '../../utils/maestro'; + +describe('Capture app start crash (Android)', () => { + let sentryServer = createSentryServer(); + + let envelope: Envelope; + + beforeAll(async () => { + await sentryServer.start(); + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + + await maestro('tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml'); + + envelope = await envelopePromise; + }, 300000); // 5 minutes timeout for crash handling + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains sdk metadata', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + content_type: 'application/json', + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + platform: 'java', + sdk: expect.objectContaining({ + name: 'sentry.java.android.react-native', + packages: expect.arrayContaining([ + expect.objectContaining({ + name: 'maven:io.sentry:sentry-android-core', + }), + expect.objectContaining({ + name: 'npm:@sentry/react-native', + }), + ]), + }), + }), + ]); + }); + + it('captures app start crash exception', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + // Android wraps onCreate exceptions, so check that at least one exception + // contains our intentional crash message + const exceptions = item?.[1]?.exception?.values; + expect(exceptions).toBeDefined(); + + const hasIntentionalCrash = exceptions?.some( + (ex: any) => + ex.type === 'RuntimeException' && + ex.value?.includes('This was intentional test crash before JS started.') + ); + + expect(hasIntentionalCrash).toBe(true); + + // Verify at least one exception has UncaughtExceptionHandler mechanism + const hasUncaughtHandler = exceptions?.some( + (ex: any) => ex.mechanism?.type === 'UncaughtExceptionHandler' + ); + + expect(hasUncaughtHandler).toBe(true); + }); + + it('crash happened before JS was loaded', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + // Verify this is a native crash, not from JavaScript + expect(item?.[1]).toEqual( + expect.objectContaining({ + platform: 'java', + }), + ); + + // Should not have JavaScript context since JS wasn't loaded yet + expect(item?.[1]?.contexts?.react_native_context).toBeUndefined(); + }); + + it('contains device and app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + brand: expect.any(String), + manufacturer: expect.any(String), + model: expect.any(String), + }), + app: expect.objectContaining({ + app_identifier: 'io.sentry.reactnative.sample', + app_name: expect.any(String), + app_version: expect.any(String), + }), + os: expect.objectContaining({ + name: 'Android', + version: expect.any(String), + }), + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml new file mode 100644 index 0000000000..a58cfbb457 --- /dev/null +++ b/samples/react-native/e2e/tests/captureAppStartCrash/captureAppStartCrash.test.android.manual.yml @@ -0,0 +1,30 @@ +appId: io.sentry.reactnative.sample +--- +# First launch: Enable crash flag and exit gracefully +- launchApp: + clearState: true + stopApp: true + +# App launches on ErrorsTab by default, wait for screen to load +- waitForAnimationToEnd: + timeout: 2000 + +# Scroll down to find the "Enable Crash on Start" button (Android only) +- scrollUntilVisible: + element: "Enable Crash on Start" + timeout: 10000 + direction: DOWN + +- tapOn: "Enable Crash on Start" +- stopApp + +# Second launch: App crashes on start +# The crash flag auto-deletes when read, so app only crashes once +- launchApp: + clearState: false + stopApp: false + +# Third launch: App starts normally and sends the crash event +- launchApp: + clearState: false + stopApp: false diff --git a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts new file mode 100644 index 0000000000..2c7bfa0809 --- /dev/null +++ b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.auto.ts @@ -0,0 +1,99 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; + +import { + createSentryServer, + containingEventWithAndroidMessage, +} from '../../utils/mockedSentryServer'; +import { getItemOfTypeFrom } from '../../utils/event'; +import { maestro } from '../../utils/maestro'; + +describe('Capture message (auto init from JS)', () => { + let sentryServer = createSentryServer(); + + let envelope: Envelope; + + beforeAll(async () => { + await sentryServer.start(); + + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithAndroidMessage('Captured message'), + ); + + await maestro('tests/captureMessage/captureMessage.test.yml'); + + envelope = await envelopePromise; + }, 240000); // 240 seconds timeout + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains message event', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + content_type: 'application/json', + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + level: 'info', + message: { + message: 'Captured message', + }, + platform: 'javascript', + }), + ]); + }); + + it('contains device context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + device: expect.objectContaining({ + battery_level: expect.any(Number), + brand: expect.any(String), + family: expect.any(String), + manufacturer: expect.any(String), + model: expect.any(String), + simulator: expect.any(Boolean), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_identifier: expect.any(String), + app_name: expect.any(String), + app_version: expect.any(String), + }), + }), + }), + ); + }); + + it('SDK initialized from JavaScript (auto init)', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + // Verify that native SDK was NOT initialized before JS + // When auto init, the SDK is initialized from JavaScript + expect(item?.[1]).toEqual( + expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.react-native', + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts similarity index 98% rename from samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts rename to samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts index 9703e0a7a3..95c1bf3c6d 100644 --- a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.ts +++ b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.android.manual.ts @@ -8,7 +8,7 @@ import { import { getItemOfTypeFrom } from '../../utils/event'; import { maestro } from '../../utils/maestro'; -describe('Capture message', () => { +describe('Capture message (manual native init)', () => { let sentryServer = createSentryServer(); let envelope: Envelope; diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 95828bda88..515b0d792b 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -10,6 +10,8 @@ "build-android-release-legacy": "scripts/build-android-release-legacy.sh", "build-android-debug": "scripts/build-android-debug.sh", "build-android-debug-legacy": "scripts/build-android-debug-legacy.sh", + "build-android-debug-auto": "scripts/build-android-debug-auto.sh", + "build-android-debug-manual": "scripts/build-android-debug-manual.sh", "build-ios-release": "scripts/build-ios-release.sh", "build-ios-debug": "scripts/build-ios-debug.sh", "test": "jest", @@ -18,6 +20,7 @@ "set-test-dsn-android": "scripts/set-dsn-aos.mjs", "set-test-dsn-ios": "scripts/set-dsn-ios.mjs", "test-android-manual": "scripts/test-android-manual.sh", + "test-android-auto": "scripts/test-android-auto.sh", "test-ios-manual": "scripts/test-ios-manual.sh", "test-ios-auto": "scripts/test-ios-auto.sh", "lint": "npx eslint . --ext .js,.jsx,.ts,.tsx", diff --git a/samples/react-native/scripts/build-android-debug-auto.sh b/samples/react-native/scripts/build-android-debug-auto.sh new file mode 100755 index 0000000000..a4e50a4d1f --- /dev/null +++ b/samples/react-native/scripts/build-android-debug-auto.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="debug" +export SENTRY_DISABLE_NATIVE_START="true" + +echo "Building Android with SENTRY_DISABLE_NATIVE_START=${SENTRY_DISABLE_NATIVE_START}" +echo "This build will initialize Sentry from JavaScript (auto init)" + +"${thisFilePath}/build-android.sh" + +# Rename the output APK to distinguish it from manual build +cd "${thisFilePath}/.." +if [ -f "app.apk" ]; then + mv app.apk app-auto.apk + echo "Build complete: app-auto.apk" +fi diff --git a/samples/react-native/scripts/build-android-debug-manual.sh b/samples/react-native/scripts/build-android-debug-manual.sh new file mode 100755 index 0000000000..7bb4754496 --- /dev/null +++ b/samples/react-native/scripts/build-android-debug-manual.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="debug" +export SENTRY_DISABLE_NATIVE_START="false" + +echo "Building Android with SENTRY_DISABLE_NATIVE_START=${SENTRY_DISABLE_NATIVE_START}" +echo "This build will initialize Sentry natively before JS (manual init)" + +"${thisFilePath}/build-android.sh" + +# Rename the output APK to distinguish it from auto build +cd "${thisFilePath}/.." +if [ -f "app.apk" ]; then + mv app.apk app-manual.apk + echo "Build complete: app-manual.apk" +fi diff --git a/samples/react-native/scripts/test-android-auto.sh b/samples/react-native/scripts/test-android-auto.sh new file mode 100755 index 0000000000..5a27fe3457 --- /dev/null +++ b/samples/react-native/scripts/test-android-auto.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e # exit on error + +# Get current directory +thisFileDirPath=$(dirname "$0") +reactProjectRootPath="$(cd "$thisFileDirPath/.." && pwd)" + +maybeApkPath=$(find "${reactProjectRootPath}" -maxdepth 1 -name "*-auto.apk") + +# Check if any APK files exist +apk_count=$(echo "$maybeApkPath" | wc -l) + +if [ -n "$maybeApkPath" ] && [ $apk_count -eq 1 ]; then + # Force install single APK using adb + apk_file="${maybeApkPath}" + echo "Installing $apk_file..." + adb install -r "$apk_file" +elif [ $apk_count -gt 1 ]; then + echo "Error: Multiple APK files found. Expected only one APK file." + exit 1 +else + echo "No APK files found, continuing without install" +fi + +# Run the tests +npx jest --config e2e/jest.config.android.auto.js diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index a03022a351..d1b2052ed1 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -22,7 +22,7 @@ import { setScopeProperties } from '../setScopeProperties'; import { TimeToFullDisplay } from '../utils'; import type { Event as SentryEvent } from '@sentry/core'; -const { AssetsModule, CppModule, CrashModule } = NativeModules; +const { AssetsModule, CppModule, CrashModule, TestControlModule } = NativeModules; interface Props { navigation: StackNavigationProp; @@ -207,6 +207,30 @@ const ErrorsScreen = (_props: Props) => { }); }} /> +