diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 3ec82a6e31..0c697f22e5 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -14,6 +14,12 @@ concurrency: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} RN_SENTRY_POD_NAME: RNSentry + IOS_APP_ARCHIVE_PATH: sentry-react-native-sample.app.zip + ANDROID_APP_ARCHIVE_PATH: sentry-react-native-sample.apk.zip + REACT_NATIVE_SAMPLE_PATH: samples/react-native + IOS_DEVICE: 'iPhone 16' + IOS_VERSION: '18.1' + ANDROID_API_LEVEL: '30' jobs: diff_check: @@ -66,7 +72,7 @@ jobs: - uses: ruby/setup-ruby@v1 if: ${{ matrix.platform == 'ios' || matrix.platform == 'macos' }} with: - working-directory: ${{ matrix.platform == 'ios' && ' samples/react-native' || ' samples/react-native-macos' }} + working-directory: ${{ matrix.platform == 'ios' && env.REACT_NATIVE_SAMPLE_PATH || ' samples/react-native-macos' }} ruby-version: '3.3.0' # based on what is used in the sample bundler-cache: true # runs 'bundle install' and caches installed gems automatically cache-version: 1 # cache the installed gems @@ -93,59 +99,39 @@ jobs: if: ${{ matrix.platform == 'ios' || matrix.platform == 'macos' }} working-directory: samples run: | - [[ "${{ matrix.platform }}" == "ios" ]] && cd react-native/ios - [[ "${{ matrix.platform }}" == "macos" ]] && cd react-native-macos/macos + [[ "${{ matrix.platform }}" == "ios" ]] && cd react-native + [[ "${{ matrix.platform }}" == "macos" ]] && cd react-native-macos - [[ "${{ matrix.build-type }}" == "production" ]] && ENABLE_PROD=1 || ENABLE_PROD=0 - [[ "${{ matrix.rn-architecture }}" == "new" ]] && ENABLE_NEW_ARCH=1 || ENABLE_NEW_ARCH=0 + [[ "${{ matrix.build-type }}" == "production" ]] && export ENABLE_PROD=1 || export ENABLE_PROD=0 + [[ "${{ matrix.rn-architecture }}" == "new" ]] && export ENABLE_NEW_ARCH=1 || export ENABLE_NEW_ARCH=0 [[ "${{ matrix.ios-use-frameworks }}" == "dynamic-frameworks" ]] && export USE_FRAMEWORKS=dynamic - echo "ENABLE_PROD=$ENABLE_PROD" - echo "ENABLE_NEW_ARCH=$ENABLE_NEW_ARCH" - PRODUCTION=$ENABLE_PROD RCT_NEW_ARCH_ENABLED=$ENABLE_NEW_ARCH bundle exec pod install - cat Podfile.lock | grep $RN_SENTRY_POD_NAME + + ./scripts/pod-install.sh - name: Build Android App if: ${{ matrix.platform == 'android' }} - working-directory: samples/react-native/android + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} run: | - if [[ ${{ matrix.rn-architecture }} == 'new' ]]; then - perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties - echo 'New Architecture enabled' - elif [[ ${{ matrix.rn-architecture }} == 'legacy' ]]; then - perl -i -pe's/newArchEnabled=true/newArchEnabled=false/g' gradle.properties - echo 'Legacy Architecture enabled' - else - echo 'No changes for architecture: ${{ matrix.rn-architecture }}' - fi - [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' - echo "Building $CONFIG" - ./gradlew ":app:assemble$CONFIG" -PreactNativeArchitectures=x86 + export RN_ARCHITECTURE="${{ matrix.rn-architecture }}" + [[ "${{ matrix.build-type }}" == "production" ]] && export CONFIG='release' || export CONFIG='debug' + + ./scripts/set-dsn-aos.mjs + ./scripts/build-android.sh -PreactNativeArchitectures=x86 - name: Build iOS App if: ${{ matrix.platform == 'ios' }} - working-directory: samples/react-native/ios + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} run: | - [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' - echo "Building $CONFIG" - mkdir -p "DerivedData" - derivedData="$(cd "DerivedData" ; pwd -P)" - set -o pipefail && xcodebuild \ - -workspace sentryreactnativesample.xcworkspace \ - -configuration "$CONFIG" \ - -scheme sentryreactnativesample \ - -sdk 'iphonesimulator' \ - -destination 'generic/platform=iOS Simulator' \ - ONLY_ACTIVE_ARCH=yes \ - -derivedDataPath "$derivedData" \ - build \ - | tee xcodebuild.log \ - | xcbeautify --quieter --is-ci --disable-colored-output + [[ "${{ matrix.build-type }}" == "production" ]] && export CONFIG='Release' || export CONFIG='Debug' + + ./scripts/set-dsn-ios.mjs + ./scripts/build-ios.sh - name: Build macOS App if: ${{ matrix.platform == 'macos' }} working-directory: samples/react-native-macos/macos run: | - [[ "${{ matrix.build-type }}" == "production" ]] && CONFIG='Release' || CONFIG='Debug' + [[ "${{ matrix.build-type }}" == "production" ]] && export CONFIG='Release' || export CONFIG='Debug' echo "Building $CONFIG" mkdir -p "DerivedData" derivedData="$(cd "DerivedData" ; pwd -P)" @@ -160,9 +146,173 @@ jobs: | tee xcodebuild.log \ | xcbeautify --quieter --is-ci --disable-colored-output + - name: Archive iOS App + if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: | + zip -r \ + ${{ github.workspace }}/${{ env.IOS_APP_ARCHIVE_PATH }} \ + sentryreactnativesample.app + + - name: Archive Android App + if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} + run: | + zip -j \ + ${{ env.ANDROID_APP_ARCHIVE_PATH }} \ + ${{ env.REACT_NATIVE_SAMPLE_PATH }}/app.apk \ + ${{ env.REACT_NATIVE_SAMPLE_PATH }}/app-androidTest.apk + + - name: Upload iOS APP + if: ${{ matrix.platform == 'ios' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' && matrix.ios-use-frameworks == 'no-frameworks' }} + uses: actions/upload-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-${{ matrix.platform }} + path: ${{ env.IOS_APP_ARCHIVE_PATH }} + retention-days: 1 + + - name: Upload Android APK + if: ${{ matrix.platform == 'android' && matrix.rn-architecture == 'new' && matrix.build-type == 'production' }} + uses: actions/upload-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.platform }} + path: ${{ env.ANDROID_APP_ARCHIVE_PATH }} + retention-days: 1 + - name: Upload logs if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: build-sample-${{ matrix.rn-architecture }}-${{ matrix.platform }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-logs - path: samples/react-native/${{ matrix.platform }}/*.log + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/${{ matrix.platform }}/*.log + + test: + name: ${{ matrix.job-name }} + runs-on: ${{ matrix.runs-on }} + needs: [diff_check, build] + if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} + strategy: + # we want that the matrix keeps running, default is to cancel them if it fails. + fail-fast: false + matrix: + include: + - job-name: 'Test iOS Release Auto Init' + platform: ios + runs-on: macos-15 + rn-architecture: 'new' + ios-use-frameworks: 'no-frameworks' + build-type: 'production' + test-command: 'yarn test-ios-auto' # tests native auto init from JS + + - job-name: 'Test iOS Release Manual Init' + platform: ios + runs-on: macos-15 + rn-architecture: 'new' + ios-use-frameworks: 'no-frameworks' + build-type: 'production' + test-command: 'yarn test-ios-manual' + + - job-name: 'Test Android Release Manual Init' + platform: android + runs-on: ubuntu-latest + rn-architecture: 'new' + build-type: 'production' + test-command: 'yarn test-android' + + steps: + - uses: actions/checkout@v4 + + - name: Download iOS App Archive + if: ${{ matrix.platform == 'ios' }} + uses: actions/download-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-${{ matrix.platform }} + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + + - name: Download Android APK + if: ${{ matrix.platform == 'android' }} + uses: actions/download-artifact@v4 + with: + name: sample-rn-${{ matrix.rn-architecture }}-${{ matrix.build-type }}-${{ matrix.platform }} + path: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + + - name: Unzip iOS App Archive + if: ${{ matrix.platform == 'ios' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: unzip ${{ env.IOS_APP_ARCHIVE_PATH }} + + - name: Unzip Android APK + if: ${{ matrix.platform == 'android' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }} + + - name: Enable Corepack + run: | + npm install -g corepack@0.29.4 + corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'yarn' + cache-dependency-path: yarn.lock + + - name: Install JS Dependencies + run: yarn install + + - name: Install Detox + run: npm install -g detox-cli@20.0.0 + + - name: Install Apple Simulator Utilities + if: ${{ matrix.platform == 'ios' }} + run: | + brew tap wix/brew + brew install applesimutils + + - name: Setup KVM + if: ${{ matrix.platform == 'android' }} + shell: bash + run: | + # check if virtualization is supported... + sudo apt install -y --no-install-recommends cpu-checker coreutils && echo "CPUs=$(nproc --all)" && kvm-ok + # allow access to KVM to run the emulator + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: futureware-tech/simulator-action@dab10d813144ef59b48d401cd95da151222ef8cd # pin@v4 + if: ${{ matrix.platform == 'ios' }} + with: + # the same envs are used by Detox ci.sim configuration + model: ${{ env.IOS_DEVICE }} + os_version: ${{ env.IOS_VERSION }} + + - name: Run Detox iOS Tests + if: ${{ matrix.platform == 'ios' }} + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + run: ${{ matrix.test-command }} + + - name: Run tests on Android + if: ${{ matrix.platform == 'android' }} + env: + # used by Detox ci.android configuration + ANDROID_AVD_NAME: 'test' # test is default reactivecircus/android-emulator-runner name + ANDROID_TYPE: 'android.emulator' + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 + with: + api-level: ${{ env.ANDROID_API_LEVEL }} + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + target: 'aosp_atd' + channel: canary # Necessary for ATDs + emulator-options: > + -no-window + -no-snapshot-save + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -camera-front none + -timezone US/Pacific + working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} + script: ${{ matrix.test-command }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6737e6eb66..ef04ec37c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,61 @@ - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#320) - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.1.2...3.2.0) + +## 6.7.0-alpha.0 + +### Features + +- Capture App Start errors and crashes by initializing Sentry from `sentry.options.json` ([#4472](https://github.com/getsentry/sentry-react-native/pull/4472)) + + Create `sentry.options.json` in the React Native project root and set options the same as you currently have in `Sentry.init` in JS. + + ```json + { + "dsn": "https://key@example.io/value", + } + ``` + + Initialize Sentry on the native layers by newly provided native methods. + + ```kotlin + import io.sentry.react.RNSentrySDK + + class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + RNSentrySDK.init(this) + } + } + ``` + + ```obj-c + #import + + @implementation AppDelegate + - (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions + { + [RNSentrySDK start]; + return [super application:application didFinishLaunchingWithOptions:launchOptions]; + } + @end + ``` + +### Changes + +- Load `optionsFile` into the JS bundle during Metro bundle process ([#4476](https://github.com/getsentry/sentry-react-native/pull/4476)) +- Add experimental version of `startWithConfigureOptions` for Apple platforms ([#4444](https://github.com/getsentry/sentry-react-native/pull/4444)) +- Add experimental version of `init` with optional `OptionsConfiguration` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) +- Add initialization using `sentry.options.json` for Apple platforms ([#4447](https://github.com/getsentry/sentry-react-native/pull/4447)) +- Add initialization using `sentry.options.json` for Android ([#4451](https://github.com/getsentry/sentry-react-native/pull/4451)) +- Merge options from file with `Sentry.init` options in JS ([#4510](https://github.com/getsentry/sentry-react-native/pull/4510)) + +### Internal + +- Extract iOS native initialization to standalone structures ([#4442](https://github.com/getsentry/sentry-react-native/pull/4442)) +- Extract Android native initialization to standalone structures ([#4445](https://github.com/getsentry/sentry-react-native/pull/4445)) + ## 6.7.0 ### Features diff --git a/lerna.json b/lerna.json index ccb2d158c7..1dc40ce9bd 100644 --- a/lerna.json +++ b/lerna.json @@ -8,4 +8,4 @@ "performance-tests/*" ], "npmClient": "yarn" -} \ No newline at end of file +} diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec index 6e4c24dff2..f59ef9daad 100644 --- a/packages/core/RNSentry.podspec +++ b/packages/core/RNSentry.podspec @@ -33,7 +33,7 @@ Pod::Spec.new do |s| s.preserve_paths = '*.js' s.source_files = 'ios/**/*.{h,m,mm}' - s.public_header_files = 'ios/RNSentry.h' + s.public_header_files = 'ios/RNSentry.h', 'ios/RNSentrySDK.h' s.compiler_flags = other_cflags diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json new file mode 100644 index 0000000000..be3bb71111 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.json @@ -0,0 +1,3 @@ +{ + "dsn": "invalid-dsn" +} \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt new file mode 100644 index 0000000000..f07bfaea41 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/invalid.options.txt @@ -0,0 +1 @@ +invalid-options \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json new file mode 100644 index 0000000000..f97a8df3f2 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/assets/sentry.options.json @@ -0,0 +1,5 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableTracing": true, + "tracesSampleRate": 1.0 +} \ No newline at end of file diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt new file mode 100644 index 0000000000..e49aa546f8 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentryJsonConverterTest.kt @@ -0,0 +1,103 @@ +package io.sentry.react + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import io.sentry.react.RNSentryJsonConverter.convertToWritable +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RNSentryJsonConverterTest { + @Test + fun testConvertToWritableWithSimpleJsonObject() { + val jsonObject = + JSONObject().apply { + put("floatKey", 12.3f) + put("doubleKey", 12.3) + put("intKey", 123) + put("stringKey", "test") + put("nullKey", JSONObject.NULL) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + assertNotNull(result) + assertEquals(12.3, result!!.getDouble("floatKey"), 0.0001) + assertEquals(12.3, result.getDouble("doubleKey"), 0.0) + assertEquals(123, result.getInt("intKey")) + assertEquals("test", result.getString("stringKey")) + assertNull(result.getString("nullKey")) + } + + @Test + fun testConvertToWritableWithNestedJsonObject() { + val jsonObject = + JSONObject().apply { + put( + "nested", + JSONObject().apply { + put("key", "value") + }, + ) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + assertNotNull(result) + val nestedMap = result!!.getMap("nested") + assertNotNull(nestedMap) + assertEquals("value", nestedMap!!.getString("key")) + } + + @Test + fun testConvertToWritableWithJsonArray() { + val jsonArray = + JSONArray().apply { + put(1) + put(2.5) + put("string") + put(JSONObject.NULL) + } + + val result: WritableArray = convertToWritable(jsonArray) + + assertEquals(1, result.getInt(0)) + assertEquals(2.5, result.getDouble(1), 0.0) + assertEquals("string", result.getString(2)) + assertNull(result.getString(3)) + } + + @Test + fun testConvertToWritableWithNestedJsonArray() { + val jsonObject = + JSONObject().apply { + put( + "array", + JSONArray().apply { + put( + JSONObject().apply { + put("key1", "value1") + }, + ) + put( + JSONObject().apply { + put("key2", "value2") + }, + ) + }, + ) + } + + val result: WritableMap? = convertToWritable(jsonObject) + + val array = result?.getArray("array") + assertEquals("value1", array?.getMap(0)?.getString("key1")) + assertEquals("value2", array?.getMap(1)?.getString("key2")) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt new file mode 100644 index 0000000000..3b95742e55 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/androidTest/java/io/sentry/react/RNSentrySDKTest.kt @@ -0,0 +1,200 @@ +package io.sentry.react + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.common.JavascriptException +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.Sentry +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.SentryEvent +import io.sentry.android.core.AndroidLogger +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.protocol.SdkVersion +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RNSentrySDKTest { + private val logger: ILogger = AndroidLogger(RNSentrySDKTest::class.java.simpleName) + private lateinit var context: Context + + companion object { + private const val INITIALISATION_ERROR = "Failed to initialize Sentry's React Native SDK" + private const val VALID_OPTIONS = "sentry.options.json" + private const val INVALID_OPTIONS = "invalid.options.json" + private const val INVALID_JSON = "invalid.options.txt" + private const val MISSING = "non-existing-file" + + private val validConfig = + OptionsConfiguration { options -> + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + private val invalidConfig = + OptionsConfiguration { options -> + options.dsn = "invalid-dsn" + } + private val emptyConfig = OptionsConfiguration {} + } + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + } + + @After + fun tearDown() { + Sentry.close() + } + + @Test + fun initialisesSuccessfullyWithDefaultValidJsonFile() { // sentry.options.json + RNSentrySDK.init(context) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndDefaultValidJsonFile() { + RNSentrySDK.init(context, validConfig) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndInvalidJsonFile() { + RNSentrySDK.init(context, validConfig, INVALID_OPTIONS, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndMissingJsonFile() { + RNSentrySDK.init(context, validConfig, MISSING, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithValidConfigurationAndErrorInParsingJsonFile() { + RNSentrySDK.init(context, validConfig, INVALID_JSON, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun initialisesSuccessfullyWithNoConfigurationAndValidJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + assertTrue(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithNoConfigurationAndInvalidJsonFile() { + try { + RNSentrySDK.init(context, emptyConfig, INVALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigAndInvalidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig, INVALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigAndValidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig, VALID_OPTIONS, logger) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun failsToInitialiseWithInvalidConfigurationAndDefaultValidJsonFile() { + try { + RNSentrySDK.init(context, invalidConfig) + } catch (e: Exception) { + assertEquals(INITIALISATION_ERROR, e.message) + } + assertFalse(Sentry.isEnabled()) + } + + @Test + fun defaultsAndFinalsAreSetWithValidJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + verifyDefaults(actualOptions) + verifyFinals(actualOptions) + // options file + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + @Test + fun defaultsAndFinalsAreSetWithValidConfiguration() { + RNSentrySDK.init(context, validConfig, MISSING, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + verifyDefaults(actualOptions) + verifyFinals(actualOptions) + // configuration + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + @Test + fun defaultsOverrideOptionsJsonFile() { + RNSentrySDK.init(context, emptyConfig, VALID_OPTIONS, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + assertNull(actualOptions.tracesSampleRate) + assertEquals(false, actualOptions.enableTracing) + } + + @Test + fun configurationOverridesDefaultOptions() { + val validConfig = + OptionsConfiguration { options -> + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.tracesSampleRate = 0.5 + options.enableTracing = true + } + RNSentrySDK.init(context, validConfig, MISSING, logger) + val actualOptions = Sentry.getCurrentHub().options as SentryAndroidOptions + assertEquals(0.5, actualOptions.tracesSampleRate) + assertEquals(true, actualOptions.enableTracing) + assert(actualOptions.dsn == "https://abcd@efgh.ingest.sentry.io/123456") + } + + private fun verifyDefaults(actualOptions: SentryAndroidOptions) { + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) + assertEquals( + io.sentry.android.core.BuildConfig.VERSION_NAME, + actualOptions.sdkVersion?.version, + ) + val pack = actualOptions.sdkVersion?.packages?.first { it.name == RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME } + assertNotNull(pack) + assertEquals(RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION, pack?.version) + assertNull(actualOptions.tracesSampleRate) + assertNull(actualOptions.tracesSampler) + assertEquals(false, actualOptions.enableTracing) + } + + private fun verifyFinals(actualOptions: SentryAndroidOptions) { + val event = + SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + val result = actualOptions.beforeSend?.execute(event, Hint()) + assertNotNull(result) + assertEquals("android", result?.getTag("event.origin")) + assertEquals("java", result?.getTag("event.environment")) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt new file mode 100644 index 0000000000..699fd81ccb --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCompositeOptionsConfigurationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.react + +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.android.core.SentryAndroidOptions +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(JUnit4::class) +class RNSentryCompositeOptionsConfigurationTest { + @Test + fun `configure should call base and overriding configurations`() { + val baseConfig: OptionsConfiguration = mock() + val overridingConfig: OptionsConfiguration = mock() + + val compositeConfig = RNSentryCompositeOptionsConfiguration(baseConfig, overridingConfig) + val options = SentryAndroidOptions() + compositeConfig.configure(options) + + verify(baseConfig).configure(options) + verify(overridingConfig).configure(options) + } + + @Test + fun `configure should apply base configuration and override values`() { + val baseConfig = + OptionsConfiguration { options -> + options.dsn = "https://base-dsn@sentry.io" + options.isDebug = false + options.release = "some-release" + } + val overridingConfig = + OptionsConfiguration { options -> + options.dsn = "https://over-dsn@sentry.io" + options.isDebug = true + options.environment = "production" + } + + val compositeConfig = RNSentryCompositeOptionsConfiguration(baseConfig, overridingConfig) + val options = SentryAndroidOptions() + compositeConfig.configure(options) + + assert(options.dsn == "https://over-dsn@sentry.io") // overridden value + assert(options.isDebug) // overridden value + assert(options.release == "some-release") // base value not overridden + assert(options.environment == "production") // overridden value not in base + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt index adffbf78ad..34af996a76 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt @@ -3,20 +3,13 @@ package io.sentry.react import android.content.pm.PackageInfo import android.content.pm.PackageManager import com.facebook.react.bridge.Arguments -import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.WritableMap -import com.facebook.react.common.JavascriptException -import io.sentry.Breadcrumb import io.sentry.ILogger import io.sentry.SentryLevel -import io.sentry.android.core.SentryAndroidOptions import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -103,163 +96,4 @@ class RNSentryModuleImplTest { val capturedMap = writableMapCaptor.value assertEquals(false, capturedMap.getBoolean("has_fetched")) } - - @Test - fun `when the spotlight option is enabled, the spotlight SentryAndroidOption is set to true and the default url is used`() { - val options = - JavaOnlyMap.of( - "spotlight", - true, - "defaultSidecarUrl", - "http://localhost:8969/teststream", - ) - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assert(actualOptions.isEnableSpotlight) - assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) - } - - @Test - fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { - val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assert(actualOptions.isEnableSpotlight) - assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) - } - - @Test - fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { - val options = JavaOnlyMap.of("spotlight", false) - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, options, logger) - assertFalse(actualOptions.isEnableSpotlight) - } - - @Test - fun `the JavascriptException is added to the ignoredExceptionsForType list on initialisation`() { - val actualOptions = SentryAndroidOptions() - module.getSentryAndroidOptions(actualOptions, JavaOnlyMap.of(), logger) - assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) - } - - @Test - fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - "http://localhost:8081", - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "https://def.ingest.sentry.io/1234567") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertNull("Breadcrumb should be filtered out", result) - } - - @Test - fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() { - val mockDevServerUrl = "http://localhost:8081" - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - mockDevServerUrl, - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", mockDevServerUrl) - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertNull("Breadcrumb should be filtered out", result) - } - - @Test - fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() { - val options = SentryAndroidOptions() - val rnOptions = - JavaOnlyMap.of( - "dsn", - "https://abc@def.ingest.sentry.io/1234567", - "devServerUrl", - "http://localhost:8081", - ) - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { - val options = SentryAndroidOptions() - module.getSentryAndroidOptions(options, JavaOnlyMap(), logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { - val options = SentryAndroidOptions() - val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } - - @Test - fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { - val options = SentryAndroidOptions() - val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") - module.getSentryAndroidOptions(options, rnOptions, logger) - - val breadcrumb = - Breadcrumb().apply { - type = "http" - setData("url", "http://testurl.com/service") - } - - val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) - - assertEquals(breadcrumb, result) - } } diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt new file mode 100644 index 0000000000..fa177159e5 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryStartTest.kt @@ -0,0 +1,251 @@ +package io.sentry.react + +import android.app.Activity +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.common.JavascriptException +import io.sentry.Breadcrumb +import io.sentry.ILogger +import io.sentry.SentryEvent +import io.sentry.android.core.CurrentActivityHolder +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.protocol.SdkVersion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations + +@RunWith(JUnit4::class) +class RNSentryStartTest { + private lateinit var logger: ILogger + + private lateinit var activity: Activity + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + logger = mock(ILogger::class.java) + activity = mock(Activity::class.java) + } + + @Test + fun `when the spotlight option is enabled, the spotlight SentryAndroidOption is set to true and the default url is used`() { + val options = + JavaOnlyMap.of( + "spotlight", + true, + "defaultSidecarUrl", + "http://localhost:8969/teststream", + ) + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) + assert(actualOptions.isEnableSpotlight) + assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) + } + + @Test + fun `when the spotlight url is passed, the spotlight is enabled for the given url`() { + val options = JavaOnlyMap.of("spotlight", "http://localhost:8969/teststream") + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) + assert(actualOptions.isEnableSpotlight) + assertEquals("http://localhost:8969/teststream", actualOptions.spotlightConnectionUrl) + } + + @Test + fun `when the spotlight option is disabled, the spotlight SentryAndroidOption is set to false`() { + val options = JavaOnlyMap.of("spotlight", false) + val actualOptions = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(actualOptions, options, logger) + assertFalse(actualOptions.isEnableSpotlight) + } + + @Test + fun `beforeBreadcrumb callback filters out Sentry DSN requests breadcrumbs`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + "http://localhost:8081", + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "https://def.ingest.sentry.io/1234567") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertNull("Breadcrumb should be filtered out", result) + } + + @Test + fun `beforeBreadcrumb callback filters out dev server breadcrumbs`() { + val mockDevServerUrl = "http://localhost:8081" + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + mockDevServerUrl, + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", mockDevServerUrl) + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertNull("Breadcrumb should be filtered out", result) + } + + @Test + fun `beforeBreadcrumb callback does not filter out non dev server or dsn breadcrumbs`() { + val options = SentryAndroidOptions() + val rnOptions = + JavaOnlyMap.of( + "dsn", + "https://abc@def.ingest.sentry.io/1234567", + "devServerUrl", + "http://localhost:8081", + ) + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url and dsn are not passed`() { + val options = SentryAndroidOptions() + RNSentryStart.getSentryAndroidOptions(options, JavaOnlyMap(), logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url is not passed and the dsn does not match`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of("dsn", "https://abc@def.ingest.sentry.io/1234567") + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the breadcrumb is not filtered out when the dev server url does not match and the dsn is not passed`() { + val options = SentryAndroidOptions() + val rnOptions = JavaOnlyMap.of("devServerUrl", "http://localhost:8081") + RNSentryStart.getSentryAndroidOptions(options, rnOptions, logger) + + val breadcrumb = + Breadcrumb().apply { + type = "http" + setData("url", "http://testurl.com/service") + } + + val result = options.beforeBreadcrumb?.execute(breadcrumb, mock()) + + assertEquals(breadcrumb, result) + } + + @Test + fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java)) + } + + @Test + fun `the sdk version information is added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name) + assertEquals( + io.sentry.android.core.BuildConfig.VERSION_NAME, + actualOptions.sdkVersion?.version, + ) + assertEquals(true, actualOptions.sdkVersion?.packages?.isNotEmpty()) + assertEquals( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + actualOptions.sdkVersion + ?.packages + ?.last() + ?.name, + ) + assertEquals( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION, + actualOptions.sdkVersion + ?.packages + ?.last() + ?.version, + ) + } + + @Test + fun `the tracing options are added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertNull(actualOptions.tracesSampleRate) + assertNull(actualOptions.tracesSampler) + assertEquals(false, actualOptions.enableTracing) + } + + @Test + fun `the current activity is added to the initialisation options with react defaults`() { + val actualOptions = SentryAndroidOptions() + RNSentryStart.updateWithReactDefaults(actualOptions, activity) + assertEquals(activity, CurrentActivityHolder.getInstance().activity) + } + + @Test + fun `beforeSend callback that sets event tags is set with react finals`() { + val options = SentryAndroidOptions() + val event = + SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") } + + RNSentryStart.updateWithReactFinals(options) + val result = options.beforeSend?.execute(event, mock()) + + assertNotNull(result) + assertEquals("android", result?.getTag("event.origin")) + assertEquals("java", result?.getTag("event.environment")) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index 48489ab216..d922dce766 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; }; 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; }; 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; }; + 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */; }; + 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */; }; + 339C6C482D3FD9A700CA72ED /* invalid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C462D3FD91900CA72ED /* invalid.options.json */; }; + 339C6C492D3FD9A700CA72ED /* invalid.options.txt in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C452D3FD90200CA72ED /* invalid.options.txt */; }; + 339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */ = {isa = PBXBuildFile; fileRef = 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */; }; 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; }; 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; @@ -28,6 +33,9 @@ 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryCocoaTesterTests-Bridging-Header.h"; sourceTree = ""; }; 3339C47F2D6625260088EB3A /* RNSentry+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentry+Test.h"; sourceTree = ""; }; 3339C4802D6625570088EB3A /* RNSentryUserTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryUserTests.mm; sourceTree = ""; }; + 333B58A82D35BA93000F8D04 /* RNSentryStart.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryStart.h; path = ../ios/RNSentryStart.h; sourceTree = SOURCE_ROOT; }; + 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "RNSentryStart+Test.h"; path = "RNSentryCocoaTesterTests/RNSentryStart+Test.h"; sourceTree = SOURCE_ROOT; }; + 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentrySDK.h; path = ../ios/RNSentrySDK.h; sourceTree = SOURCE_ROOT; }; 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RNSentryReplayBreadcrumbConverterTests.swift; sourceTree = ""; }; 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; }; 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; }; @@ -38,6 +46,13 @@ 338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; }; 33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; }; 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; }; + 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartTests.swift; sourceTree = ""; }; + 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryVersion.h; path = ../ios/RNSentryVersion.h; sourceTree = SOURCE_ROOT; }; + 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryStartFromFileTests.swift; sourceTree = ""; }; + 339C6C442D3FD62D00CA72ED /* RNSentrySDK+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentrySDK+Test.h"; sourceTree = ""; }; + 339C6C452D3FD90200CA72ED /* invalid.options.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = invalid.options.txt; sourceTree = ""; }; + 339C6C462D3FD91900CA72ED /* invalid.options.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = invalid.options.json; sourceTree = ""; }; + 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = valid.options.json; sourceTree = ""; }; 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryFramesTrackerListenerTests.m; sourceTree = ""; }; 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryFramesTrackerListenerTests.h; sourceTree = ""; }; 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; @@ -72,6 +87,7 @@ 3360896929524163007C7730 = { isa = PBXGroup; children = ( + 339C6C432D3FD41C00CA72ED /* TestAssets */, 33AFE0122B8F319000AAB120 /* RNSentry */, 3360899029524164007C7730 /* RNSentryCocoaTesterTests */, 3360897329524163007C7730 /* Products */, @@ -91,6 +107,8 @@ 3360899029524164007C7730 /* RNSentryCocoaTesterTests */ = { isa = PBXGroup; children = ( + 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */, + 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */, 3339C47F2D6625260088EB3A /* RNSentry+Test.h */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, @@ -118,9 +136,24 @@ path = Replay; sourceTree = ""; }; + 339C6C432D3FD41C00CA72ED /* TestAssets */ = { + isa = PBXGroup; + children = ( + 339C6C4A2D3FD9AB00CA72ED /* valid.options.json */, + 339C6C462D3FD91900CA72ED /* invalid.options.json */, + 339C6C452D3FD90200CA72ED /* invalid.options.txt */, + ); + path = TestAssets; + sourceTree = ""; + }; 33AFE0122B8F319000AAB120 /* RNSentry */ = { isa = PBXGroup; children = ( + 339C6C442D3FD62D00CA72ED /* RNSentrySDK+Test.h */, + 339C6C3D2D3FA04D00CA72ED /* RNSentryVersion.h */, + 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */, + 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */, + 333B58A82D35BA93000F8D04 /* RNSentryStart.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, @@ -151,6 +184,7 @@ 3360898929524164007C7730 /* Sources */, BB7D14838753E6599863899B /* Frameworks */, CC7959F3721CB3AD7CB6A047 /* [CP] Copy Pods Resources */, + 339C6C472D3FD99900CA72ED /* Resources */, ); buildRules = ( ); @@ -195,6 +229,19 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 339C6C472D3FD99900CA72ED /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 339C6C482D3FD9A700CA72ED /* invalid.options.json in Resources */, + 339C6C4B2D3FD9B200CA72ED /* valid.options.json in Resources */, + 339C6C492D3FD9A700CA72ED /* invalid.options.txt in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ 30F19D4E16BEEFEC68733838 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -244,7 +291,9 @@ files = ( AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */, 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, + 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, + 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index bc2bdd0304..08fddcbf8e 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -7,3 +7,6 @@ #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" +#import "RNSentrySDK+Test.h" +#import "RNSentryStart.h" +#import "RNSentryVersion.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h new file mode 100644 index 0000000000..fcdfe7872b --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStart+Test.h @@ -0,0 +1,8 @@ +#import "RNSentryStart.h" + +@interface +RNSentryStart (Test) + ++ (void)setEventOriginTag:(SentryEvent *)event; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift new file mode 100644 index 0000000000..e0269a5961 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartFromFileTests.swift @@ -0,0 +1,115 @@ +import XCTest + +final class RNSentryStartFromFileTests: XCTestCase { + + func testNoThrowOnMissingOptionsFile() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getNonExistingOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNil(actualOptions.parsedDsn) + } + + func testNoThrowOnInvalidFileType() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getInvalidOptionsTypePath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNil(actualOptions.parsedDsn) + } + + func testNoThrowOnInvalidOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getInvalidOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNotNil(actualOptions.parsedDsn) + XCTAssertEqual(actualOptions.environment, "environment-from-invalid-file") + } + + func testLoadValidOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getValidOptionsPath(), configureOptions: { _ in + wasConfigurationCalled = true + }) + + XCTAssertTrue(wasConfigurationCalled) + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertNil(actualOptions.dsn) + XCTAssertNotNil(actualOptions.parsedDsn) + XCTAssertEqual(actualOptions.environment, "environment-from-valid-file") + } + + func testOptionsFromFileInConfigureOptions() { + var wasConfigurationCalled = false + + RNSentrySDK.start(getValidOptionsPath()) { options in + wasConfigurationCalled = true + XCTAssertEqual(options.environment, "environment-from-valid-file") + } + + XCTAssertTrue(wasConfigurationCalled) + } + + func testOptionsOverwrittenInConfigureOptions() { + RNSentrySDK.start(getValidOptionsPath()) { options in + options.environment = "new-environment" + } + + let actualOptions = PrivateSentrySDKOnly.options + XCTAssertEqual(actualOptions.environment, "new-environment") + } + + func getNonExistingOptionsPath() -> String { + return "/non-existing.options.json" + } + + func getInvalidOptionsTypePath() -> String { + guard let path = getTestBundle().path(forResource: "invalid.options", ofType: "txt") else { + fatalError("Could not get invalid type options path") + } + return path + } + + func getInvalidOptionsPath() -> String { + guard let path = getTestBundle().path(forResource: "invalid.options", ofType: "json") else { + fatalError("Could not get invalid options path") + } + return path + } + + func getValidOptionsPath() -> String { + guard let path = getTestBundle().path(forResource: "valid.options", ofType: "json") else { + fatalError("Could not get invalid options path") + } + return path + } + + func getTestBundle() -> Bundle { + let maybeBundle = Bundle.allBundles.first(where: { $0.bundlePath.hasSuffix(".xctest") }) + guard let bundle = maybeBundle else { + fatalError("Could not find test bundle") + } + return bundle + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift new file mode 100644 index 0000000000..b9d12200cf --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryStartTests.swift @@ -0,0 +1,248 @@ +import XCTest + +final class RNSentryStartTests: XCTestCase { + + func testStartDoesNotThrowWithoutConfigure() { + RNSentrySDK.start(configureOptions: nil) + } + + func assertReactDefaults(_ actualOptions: Options?) { + XCTAssertFalse(actualOptions!.enableCaptureFailedRequests) + XCTAssertNil(actualOptions!.tracesSampleRate) + XCTAssertNil(actualOptions!.tracesSampler) + XCTAssertFalse(actualOptions!.enableTracing) + } + + func testStartSetsReactDeafults() { + var actualOptions: Options? + + RNSentrySDK.start { options in + actualOptions = options + } + + XCTAssertNotNil(actualOptions, "start have not provided default options or have not executed configure callback") + assertReactDefaults(actualOptions) + } + + func testAutoStartSetsReactDefaults() throws { + try startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + + let actualOptions = PrivateSentrySDKOnly.options + assertReactDefaults(actualOptions) + } + + func testStartEnablesHybridTracing() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + }, + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.enableAutoPerformanceTracing = true + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableAutoPerformanceTracing": true + ]) + } + ] + + // Test each implementation + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + XCTAssertTrue(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) + XCTAssertTrue(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + } + } + + func testStartDisablesHybridTracing() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.enableAutoPerformanceTracing = false + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "enableAutoPerformanceTracing": false + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + XCTAssertFalse(PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode) + XCTAssertFalse(PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode) + } + } + + func testStartIgnoresUnhandledJsExceptions() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + let actualEvent = actualOptions.beforeSend!(createUnhandledJsExceptionEvent()) + + XCTAssertNil(actualEvent) + } + } + + func testStartSetsNativeEventOrigin() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualOptions = PrivateSentrySDKOnly.options + + let actualEvent = actualOptions.beforeSend!(createNativeEvent()) + + XCTAssertNotNil(actualEvent) + XCTAssertNotNil(actualEvent!.tags) + XCTAssertEqual(actualEvent!.tags!["event.origin"], "ios") + XCTAssertEqual(actualEvent!.tags!["event.environment"], "native") + } + } + + func testStartDoesNotOverwriteUserBeforeSend() { + var executed = false + + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + options.beforeSend = { event in + executed = true + return event + } + } + + PrivateSentrySDKOnly.options.beforeSend!(genericEvent()) + + XCTAssertTrue(executed) + } + + func testStartSetsHybridSdkName() throws { + let testCases: [() throws -> Void] = [ + { + RNSentrySDK.start { options in + options.dsn = "https://abcd@efgh.ingest.sentry.io/123456" + } + }, + { + try self.startFromRN(options: [ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456" + ]) + } + ] + + for startMethod in testCases { + try startMethod() + + let actualEvent = captuteTestEvent() + + XCTAssertNotNil(actualEvent) + XCTAssertNotNil(actualEvent!.sdk) + XCTAssertEqual(actualEvent!.sdk!["name"] as! String, NATIVE_SDK_NAME) + + let packages = actualEvent!.sdk!["packages"] as! [[String: String]] + let reactPackage = packages.first { $0["name"] == REACT_NATIVE_SDK_PACKAGE_NAME } + + XCTAssertNotNil(reactPackage) + XCTAssertEqual(reactPackage!["name"], REACT_NATIVE_SDK_PACKAGE_NAME) + XCTAssertEqual(reactPackage!["version"], REACT_NATIVE_SDK_PACKAGE_VERSION) + } + } + + func startFromRN(options: [String: Any]) throws { + var error: NSError? + RNSentryStart.start(options: options, error: &error) + + if let error = error { + throw error + } + } + + func createUnhandledJsExceptionEvent() -> Event { + let event = Event() + event.exceptions = [] + event.exceptions!.append(Exception(value: "Test", type: "Unhandled JS Exception: undefined is not a function")) + return event + } + + func createNativeEvent() -> Event { + let event = Event() + event.sdk = [ + "name": NATIVE_SDK_NAME, + "version": "1.2.3" + ] + return event + } + + func genericEvent() -> Event { + return Event() + } + + func captuteTestEvent() -> Event? { + var actualEvent: Event? + + // This is the closest to the sent event we can get using the actual Sentry start method + let originalBeforeSend = PrivateSentrySDKOnly.options.beforeSend + PrivateSentrySDKOnly.options.beforeSend = { event in + if let originalBeforeSend = originalBeforeSend { + let processedEvent = originalBeforeSend(event) + actualEvent = processedEvent + return processedEvent + } + actualEvent = event + return event + } + + SentrySDK.capture(message: "Test") + + return actualEvent + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm index 6e63793b85..abe2ae70ce 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTests.mm @@ -1,6 +1,8 @@ #import "RNSentryTests.h" +#import "RNSentryStart+Test.h" #import #import +#import #import #import #import @@ -11,9 +13,8 @@ @interface RNSentryInitNativeSdkTests : XCTestCase @implementation RNSentryInitNativeSdkTests -- (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties +- (void)testStartWithDictionaryRemovesPerformanceProperties { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @@ -25,9 +26,8 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties , @"enableTracing" : @YES, } ; -SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; - +[RNSentryStart startWithOptions:mockedReactNativeDictionary error:&error]; +SentryOptions *actualOptions = PrivateSentrySDKOnly.options; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertNotNil( @@ -40,14 +40,13 @@ - (void)testCreateOptionsWithDictionaryRemovesPerformanceProperties - (void)testCaptureFailedRequestsIsDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + [RNSentryStart startWithOptions:mockedReactNativeDictionary error:&error]; + SentryOptions *actualOptions = PrivateSentrySDKOnly.options; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); @@ -56,14 +55,13 @@ - (void)testCaptureFailedRequestsIsDisabled - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDefault { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], true, @@ -72,14 +70,13 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDefault - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDefault { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual( @@ -88,15 +85,14 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDefault - (void)testCreateOptionsWithDictionaryNativeCrashHandlingEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableNativeCrashHandling" : @YES, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], true, @@ -105,15 +101,14 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingEnabled - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableAutoPerformanceTracing" : @YES, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual( @@ -122,15 +117,14 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingEnabled - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableNativeCrashHandling" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual([actualOptions.integrations containsObject:@"SentryCrashIntegration"], false, @@ -139,15 +133,14 @@ - (void)testCreateOptionsWithDictionaryNativeCrashHandlingDisabled - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"enableAutoPerformanceTracing" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertEqual(actualOptions.enableAutoPerformanceTracing, false, @@ -156,7 +149,6 @@ - (void)testCreateOptionsWithDictionaryAutoPerformanceTracingDisabled - (void)testCreateOptionsWithDictionarySpotlightEnabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @@ -164,8 +156,8 @@ - (void)testCreateOptionsWithDictionarySpotlightEnabled @"spotlight" : @YES, @"defaultSidecarUrl" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertTrue(actualOptions.enableSpotlight, @"Did not enable spotlight"); @@ -174,7 +166,6 @@ - (void)testCreateOptionsWithDictionarySpotlightEnabled - (void)testCreateOptionsWithDictionarySpotlightOne { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @@ -182,8 +173,8 @@ - (void)testCreateOptionsWithDictionarySpotlightOne @"spotlight" : @1, @"defaultSidecarUrl" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertTrue(actualOptions.enableSpotlight, @"Did not enable spotlight"); @@ -192,15 +183,14 @@ - (void)testCreateOptionsWithDictionarySpotlightOne - (void)testCreateOptionsWithDictionarySpotlightUrl { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"spotlight" : @"http://localhost:8969/teststream", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertTrue(actualOptions.enableSpotlight, @"Did not enable spotlight"); @@ -209,15 +199,14 @@ - (void)testCreateOptionsWithDictionarySpotlightUrl - (void)testCreateOptionsWithDictionarySpotlightDisabled { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"spotlight" : @NO, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertFalse(actualOptions.enableSpotlight, @"Did not disable spotlight"); @@ -225,15 +214,14 @@ - (void)testCreateOptionsWithDictionarySpotlightDisabled - (void)testCreateOptionsWithDictionarySpotlightZero { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"https://abcd@efgh.ingest.sentry.io/123456", @"spotlight" : @0, }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNotNil(actualOptions, @"Did not create sentry options"); XCTAssertNil(error, @"Should not pass no error"); XCTAssertFalse(actualOptions.enableSpotlight, @"Did not disable spotlight"); @@ -241,14 +229,13 @@ - (void)testCreateOptionsWithDictionarySpotlightZero - (void)testPassesErrorOnWrongDsn { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedReactNativeDictionary = @{ @"dsn" : @"not_a_valid_dsn", }; - SentryOptions *actualOptions = [rnSentry createOptionsWithDictionary:mockedReactNativeDictionary - error:&error]; + SentryOptions *actualOptions = + [RNSentryStart createOptionsWithDictionary:mockedReactNativeDictionary error:&error]; XCTAssertNil(actualOptions, @"Created invalid sentry options"); XCTAssertNotNil(error, @"Did not created error on invalid dsn"); @@ -256,14 +243,14 @@ - (void)testPassesErrorOnWrongDsn - (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : @"http://localhost:8081" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -276,14 +263,14 @@ - (void)testBeforeBreadcrumbsCallbackFiltersOutSentryDsnRequestBreadcrumbs - (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSString *mockDevServer = @"http://localhost:8081"; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : mockDevServer }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -296,14 +283,14 @@ - (void)testBeforeBreadcrumbsCallbackFiltersOutDevServerRequestBreadcrumbs - (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBreadcrumbs { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ @"dsn" : @"https://abc@def.ingest.sentry.io/1234567", @"devServerUrl" : @"http://localhost:8081" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -316,13 +303,13 @@ - (void)testBeforeBreadcrumbsCallbackDoesNotFiltersOutNonDevServerOrDsnRequestBr - (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedAndDsnDoesNotMatch { - RNSentry *rnSentry = [[RNSentry alloc] init]; NSError *error = nil; NSDictionary *_Nonnull mockedDictionary = @{ // dsn is always validated in SentryOptions initialization @"dsn" : @"https://abc@def.ingest.sentry.io/1234567" }; - SentryOptions *options = [rnSentry createOptionsWithDictionary:mockedDictionary error:&error]; + SentryOptions *options = [RNSentryStart createOptionsWithDictionary:mockedDictionary + error:&error]; SentryBreadcrumb *breadcrumb = [[SentryBreadcrumb alloc] init]; breadcrumb.type = @"http"; @@ -335,13 +322,12 @@ - (void)testBeforeBreadcrumbsCallbackKeepsBreadcrumbWhenDevServerUrlIsNotPassedA - (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags { - RNSentry *rnSentry = [[RNSentry alloc] init]; SentryEvent *testEvent = [[SentryEvent alloc] init]; testEvent.sdk = @{ @"name" : @"sentry.cocoa.react-native", }; - [rnSentry setEventOriginTag:testEvent]; + [RNSentryStart setEventOriginTag:testEvent]; XCTAssertEqual(testEvent.tags[@"event.origin"], @"ios"); XCTAssertEqual(testEvent.tags[@"event.environment"], @"native"); @@ -349,7 +335,6 @@ - (void)testEventFromSentryCocoaReactNativeHasOriginAndEnvironmentTags - (void)testEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritten { - RNSentry *rnSentry = [[RNSentry alloc] init]; SentryEvent *testEvent = [[SentryEvent alloc] init]; testEvent.sdk = @{ @"name" : @"sentry.cocoa.react-native", @@ -359,7 +344,7 @@ - (void)testEventFromSentryReactNativeOriginAndEnvironmentTagsAreOverwritten @"event.environment" : @"testEventEnvironmentTag", }; - [rnSentry setEventOriginTag:testEvent]; + [RNSentryStart setEventOriginTag:testEvent]; XCTAssertEqual(testEvent.tags[@"event.origin"], @"ios"); XCTAssertEqual(testEvent.tags[@"event.environment"], @"native"); diff --git a/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h b/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h new file mode 100644 index 0000000000..06da31b42d --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentrySDK+Test.h @@ -0,0 +1,9 @@ +#import "RNSentrySDK.h" + +@interface +RNSentrySDK (Test) + ++ (void)start:(NSString *)path + configureOptions:(void (^)(SentryOptions *_Nonnull options))configureOptions; + +@end diff --git a/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json new file mode 100644 index 0000000000..bf8f2be64c --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.json @@ -0,0 +1,5 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "environment": "environment-from-invalid-file", + "invalid-option": 123 +} diff --git a/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt new file mode 100644 index 0000000000..601553b507 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/invalid.options.txt @@ -0,0 +1 @@ +invalid-options diff --git a/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json b/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json new file mode 100644 index 0000000000..641087d5e8 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/TestAssets/valid.options.json @@ -0,0 +1,4 @@ +{ + "dsn": "https://abcd@efgh.ingest.sentry.io/123456", + "environment": "environment-from-valid-file" +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java new file mode 100644 index 0000000000..0069abb660 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryCompositeOptionsConfiguration.java @@ -0,0 +1,25 @@ +package io.sentry.react; + +import io.sentry.Sentry.OptionsConfiguration; +import io.sentry.android.core.SentryAndroidOptions; +import java.util.List; +import org.jetbrains.annotations.NotNull; + +class RNSentryCompositeOptionsConfiguration implements OptionsConfiguration { + private final @NotNull List> configurations; + + @SafeVarargs + protected RNSentryCompositeOptionsConfiguration( + @NotNull OptionsConfiguration... configurations) { + this.configurations = List.of(configurations); + } + + @Override + public void configure(@NotNull SentryAndroidOptions options) { + for (OptionsConfiguration configuration : configurations) { + if (configuration != null) { + configuration.configure(options); + } + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java new file mode 100644 index 0000000000..44ec324eed --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonConverter.java @@ -0,0 +1,76 @@ +package io.sentry.react; + +import com.facebook.react.bridge.JavaOnlyArray; +import com.facebook.react.bridge.JavaOnlyMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import java.util.Iterator; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +final class RNSentryJsonConverter { + public static final String NAME = "RNSentry.RNSentryJsonConverter"; + + private static final ILogger logger = new AndroidLogger(NAME); + + private RNSentryJsonConverter() { + throw new AssertionError("Utility class should not be instantiated"); + } + + @Nullable + static WritableMap convertToWritable(@NotNull JSONObject jsonObject) { + try { + WritableMap writableMap = new JavaOnlyMap(); + Iterator iterator = jsonObject.keys(); + while (iterator.hasNext()) { + String key = iterator.next(); + Object value = jsonObject.get(key); + if (value instanceof Float || value instanceof Double) { + writableMap.putDouble(key, jsonObject.getDouble(key)); + } else if (value instanceof Number) { + writableMap.putInt(key, jsonObject.getInt(key)); + } else if (value instanceof String) { + writableMap.putString(key, jsonObject.getString(key)); + } else if (value instanceof JSONObject) { + writableMap.putMap(key, convertToWritable(jsonObject.getJSONObject(key))); + } else if (value instanceof JSONArray) { + writableMap.putArray(key, convertToWritable(jsonObject.getJSONArray(key))); + } else if (value == JSONObject.NULL) { + writableMap.putNull(key); + } + } + return writableMap; + } catch (JSONException e) { + logger.log(SentryLevel.ERROR, "Error parsing json object:" + e.getMessage()); + return null; + } + } + + @NotNull + static WritableArray convertToWritable(@NotNull JSONArray jsonArray) throws JSONException { + WritableArray writableArray = new JavaOnlyArray(); + for (int i = 0; i < jsonArray.length(); i++) { + Object value = jsonArray.get(i); + if (value instanceof Float || value instanceof Double) { + writableArray.pushDouble(jsonArray.getDouble(i)); + } else if (value instanceof Number) { + writableArray.pushInt(jsonArray.getInt(i)); + } else if (value instanceof String) { + writableArray.pushString(jsonArray.getString(i)); + } else if (value instanceof JSONObject) { + writableArray.pushMap(convertToWritable(jsonArray.getJSONObject(i))); + } else if (value instanceof JSONArray) { + writableArray.pushArray(convertToWritable(jsonArray.getJSONArray(i))); + } else if (value == JSONObject.NULL) { + writableArray.pushNull(); + } + } + return writableArray; + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java new file mode 100644 index 0000000000..9c7cf5d3ff --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryJsonUtils.java @@ -0,0 +1,41 @@ +package io.sentry.react; + +import android.content.Context; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; + +final class RNSentryJsonUtils { + private RNSentryJsonUtils() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static @Nullable JSONObject getOptionsFromConfigurationFile( + @NotNull Context context, @NotNull String fileName, @NotNull ILogger logger) { + try (InputStream inputStream = context.getAssets().open(fileName); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + String configFileContent = stringBuilder.toString(); + return new JSONObject(configFileContent); + + } catch (Exception e) { + logger.log( + SentryLevel.ERROR, + "Failed to read configuration file. Please make sure " + + fileName + + " exists in the root of your project.", + e); + return null; + } + } +} 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 38ce09c84c..52587e7cb8 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 @@ -21,13 +21,11 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; -import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.common.JavascriptException; import com.facebook.react.modules.core.DeviceEventManagerModule; import io.sentry.Breadcrumb; import io.sentry.HubAdapter; @@ -35,25 +33,16 @@ import io.sentry.IScope; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; -import io.sentry.Integration; import io.sentry.Sentry; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; -import io.sentry.SentryEvent; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.SentryReplayOptions; -import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.AndroidProfiler; -import io.sentry.android.core.AnrIntegration; -import io.sentry.android.core.BuildConfig; import io.sentry.android.core.BuildInfoProvider; -import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.InternalSentrySdk; -import io.sentry.android.core.NdkIntegration; -import io.sentry.android.core.SentryAndroid; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.ViewHierarchyEventProcessor; @@ -62,11 +51,8 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; -import io.sentry.protocol.SentryPackage; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; -import io.sentry.react.replay.RNSentryReplayMask; -import io.sentry.react.replay.RNSentryReplayUnmask; import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; @@ -79,8 +65,6 @@ import java.io.FileReader; import java.io.IOException; import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Iterator; @@ -180,216 +164,12 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) { } public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { - SentryAndroid.init( - this.getReactApplicationContext(), - options -> getSentryAndroidOptions(options, rnOptions, logger)); + RNSentryStart.startWithOptions( + this.getReactApplicationContext(), rnOptions, getCurrentActivity(), logger); promise.resolve(true); } - protected void getSentryAndroidOptions( - @NotNull SentryAndroidOptions options, @NotNull ReadableMap rnOptions, ILogger logger) { - @Nullable SdkVersion sdkVersion = options.getSdkVersion(); - if (sdkVersion == null) { - sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); - } else { - sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); - } - sdkVersion.addPackage( - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - - options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); - options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); - options.setSdkVersion(sdkVersion); - - if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { - options.setDebug(true); - } - if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) { - String dsn = rnOptions.getString("dsn"); - logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn)); - options.setDsn(dsn); - } else { - // SentryAndroid needs an empty string fallback for the dsn. - options.setDsn(""); - } - if (rnOptions.hasKey("sampleRate")) { - options.setSampleRate(rnOptions.getDouble("sampleRate")); - } - if (rnOptions.hasKey("sendClientReports")) { - options.setSendClientReports(rnOptions.getBoolean("sendClientReports")); - } - if (rnOptions.hasKey("maxBreadcrumbs")) { - options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs")); - } - if (rnOptions.hasKey("maxCacheItems")) { - options.setMaxCacheItems(rnOptions.getInt("maxCacheItems")); - } - if (rnOptions.hasKey("environment") && rnOptions.getString("environment") != null) { - options.setEnvironment(rnOptions.getString("environment")); - } - if (rnOptions.hasKey("release") && rnOptions.getString("release") != null) { - options.setRelease(rnOptions.getString("release")); - } - if (rnOptions.hasKey("dist") && rnOptions.getString("dist") != null) { - options.setDist(rnOptions.getString("dist")); - } - if (rnOptions.hasKey("enableAutoSessionTracking")) { - options.setEnableAutoSessionTracking(rnOptions.getBoolean("enableAutoSessionTracking")); - } - if (rnOptions.hasKey("sessionTrackingIntervalMillis")) { - options.setSessionTrackingIntervalMillis(rnOptions.getInt("sessionTrackingIntervalMillis")); - } - if (rnOptions.hasKey("shutdownTimeout")) { - options.setShutdownTimeoutMillis(rnOptions.getInt("shutdownTimeout")); - } - if (rnOptions.hasKey("enableNdkScopeSync")) { - options.setEnableScopeSync(rnOptions.getBoolean("enableNdkScopeSync")); - } - if (rnOptions.hasKey("attachStacktrace")) { - options.setAttachStacktrace(rnOptions.getBoolean("attachStacktrace")); - } - if (rnOptions.hasKey("attachThreads")) { - // JS use top level stacktrace and android attaches Threads which hides them so - // by default we hide. - options.setAttachThreads(rnOptions.getBoolean("attachThreads")); - } - if (rnOptions.hasKey("attachScreenshot")) { - options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); - } - if (rnOptions.hasKey("attachViewHierarchy")) { - options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); - } - if (rnOptions.hasKey("sendDefaultPii")) { - options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); - } - if (rnOptions.hasKey("maxQueueSize")) { - options.setMaxQueueSize(rnOptions.getInt("maxQueueSize")); - } - if (rnOptions.hasKey("enableNdk")) { - options.setEnableNdk(rnOptions.getBoolean("enableNdk")); - } - if (rnOptions.hasKey("spotlight")) { - if (rnOptions.getType("spotlight") == ReadableType.Boolean) { - options.setEnableSpotlight(rnOptions.getBoolean("spotlight")); - options.setSpotlightConnectionUrl(rnOptions.getString("defaultSidecarUrl")); - } else if (rnOptions.getType("spotlight") == ReadableType.String) { - options.setEnableSpotlight(true); - options.setSpotlightConnectionUrl(rnOptions.getString("spotlight")); - } - } - - SentryReplayOptions replayOptions = getReplayOptions(rnOptions); - options.setSessionReplay(replayOptions); - if (isReplayEnabled(replayOptions)) { - options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); - } - - // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - String dsn = getURLFromDSN(rnOptions.getString("dsn")); - String devServerUrl = rnOptions.getString("devServerUrl"); - options.setBeforeBreadcrumb( - (breadcrumb, hint) -> { - Object urlObject = breadcrumb.getData("url"); - String url = urlObject instanceof String ? (String) urlObject : ""; - if ("http".equals(breadcrumb.getType()) - && ((dsn != null && url.startsWith(dsn)) - || (devServerUrl != null && url.startsWith(devServerUrl)))) { - return null; - } - return breadcrumb; - }); - - // React native internally throws a JavascriptException. - // we want to ignore it on the native side to avoid sending it twice. - options.addIgnoredExceptionForType(JavascriptException.class); - - options.setBeforeSend( - (event, hint) -> { - setEventOriginTag(event); - addPackages(event, options.getSdkVersion()); - - return event; - }); - - if (rnOptions.hasKey("enableNativeCrashHandling") - && !rnOptions.getBoolean("enableNativeCrashHandling")) { - final List integrations = options.getIntegrations(); - for (final Integration integration : integrations) { - if (integration instanceof UncaughtExceptionHandlerIntegration - || integration instanceof AnrIntegration - || integration instanceof NdkIntegration) { - integrations.remove(integration); - } - } - } - logger.log( - SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); - - final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); - final Activity currentActivity = getCurrentActivity(); - if (currentActivity != null) { - currentActivityHolder.setActivity(currentActivity); - } - } - - private boolean isReplayEnabled(SentryReplayOptions replayOptions) { - return replayOptions.getSessionSampleRate() != null - || replayOptions.getOnErrorSampleRate() != null; - } - - private SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { - final SdkVersion replaySdkVersion = - new SdkVersion( - RNSentryVersion.REACT_NATIVE_SDK_NAME, - RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); - @NotNull - final SentryReplayOptions androidReplayOptions = - new SentryReplayOptions(false, replaySdkVersion); - - if (!(rnOptions.hasKey("replaysSessionSampleRate") - || rnOptions.hasKey("replaysOnErrorSampleRate"))) { - return androidReplayOptions; - } - - androidReplayOptions.setSessionSampleRate( - rnOptions.hasKey("replaysSessionSampleRate") - ? rnOptions.getDouble("replaysSessionSampleRate") - : null); - androidReplayOptions.setOnErrorSampleRate( - rnOptions.hasKey("replaysOnErrorSampleRate") - ? rnOptions.getDouble("replaysOnErrorSampleRate") - : null); - - if (!rnOptions.hasKey("mobileReplayOptions")) { - return androidReplayOptions; - } - @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); - if (rnMobileReplayOptions == null) { - return androidReplayOptions; - } - - androidReplayOptions.setMaskAllText( - !rnMobileReplayOptions.hasKey("maskAllText") - || rnMobileReplayOptions.getBoolean("maskAllText")); - androidReplayOptions.setMaskAllImages( - !rnMobileReplayOptions.hasKey("maskAllImages") - || rnMobileReplayOptions.getBoolean("maskAllImages")); - - final boolean redactVectors = - !rnMobileReplayOptions.hasKey("maskAllVectors") - || rnMobileReplayOptions.getBoolean("maskAllVectors"); - if (redactVectors) { - androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg - } - - androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); - androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); - - return androidReplayOptions; - } - public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } @@ -1009,51 +789,6 @@ public void crashedLastRun(Promise promise) { promise.resolve(Sentry.isCrashedLastRun()); } - private void setEventOriginTag(SentryEvent event) { - // We hardcode native-java as only java events are processed by the Android SDK. - SdkVersion sdk = event.getSdk(); - if (sdk != null) { - switch (sdk.getName()) { - case RNSentryVersion.NATIVE_SDK_NAME: - setEventEnvironmentTag(event, "native"); - break; - case RNSentryVersion.ANDROID_SDK_NAME: - setEventEnvironmentTag(event, "java"); - break; - default: - break; - } - } - } - - private void setEventEnvironmentTag(SentryEvent event, String environment) { - event.setTag("event.origin", "android"); - event.setTag("event.environment", environment); - } - - private void addPackages(SentryEvent event, SdkVersion sdk) { - SdkVersion eventSdk = event.getSdk(); - if (eventSdk != null - && "sentry.javascript.react-native".equals(eventSdk.getName()) - && sdk != null) { - List sentryPackages = sdk.getPackages(); - if (sentryPackages != null) { - for (SentryPackage sentryPackage : sentryPackages) { - eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); - } - } - - List integrations = sdk.getIntegrations(); - if (integrations != null) { - for (String integration : integrations) { - eventSdk.addIntegration(integration); - } - } - - event.setSdk(eventSdk); - } - } - private boolean checkAndroidXAvailability() { try { Class.forName("androidx.core.app.FrameMetricsAggregator"); @@ -1067,17 +802,4 @@ private boolean checkAndroidXAvailability() { private boolean isFrameMetricsAggregatorAvailable() { return androidXAvailable && frameMetricsAggregator != null; } - - public static @Nullable String getURLFromDSN(@Nullable String dsn) { - if (dsn == null) { - return null; - } - URI uri = null; - try { - uri = new URI(dsn); - } catch (URISyntaxException e) { - return null; - } - return uri.getScheme() + "://" + uri.getHost(); - } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java b/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java new file mode 100644 index 0000000000..ca219351fe --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentrySDK.java @@ -0,0 +1,68 @@ +package io.sentry.react; + +import android.content.Context; +import com.facebook.react.bridge.ReadableMap; +import io.sentry.ILogger; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.SentryAndroidOptions; +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; + +public final class RNSentrySDK { + private static final String CONFIGURATION_FILE = "sentry.options.json"; + private static final String NAME = "RNSentrySDK"; + + private static final ILogger logger = new AndroidLogger(NAME); + + private RNSentrySDK() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static void init( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration, + @NotNull String configurationFile, + @NotNull ILogger logger) { + try { + JSONObject jsonObject = + RNSentryJsonUtils.getOptionsFromConfigurationFile(context, configurationFile, logger); + if (jsonObject == null) { + RNSentryStart.startWithConfiguration(context, configuration); + return; + } + ReadableMap rnOptions = RNSentryJsonConverter.convertToWritable(jsonObject); + if (rnOptions == null) { + RNSentryStart.startWithConfiguration(context, configuration); + return; + } + RNSentryStart.startWithOptions(context, rnOptions, configuration, logger); + } catch (Exception e) { + logger.log( + SentryLevel.ERROR, "Failed to start Sentry with options from configuration file.", e); + throw new RuntimeException("Failed to initialize Sentry's React Native SDK", e); + } + } + + /** + * @experimental Start the Native Android SDK with the provided configuration options. Uses as a + * base configurations the `sentry.options.json` configuration file if it exists. + * @param context Android Context + * @param configuration configuration options + */ + public static void init( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration) { + init(context, configuration, CONFIGURATION_FILE, logger); + } + + /** + * @experimental Start the Native Android SDK with options from `sentry.options.json` + * configuration file. + * @param context Android Context + */ + public static void init(@NotNull final Context context) { + init(context, options -> {}, CONFIGURATION_FILE, logger); + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java new file mode 100644 index 0000000000..86699ced05 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -0,0 +1,365 @@ +package io.sentry.react; + +import android.app.Activity; +import android.content.Context; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.common.JavascriptException; +import io.sentry.ILogger; +import io.sentry.Integration; +import io.sentry.Sentry; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions.BeforeSendCallback; +import io.sentry.SentryReplayOptions; +import io.sentry.UncaughtExceptionHandlerIntegration; +import io.sentry.android.core.AnrIntegration; +import io.sentry.android.core.BuildConfig; +import io.sentry.android.core.CurrentActivityHolder; +import io.sentry.android.core.NdkIntegration; +import io.sentry.android.core.SentryAndroid; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryPackage; +import io.sentry.react.replay.RNSentryReplayMask; +import io.sentry.react.replay.RNSentryReplayUnmask; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class RNSentryStart { + + private RNSentryStart() { + throw new AssertionError("Utility class should not be instantiated"); + } + + static void startWithConfiguration( + @NotNull final Context context, + @NotNull Sentry.OptionsConfiguration configuration) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, null); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + defaults, configuration, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void startWithOptions( + @NotNull final Context context, + @NotNull final ReadableMap rnOptions, + @NotNull Sentry.OptionsConfiguration configuration, + @NotNull ILogger logger) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, null); + Sentry.OptionsConfiguration rnConfigurationOptions = + options -> getSentryAndroidOptions(options, rnOptions, logger); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + rnConfigurationOptions, defaults, configuration, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void startWithOptions( + @NotNull final Context context, + @NotNull final ReadableMap rnOptions, + @Nullable Activity currentActivity, + @NotNull ILogger logger) { + Sentry.OptionsConfiguration defaults = + options -> updateWithReactDefaults(options, currentActivity); + Sentry.OptionsConfiguration rnConfigurationOptions = + options -> getSentryAndroidOptions(options, rnOptions, logger); + RNSentryCompositeOptionsConfiguration compositeConfiguration = + new RNSentryCompositeOptionsConfiguration( + rnConfigurationOptions, defaults, RNSentryStart::updateWithReactFinals); + SentryAndroid.init(context, compositeConfiguration); + } + + static void getSentryAndroidOptions( + @NotNull SentryAndroidOptions options, + @NotNull ReadableMap rnOptions, + @NotNull ILogger logger) { + if (rnOptions.hasKey("debug") && rnOptions.getBoolean("debug")) { + options.setDebug(true); + } + if (rnOptions.hasKey("dsn") && rnOptions.getString("dsn") != null) { + String dsn = rnOptions.getString("dsn"); + logger.log(SentryLevel.INFO, String.format("Starting with DSN: '%s'", dsn)); + options.setDsn(dsn); + } else { + // SentryAndroid needs an empty string fallback for the dsn. + options.setDsn(""); + } + if (rnOptions.hasKey("sampleRate")) { + options.setSampleRate(rnOptions.getDouble("sampleRate")); + } + if (rnOptions.hasKey("sendClientReports")) { + options.setSendClientReports(rnOptions.getBoolean("sendClientReports")); + } + if (rnOptions.hasKey("maxBreadcrumbs")) { + options.setMaxBreadcrumbs(rnOptions.getInt("maxBreadcrumbs")); + } + if (rnOptions.hasKey("maxCacheItems")) { + options.setMaxCacheItems(rnOptions.getInt("maxCacheItems")); + } + if (rnOptions.hasKey("environment") && rnOptions.getString("environment") != null) { + options.setEnvironment(rnOptions.getString("environment")); + } + if (rnOptions.hasKey("release") && rnOptions.getString("release") != null) { + options.setRelease(rnOptions.getString("release")); + } + if (rnOptions.hasKey("dist") && rnOptions.getString("dist") != null) { + options.setDist(rnOptions.getString("dist")); + } + if (rnOptions.hasKey("enableAutoSessionTracking")) { + options.setEnableAutoSessionTracking(rnOptions.getBoolean("enableAutoSessionTracking")); + } + if (rnOptions.hasKey("sessionTrackingIntervalMillis")) { + options.setSessionTrackingIntervalMillis(rnOptions.getInt("sessionTrackingIntervalMillis")); + } + if (rnOptions.hasKey("shutdownTimeout")) { + options.setShutdownTimeoutMillis(rnOptions.getInt("shutdownTimeout")); + } + if (rnOptions.hasKey("enableNdkScopeSync")) { + options.setEnableScopeSync(rnOptions.getBoolean("enableNdkScopeSync")); + } + if (rnOptions.hasKey("attachStacktrace")) { + options.setAttachStacktrace(rnOptions.getBoolean("attachStacktrace")); + } + if (rnOptions.hasKey("attachThreads")) { + // JS use top level stacktrace and android attaches Threads which hides them so + // by default we hide. + options.setAttachThreads(rnOptions.getBoolean("attachThreads")); + } + if (rnOptions.hasKey("attachScreenshot")) { + options.setAttachScreenshot(rnOptions.getBoolean("attachScreenshot")); + } + if (rnOptions.hasKey("attachViewHierarchy")) { + options.setAttachViewHierarchy(rnOptions.getBoolean("attachViewHierarchy")); + } + if (rnOptions.hasKey("sendDefaultPii")) { + options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); + } + if (rnOptions.hasKey("maxQueueSize")) { + options.setMaxQueueSize(rnOptions.getInt("maxQueueSize")); + } + if (rnOptions.hasKey("enableNdk")) { + options.setEnableNdk(rnOptions.getBoolean("enableNdk")); + } + if (rnOptions.hasKey("spotlight")) { + if (rnOptions.getType("spotlight") == ReadableType.Boolean) { + options.setEnableSpotlight(rnOptions.getBoolean("spotlight")); + options.setSpotlightConnectionUrl(rnOptions.getString("defaultSidecarUrl")); + } else if (rnOptions.getType("spotlight") == ReadableType.String) { + options.setEnableSpotlight(true); + options.setSpotlightConnectionUrl(rnOptions.getString("spotlight")); + } + } + + SentryReplayOptions replayOptions = getReplayOptions(rnOptions); + options.setSessionReplay(replayOptions); + if (isReplayEnabled(replayOptions)) { + options.getReplayController().setBreadcrumbConverter(new RNSentryReplayBreadcrumbConverter()); + } + + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + String dsn = getURLFromDSN(rnOptions.getString("dsn")); + String devServerUrl = rnOptions.getString("devServerUrl"); + options.setBeforeBreadcrumb( + (breadcrumb, hint) -> { + Object urlObject = breadcrumb.getData("url"); + String url = urlObject instanceof String ? (String) urlObject : ""; + if ("http".equals(breadcrumb.getType()) + && ((dsn != null && url.startsWith(dsn)) + || (devServerUrl != null && url.startsWith(devServerUrl)))) { + return null; + } + return breadcrumb; + }); + + if (rnOptions.hasKey("enableNativeCrashHandling") + && !rnOptions.getBoolean("enableNativeCrashHandling")) { + final List integrations = options.getIntegrations(); + for (final Integration integration : integrations) { + if (integration instanceof UncaughtExceptionHandlerIntegration + || integration instanceof AnrIntegration + || integration instanceof NdkIntegration) { + integrations.remove(integration); + } + } + } + logger.log( + SentryLevel.INFO, String.format("Native Integrations '%s'", options.getIntegrations())); + } + + /** + * This function updates the options with RNSentry defaults. These default can be overwritten by + * users during manual native initialization. + */ + static void updateWithReactDefaults( + @NotNull SentryAndroidOptions options, @Nullable Activity currentActivity) { + @Nullable SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion == null) { + sdkVersion = new SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, BuildConfig.VERSION_NAME); + } else { + sdkVersion.setName(RNSentryVersion.ANDROID_SDK_NAME); + } + sdkVersion.addPackage( + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + + options.setSentryClientName(sdkVersion.getName() + "/" + sdkVersion.getVersion()); + options.setNativeSdkName(RNSentryVersion.NATIVE_SDK_NAME); + options.setSdkVersion(sdkVersion); + + // Tracing is only enabled in JS to avoid duplicate navigation spans + options.setTracesSampleRate(null); + options.setTracesSampler(null); + options.setEnableTracing(false); + + // React native internally throws a JavascriptException. + // we want to ignore it on the native side to avoid sending it twice. + options.addIgnoredExceptionForType(JavascriptException.class); + + setCurrentActivity(currentActivity); + } + + /** + * This function updates options with changes RNSentry users should not change and so this is + * applied after the configureOptions callback during manual native initialization. + */ + static void updateWithReactFinals(@NotNull SentryAndroidOptions options) { + BeforeSendCallback userBeforeSend = options.getBeforeSend(); + options.setBeforeSend( + (event, hint) -> { + setEventOriginTag(event); + addPackages(event, options.getSdkVersion()); + if (userBeforeSend != null) { + return userBeforeSend.execute(event, hint); + } + return event; + }); + } + + private static void setCurrentActivity(Activity currentActivity) { + final CurrentActivityHolder currentActivityHolder = CurrentActivityHolder.getInstance(); + if (currentActivity != null) { + currentActivityHolder.setActivity(currentActivity); + } + } + + private static boolean isReplayEnabled(SentryReplayOptions replayOptions) { + return replayOptions.getSessionSampleRate() != null + || replayOptions.getOnErrorSampleRate() != null; + } + + private static SentryReplayOptions getReplayOptions(@NotNull ReadableMap rnOptions) { + final SdkVersion replaySdkVersion = + new SdkVersion( + RNSentryVersion.REACT_NATIVE_SDK_NAME, + RNSentryVersion.REACT_NATIVE_SDK_PACKAGE_VERSION); + @NotNull + final SentryReplayOptions androidReplayOptions = + new SentryReplayOptions(false, replaySdkVersion); + + if (!(rnOptions.hasKey("replaysSessionSampleRate") + || rnOptions.hasKey("replaysOnErrorSampleRate"))) { + return androidReplayOptions; + } + + androidReplayOptions.setSessionSampleRate( + rnOptions.hasKey("replaysSessionSampleRate") + ? rnOptions.getDouble("replaysSessionSampleRate") + : null); + androidReplayOptions.setOnErrorSampleRate( + rnOptions.hasKey("replaysOnErrorSampleRate") + ? rnOptions.getDouble("replaysOnErrorSampleRate") + : null); + + if (!rnOptions.hasKey("mobileReplayOptions")) { + return androidReplayOptions; + } + @Nullable final ReadableMap rnMobileReplayOptions = rnOptions.getMap("mobileReplayOptions"); + if (rnMobileReplayOptions == null) { + return androidReplayOptions; + } + + androidReplayOptions.setMaskAllText( + !rnMobileReplayOptions.hasKey("maskAllText") + || rnMobileReplayOptions.getBoolean("maskAllText")); + androidReplayOptions.setMaskAllImages( + !rnMobileReplayOptions.hasKey("maskAllImages") + || rnMobileReplayOptions.getBoolean("maskAllImages")); + + final boolean redactVectors = + !rnMobileReplayOptions.hasKey("maskAllVectors") + || rnMobileReplayOptions.getBoolean("maskAllVectors"); + if (redactVectors) { + androidReplayOptions.addMaskViewClass("com.horcrux.svg.SvgView"); // react-native-svg + } + + androidReplayOptions.setMaskViewContainerClass(RNSentryReplayMask.class.getName()); + androidReplayOptions.setUnmaskViewContainerClass(RNSentryReplayUnmask.class.getName()); + + return androidReplayOptions; + } + + private static void setEventOriginTag(SentryEvent event) { + // We hardcode native-java as only java events are processed by the Android SDK. + SdkVersion sdk = event.getSdk(); + if (sdk != null) { + switch (sdk.getName()) { + case RNSentryVersion.NATIVE_SDK_NAME: + setEventEnvironmentTag(event, "native"); + break; + case RNSentryVersion.ANDROID_SDK_NAME: + setEventEnvironmentTag(event, "java"); + break; + default: + break; + } + } + } + + private static void setEventEnvironmentTag(SentryEvent event, String environment) { + event.setTag("event.origin", "android"); + event.setTag("event.environment", environment); + } + + private static void addPackages(SentryEvent event, SdkVersion sdk) { + SdkVersion eventSdk = event.getSdk(); + if (eventSdk != null + && "sentry.javascript.react-native".equals(eventSdk.getName()) + && sdk != null) { + List sentryPackages = sdk.getPackages(); + if (sentryPackages != null) { + for (SentryPackage sentryPackage : sentryPackages) { + eventSdk.addPackage(sentryPackage.getName(), sentryPackage.getVersion()); + } + } + + List integrations = sdk.getIntegrations(); + if (integrations != null) { + for (String integration : integrations) { + eventSdk.addIntegration(integration); + } + } + + event.setSdk(eventSdk); + } + } + + private static @Nullable String getURLFromDSN(@Nullable String dsn) { + if (dsn == null) { + return null; + } + URI uri = null; + try { + uri = new URI(dsn); + } catch (URISyntaxException e) { + return null; + } + return uri.getScheme() + "://" + uri.getHost(); + } +} diff --git a/packages/core/ios/RNSentry.h b/packages/core/ios/RNSentry.h index cfd0b74b28..c7fb93e0ea 100644 --- a/packages/core/ios/RNSentry.h +++ b/packages/core/ios/RNSentry.h @@ -11,6 +11,9 @@ #import #import +// This import exposes public RNSentrySDK start +#import "RNSentrySDK.h" + typedef int (*SymbolicateCallbackType)(const void *, Dl_info *); @interface @@ -20,11 +23,6 @@ SentrySDK (Private) @interface RNSentry : RCTEventEmitter -- (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options - error:(NSError *_Nullable *_Nonnull)errorPointer; - -- (void)setEventOriginTag:(SentryEvent *)event; - - (NSDictionary *_Nonnull)fetchNativeStackFramesBy:(NSArray *)instructionsAddr symbolicate:(SymbolicateCallbackType)symbolicate; diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index de7f82be35..fa6654a563 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -49,6 +49,7 @@ # import "RNSentryRNSScreen.h" #endif +#import "RNSentryStart.h" #import "RNSentryVersion.h" @interface @@ -63,7 +64,6 @@ + (void)storeEnvelope:(SentryEnvelope *)envelope; static bool hasFetchedAppStart; @implementation RNSentry { - bool sentHybridSdkDidBecomeActive; bool hasListeners; RNSentryTimeToDisplay *_timeToDisplay; } @@ -94,181 +94,14 @@ - (instancetype)init : (RCTPromiseRejectBlock)reject) { NSError *error = nil; - SentryOptions *sentryOptions = [self createOptionsWithDictionary:options error:&error]; + [RNSentryStart startWithOptions:options error:&error]; if (error != nil) { reject(@"SentryReactNative", error.localizedDescription, error); return; } - - NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; - [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; - [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME - version:REACT_NATIVE_SDK_PACKAGE_VERSION]; - - [SentrySDK startWithOptions:sentryOptions]; - -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - BOOL appIsActive = - [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; -#else - BOOL appIsActive = [[NSApplication sharedApplication] isActive]; -#endif - - // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive - // notification, send it. - if (appIsActive && !sentHybridSdkDidBecomeActive - && (PrivateSentrySDKOnly.options.enableAutoSessionTracking - || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { - [[NSNotificationCenter defaultCenter] postNotificationName:@"SentryHybridSdkDidBecomeActive" - object:nil]; - - sentHybridSdkDidBecomeActive = true; - } - -#if SENTRY_TARGET_REPLAY_SUPPORTED - [RNSentryReplay postInit]; -#endif - resolve(@YES); } -- (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options - error:(NSError *_Nonnull *_Nonnull)errorPointer -{ - SentryBeforeSendEventCallback beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception of - // react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - return nil; - } - - [self setEventOriginTag:event]; - - return event; - }; - - NSMutableDictionary *mutableOptions = [options mutableCopy]; - [mutableOptions setValue:beforeSend forKey:@"beforeSend"]; - - // remove performance traces sample rate and traces sampler since we don't want to synchronize - // these configurations to the Native SDKs. The user could tho initialize the SDK manually and - // set themselves. - [mutableOptions removeObjectForKey:@"tracesSampleRate"]; - [mutableOptions removeObjectForKey:@"tracesSampler"]; - [mutableOptions removeObjectForKey:@"enableTracing"]; - -#if SENTRY_TARGET_REPLAY_SUPPORTED - [RNSentryReplay updateOptions:mutableOptions]; -#endif - - SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions - didFailWithError:errorPointer]; - if (*errorPointer != nil) { - return nil; - } - - // Exclude Dev Server and Sentry Dsn request from Breadcrumbs - NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; - NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; - sentryOptions.beforeBreadcrumb - = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) - { - NSString *url = breadcrumb.data[@"url"] ?: @""; - - if ([@"http" isEqualToString:breadcrumb.type] - && ((dsn != nil && [url hasPrefix:dsn]) - || (devServerUrl != nil && [url hasPrefix:devServerUrl]))) { - return nil; - } - return breadcrumb; - }; - - if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) { - BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue]; - - if (!enableNativeCrashHandling) { - NSMutableArray *integrations = sentryOptions.integrations.mutableCopy; - [integrations removeObject:@"SentryCrashIntegration"]; - sentryOptions.integrations = integrations; - } - } - - // Set spotlight option - if ([mutableOptions valueForKey:@"spotlight"] != nil) { - id spotlightValue = [mutableOptions valueForKey:@"spotlight"]; - if ([spotlightValue isKindOfClass:[NSString class]]) { - NSLog(@"Using Spotlight on address: %@", spotlightValue); - sentryOptions.enableSpotlight = true; - sentryOptions.spotlightUrl = spotlightValue; - } else if ([spotlightValue isKindOfClass:[NSNumber class]]) { - sentryOptions.enableSpotlight = [spotlightValue boolValue]; - id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"]; - if (defaultSpotlightUrl != nil) { - sentryOptions.spotlightUrl = defaultSpotlightUrl; - } - } - } - - // Enable the App start and Frames tracking measurements - if ([mutableOptions valueForKey:@"enableAutoPerformanceTracing"] != nil) { - BOOL enableAutoPerformanceTracing = - [mutableOptions[@"enableAutoPerformanceTracing"] boolValue]; - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = enableAutoPerformanceTracing; -#endif - } - - // Failed requests can only be enabled in one SDK to avoid duplicates - sentryOptions.enableCaptureFailedRequests = NO; - - return sentryOptions; -} - -- (NSString *_Nullable)getURLFromDSN:(NSString *)dsn -{ - NSURL *url = [NSURL URLWithString:dsn]; - if (!url) { - return nil; - } - return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; -} - -- (void)setEventOriginTag:(SentryEvent *)event -{ - if (event.sdk != nil) { - NSString *sdkName = event.sdk[@"name"]; - - // If the event is from react native, it gets set - // there and we do not handle it here. - if ([sdkName isEqual:NATIVE_SDK_NAME]) { - [self setEventEnvironmentTag:event origin:@"ios" environment:@"native"]; - } - } -} - -- (void)setEventEnvironmentTag:(SentryEvent *)event - origin:(NSString *)origin - environment:(NSString *)environment -{ - NSMutableDictionary *newTags = [NSMutableDictionary new]; - - if (nil != event.tags && [event.tags count] > 0) { - [newTags addEntriesFromDictionary:event.tags]; - } - if (nil != origin) { - [newTags setValue:origin forKey:@"event.origin"]; - } - if (nil != environment) { - [newTags setValue:environment forKey:@"event.environment"]; - } - - event.tags = newTags; -} - RCT_EXPORT_METHOD(initNativeReactNavigationNewFrameTracking : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) diff --git a/packages/core/ios/RNSentrySDK.h b/packages/core/ios/RNSentrySDK.h new file mode 100644 index 0000000000..232071d9bc --- /dev/null +++ b/packages/core/ios/RNSentrySDK.h @@ -0,0 +1,31 @@ +#import + +@interface RNSentrySDK : NSObject +SENTRY_NO_INIT + +/** + * @experimental + * Inits and configures Sentry for React Native applications using `sentry.options.json` + * configuration file. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)start; + +/** + * @experimental + * Inits and configures Sentry for React Native applicationsusing `sentry.options.json` + * configuration file and `configureOptions` callback. + * + * The `configureOptions` callback can overwrite the config file options + * and add non-serializable items to the options object. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)startWithConfigureOptions: + (void (^_Nullable)(SentryOptions *_Nonnull options))configureOptions + NS_SWIFT_NAME(start(configureOptions:)); + +@end diff --git a/packages/core/ios/RNSentrySDK.m b/packages/core/ios/RNSentrySDK.m new file mode 100644 index 0000000000..7d7f4cf9b3 --- /dev/null +++ b/packages/core/ios/RNSentrySDK.m @@ -0,0 +1,71 @@ +#import "RNSentrySDK.h" +#import "RNSentryStart.h" + +static NSString *SENTRY_OPTIONS_RESOURCE_NAME = @"sentry.options"; +static NSString *SENTRY_OPTIONS_RESOURCE_TYPE = @"json"; + +@implementation RNSentrySDK + ++ (void)start +{ + [self startWithConfigureOptions:nil]; +} + ++ (void)startWithConfigureOptions:(void (^)(SentryOptions *options))configureOptions +{ + NSString *path = [[NSBundle mainBundle] pathForResource:SENTRY_OPTIONS_RESOURCE_NAME + ofType:SENTRY_OPTIONS_RESOURCE_TYPE]; + + [self start:path configureOptions:configureOptions]; +} + ++ (void)start:(NSString *)path configureOptions:(void (^)(SentryOptions *options))configureOptions +{ + NSError *readError = nil; + NSError *parseError = nil; + NSError *optionsError = nil; + + NSData *_Nullable content = nil; + if (path != nil) { + content = [NSData dataWithContentsOfFile:path options:0 error:&readError]; + } + + NSDictionary *dict = nil; + if (content != nil) { + dict = [NSJSONSerialization JSONObjectWithData:content options:0 error:&parseError]; + } + + if (readError != nil) { + NSLog(@"[RNSentry] Failed to load options from %@, with error: %@", path, + readError.localizedDescription); + } + + if (parseError != nil) { + NSLog(@"[RNSentry] Failed to parse JSON from %@, with error: %@", path, + parseError.localizedDescription); + } + + SentryOptions *options = nil; + if (dict != nil) { + options = [RNSentryStart createOptionsWithDictionary:dict error:&optionsError]; + } + + if (optionsError != nil) { + NSLog(@"[RNSentry] Failed to parse options from %@, with error: %@", path, + optionsError.localizedDescription); + } + + if (options == nil) { + // Fallback in case that options file could not be parsed. + options = [[SentryOptions alloc] init]; + } + + [RNSentryStart updateWithReactDefaults:options]; + if (configureOptions != nil) { + configureOptions(options); + } + [RNSentryStart updateWithReactFinals:options]; + [RNSentryStart startWithOptions:options]; +} + +@end diff --git a/packages/core/ios/RNSentryStart.h b/packages/core/ios/RNSentryStart.h new file mode 100644 index 0000000000..01a0617148 --- /dev/null +++ b/packages/core/ios/RNSentryStart.h @@ -0,0 +1,26 @@ +#import +#import + +@interface RNSentryStart : NSObject +SENTRY_NO_INIT + ++ (void)startWithOptions:(NSDictionary *_Nonnull)javascriptOptions + error:(NSError *_Nullable *_Nullable)errorPointer; + ++ (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options + error:(NSError *_Nonnull *_Nonnull)errorPointer; + ++ (void)updateWithReactDefaults:(SentryOptions *)options; ++ (void)updateWithReactFinals:(SentryOptions *)options; + +/** + * @experimental + * Inits and configures Sentry for React Native applications. Make sure to + * set a valid DSN. + * + * @discussion Call this method on the main thread. When calling it from a background thread, the + * SDK starts on the main thread async. + */ ++ (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)); + +@end diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m new file mode 100644 index 0000000000..84e2d83b02 --- /dev/null +++ b/packages/core/ios/RNSentryStart.m @@ -0,0 +1,222 @@ +#import "RNSentryStart.h" +#import "RNSentryReplay.h" +#import "RNSentryVersion.h" + +#import +#import +#import + +@implementation RNSentryStart + ++ (void)startWithOptions:(NSDictionary *_Nonnull)javascriptOptions + error:(NSError *_Nullable *_Nullable)errorPointer +{ + SentryOptions *options = [self createOptionsWithDictionary:javascriptOptions + error:errorPointer]; + [self updateWithReactDefaults:options]; + [self updateWithReactFinals:options]; + [self startWithOptions:options]; +} + ++ (void)startWithOptions:(SentryOptions *)options NS_SWIFT_NAME(start(options:)) +{ + NSString *sdkVersion = [PrivateSentrySDKOnly getSdkVersionString]; + [PrivateSentrySDKOnly setSdkName:NATIVE_SDK_NAME andVersionString:sdkVersion]; + [PrivateSentrySDKOnly addSdkPackage:REACT_NATIVE_SDK_PACKAGE_NAME + version:REACT_NATIVE_SDK_PACKAGE_VERSION]; + + [SentrySDK startWithOptions:options]; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay postInit]; +#endif + + [self postDidBecomeActiveNotification]; +} + ++ (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)options + error:(NSError *_Nonnull *_Nonnull)errorPointer +{ + NSMutableDictionary *mutableOptions = [options mutableCopy]; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + [RNSentryReplay updateOptions:mutableOptions]; +#endif + + SentryOptions *sentryOptions = [[SentryOptions alloc] initWithDict:mutableOptions + didFailWithError:errorPointer]; + if (*errorPointer != nil) { + return nil; + } + + // Exclude Dev Server and Sentry Dsn request from Breadcrumbs + NSString *dsn = [self getURLFromDSN:[mutableOptions valueForKey:@"dsn"]]; + // TODO: For Auto Init from JS dev server is resolved automatically, for init from options file + // dev server has to be specified manually + NSString *devServerUrl = [mutableOptions valueForKey:@"devServerUrl"]; + sentryOptions.beforeBreadcrumb + = ^SentryBreadcrumb *_Nullable(SentryBreadcrumb *_Nonnull breadcrumb) + { + NSString *url = breadcrumb.data[@"url"] ?: @""; + + if ([@"http" isEqualToString:breadcrumb.type] + && ((dsn != nil && [url hasPrefix:dsn]) + || (devServerUrl != nil && [url hasPrefix:devServerUrl]))) { + return nil; + } + return breadcrumb; + }; + + // JS options.enableNativeCrashHandling equals to native options.enableCrashHandler + if ([mutableOptions valueForKey:@"enableNativeCrashHandling"] != nil) { + BOOL enableNativeCrashHandling = [mutableOptions[@"enableNativeCrashHandling"] boolValue]; + + if (!enableNativeCrashHandling) { + NSMutableArray *integrations = sentryOptions.integrations.mutableCopy; + [integrations removeObject:@"SentryCrashIntegration"]; + sentryOptions.integrations = integrations; + } + } + + // Set spotlight option + if ([mutableOptions valueForKey:@"spotlight"] != nil) { + id spotlightValue = [mutableOptions valueForKey:@"spotlight"]; + if ([spotlightValue isKindOfClass:[NSString class]]) { + NSLog(@"Using Spotlight on address: %@", spotlightValue); + sentryOptions.enableSpotlight = true; + sentryOptions.spotlightUrl = spotlightValue; + } else if ([spotlightValue isKindOfClass:[NSNumber class]]) { + sentryOptions.enableSpotlight = [spotlightValue boolValue]; + // TODO: For Auto init from JS set automatically for init from options file have to be + // set manually + id defaultSpotlightUrl = [mutableOptions valueForKey:@"defaultSidecarUrl"]; + if (defaultSpotlightUrl != nil) { + sentryOptions.spotlightUrl = defaultSpotlightUrl; + } + } + } + + return sentryOptions; +} + +/** + * This function updates the options with RNSentry defaults. These default can be + * overwritten by users during manual native initialization. + */ ++ (void)updateWithReactDefaults:(SentryOptions *)options +{ + // Failed requests are captured only in JS to avoid duplicates + options.enableCaptureFailedRequests = NO; + + // Tracing is only enabled in JS to avoid duplicate navigation spans + options.tracesSampleRate = nil; + options.tracesSampler = nil; + options.enableTracing = NO; +} + +/** + * This function updates options with changes RNSentry users should not change + * and so this is applied after the configureOptions callback during manual native initialization. + */ ++ (void)updateWithReactFinals:(SentryOptions *)options +{ + SentryBeforeSendEventCallback userBeforeSend = options.beforeSend; + options.beforeSend = ^SentryEvent *(SentryEvent *event) + { + // Unhandled JS Exception are processed by the SDK on JS layer + // To avoid duplicates we drop them in the native SDKs + if (nil != event.exceptions.firstObject.type && + [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location + != NSNotFound) { + return nil; + } + + [self setEventOriginTag:event]; + if (userBeforeSend == nil) { + return event; + } else { + return userBeforeSend(event); + } + }; + + // App Start Hybrid mode doesn't wait for didFinishLaunchNotification and the + // didBecomeVisibleNotification as they will be missed when auto initializing from JS + // App Start measurements are created right after the tracking starts + PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = options.enableAutoPerformanceTracing; +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + // Frames Tracking Hybrid Mode ensures tracking + // is enabled without tracing enabled in the native SDK + PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode + = options.enableAutoPerformanceTracing; +#endif +} + ++ (void)setEventOriginTag:(SentryEvent *)event +{ + if (event.sdk != nil) { + NSString *sdkName = event.sdk[@"name"]; + + // If the event is from react native, it gets set + // there and we do not handle it here. + if ([sdkName isEqual:NATIVE_SDK_NAME]) { + [self setEventEnvironmentTag:event origin:@"ios" environment:@"native"]; + } + } +} + ++ (void)setEventEnvironmentTag:(SentryEvent *)event + origin:(NSString *)origin + environment:(NSString *)environment +{ + NSMutableDictionary *newTags = [NSMutableDictionary new]; + + if (nil != event.tags && [event.tags count] > 0) { + [newTags addEntriesFromDictionary:event.tags]; + } + if (nil != origin) { + [newTags setValue:origin forKey:@"event.origin"]; + } + if (nil != environment) { + [newTags setValue:environment forKey:@"event.environment"]; + } + + event.tags = newTags; +} + ++ (NSString *_Nullable)getURLFromDSN:(NSString *)dsn +{ + NSURL *url = [NSURL URLWithString:dsn]; + if (!url) { + return nil; + } + return [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; +} + +static bool sentHybridSdkDidBecomeActive = NO; + ++ (void)postDidBecomeActiveNotification +{ +#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST + BOOL appIsActive = + [[UIApplication sharedApplication] applicationState] == UIApplicationStateActive; +#else + BOOL appIsActive = [[NSApplication sharedApplication] isActive]; +#endif + + // If the app is active/in foreground, and we have not sent the SentryHybridSdkDidBecomeActive + // notification, send it. + if (appIsActive && !sentHybridSdkDidBecomeActive + && (PrivateSentrySDKOnly.options.enableAutoSessionTracking + || PrivateSentrySDKOnly.options.enableWatchdogTerminationTracking)) { + // Updates Native App State Manager + // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentryAppStateManager.m#L136 + // Triggers Session Tracker + // https://github.com/getsentry/sentry-cocoa/blob/888a145b144b8077e03151a886520f332e47e297/Sources/Sentry/SentrySessionTracker.m#L144 + [[NSNotificationCenter defaultCenter] postNotificationName:@"SentryHybridSdkDidBecomeActive" + object:nil]; + + sentHybridSdkDidBecomeActive = true; + } +} + +@end diff --git a/packages/core/jest.config.tools.js b/packages/core/jest.config.tools.js index 5c5902d8a7..996ad05625 100644 --- a/packages/core/jest.config.tools.js +++ b/packages/core/jest.config.tools.js @@ -1,7 +1,7 @@ module.exports = { collectCoverage: true, preset: 'ts-jest', - setupFilesAfterEnv: ['/test/mockConsole.ts'], + setupFilesAfterEnv: ['jest-extended/all', '/test/mockConsole.ts'], globals: { __DEV__: true, }, diff --git a/packages/core/scripts/sentry-xcode.sh b/packages/core/scripts/sentry-xcode.sh index 78970c4c60..336d393220 100755 --- a/packages/core/scripts/sentry-xcode.sh +++ b/packages/core/scripts/sentry-xcode.sh @@ -51,3 +51,22 @@ fi if [ -f "$SENTRY_COLLECT_MODULES" ]; then /bin/sh "$SENTRY_COLLECT_MODULES" fi + +SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX="Skipping options file copy. To disable this behavior, set SENTRY_COPY_OPTIONS_FILE=false in your environment variables." +SENTRY_OPTIONS_FILE_NAME="sentry.options.json" +SENTRY_OPTIONS_FILE_DESTINATION_PATH="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/$SENTRY_OPTIONS_FILE_NAME" +[ -z "$SENTRY_OPTIONS_FILE_PATH" ] && SENTRY_OPTIONS_FILE_PATH="$RN_PROJECT_ROOT/$SENTRY_OPTIONS_FILE_NAME" +[ -z "$SENTRY_COPY_OPTIONS_FILE" ] && SENTRY_COPY_OPTIONS_FILE=true + +if [ "$SENTRY_COPY_OPTIONS_FILE" = true ]; then + if [[ -z "$CONFIGURATION_BUILD_DIR" ]]; then + echo "[Sentry] CONFIGURATION_BUILD_DIR is not set. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + elif [[ -z "$UNLOCALIZED_RESOURCES_FOLDER_PATH" ]]; then + echo "[Sentry] UNLOCALIZED_RESOURCES_FOLDER_PATH is not set. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + elif [ ! -f "$SENTRY_OPTIONS_FILE_PATH" ]; then + echo "[Sentry] $SENTRY_OPTIONS_FILE_PATH not found. $SENTRY_OPTIONS_FILE_ERROR_MESSAGE_POSTFIX" 1>&2 + else + cp "$SENTRY_OPTIONS_FILE_PATH" "$SENTRY_OPTIONS_FILE_DESTINATION_PATH" + echo "[Sentry] Copied $SENTRY_OPTIONS_FILE_PATH to $SENTRY_OPTIONS_FILE_DESTINATION_PATH" + fi +fi diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle index fbbf567412..990703527f 100644 --- a/packages/core/sentry.gradle +++ b/packages/core/sentry.gradle @@ -3,7 +3,7 @@ import org.apache.tools.ant.taskdefs.condition.Os import java.util.regex.Matcher import java.util.regex.Pattern -project.ext.shouldSentryAutoUploadNative = { -> +project.ext.shouldSentryAutoUploadNative = { -> return System.getenv('SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD') != 'true' } @@ -15,9 +15,60 @@ project.ext.shouldSentryAutoUpload = { -> return shouldSentryAutoUploadGeneral() && shouldSentryAutoUploadNative() } +project.ext.shouldCopySentryOptionsFile = { -> // If not set, default to true + return System.getenv('SENTRY_COPY_OPTIONS_FILE') != 'false' +} + def config = project.hasProperty("sentryCli") ? project.sentryCli : []; +def configFile = "sentry.options.json" // Sentry condiguration file +def androidAssetsDir = new File("$rootDir/app/src/main/assets") // Path to Android assets folder + +tasks.register("copySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + def appRoot = project.rootDir.parentFile ?: project.rootDir + def sentryOptionsFile = new File(appRoot, configFile) + if (sentryOptionsFile.exists()) { + if (!androidAssetsDir.exists()) { + androidAssetsDir.mkdirs() + } + + copy { + from sentryOptionsFile + into androidAssetsDir + rename { String fileName -> configFile } + } + logger.lifecycle("Copied ${configFile} to Android assets") + } else { + logger.warn("${configFile} not found in app root (${appRoot})") + } + } +} + +tasks.register("cleanupTemporarySentryJsonConfiguration") { + onlyIf { shouldCopySentryOptionsFile() } + doLast { + def sentryOptionsFile = new File(androidAssetsDir, configFile) + if (sentryOptionsFile.exists()) { + logger.lifecycle("Deleting temporary file: ${sentryOptionsFile.path}") + sentryOptionsFile.delete() + } + } +} + gradle.projectsEvaluated { + // Add a task that copies the sentry.options.json file before the build starts + tasks.named("preBuild").configure { + dependsOn("copySentryJsonConfiguration") + } + // Cleanup sentry.options.json from assets after the build + tasks.matching { task -> + task.name == "build" || task.name.startsWith("assemble") || task.name.startsWith("install") + }.configureEach { + finalizedBy("cleanupTemporarySentryJsonConfiguration") + } + def releases = extractReleasesInfo() if (config.flavorAware && config.sentryProperties) { diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 5edba50b48..ceb4c7e263 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -20,6 +20,7 @@ import { useEncodePolyfill } from './transports/encodePolyfill'; import { DEFAULT_BUFFER_SIZE, makeNativeTransportFactory } from './transports/native'; import { getDefaultEnvironment, isExpoGo, isRunningInMetroDevServer } from './utils/environment'; import { safeFactory, safeTracesSampler } from './utils/safe'; +import { RN_GLOBAL_OBJ } from './utils/worldwide'; import { NATIVE } from './wrapper'; const DEFAULT_OPTIONS: ReactNativeOptions = { @@ -48,12 +49,17 @@ export function init(passedOptions: ReactNativeOptions): void { return; } - const maxQueueSize = passedOptions.maxQueueSize + const userOptions = { + ...RN_GLOBAL_OBJ.__SENTRY_OPTIONS__, + ...passedOptions, + }; + + const maxQueueSize = userOptions.maxQueueSize // eslint-disable-next-line deprecation/deprecation - ?? passedOptions.transportOptions?.bufferSize + ?? userOptions.transportOptions?.bufferSize ?? DEFAULT_OPTIONS.maxQueueSize; - const enableNative = passedOptions.enableNative === undefined || passedOptions.enableNative + const enableNative = userOptions.enableNative === undefined || userOptions.enableNative ? NATIVE.isNativeAvailable() : false; @@ -76,11 +82,11 @@ export function init(passedOptions: ReactNativeOptions): void { return `${dsnComponents.protocol}://${dsnComponents.host}${port}`; }; - const userBeforeBreadcrumb = safeFactory(passedOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }); + const userBeforeBreadcrumb = safeFactory(userOptions.beforeBreadcrumb, { loggerMessage: 'The beforeBreadcrumb threw an error' }); // Exclude Dev Server and Sentry Dsn request from Breadcrumbs const devServerUrl = getDevServer()?.url; - const dsn = getURLFromDSN(passedOptions.dsn); + const dsn = getURLFromDSN(userOptions.dsn); const defaultBeforeBreadcrumb = (breadcrumb: Breadcrumb, _hint?: BreadcrumbHint): Breadcrumb | null => { const type = breadcrumb.type || ''; const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : ''; @@ -104,26 +110,34 @@ export function init(passedOptions: ReactNativeOptions): void { const options: ReactNativeClientOptions = { ...DEFAULT_OPTIONS, - ...passedOptions, + ...userOptions, enableNative, - enableNativeNagger: shouldEnableNativeNagger(passedOptions.enableNativeNagger), + enableNativeNagger: shouldEnableNativeNagger(userOptions.enableNativeNagger), // If custom transport factory fails the SDK won't initialize - transport: passedOptions.transport + transport: userOptions.transport || makeNativeTransportFactory({ enableNative, }) || makeFetchTransport, transportOptions: { ...DEFAULT_OPTIONS.transportOptions, - ...(passedOptions.transportOptions ?? {}), + ...(userOptions.transportOptions ?? {}), bufferSize: maxQueueSize, }, maxQueueSize, integrations: [], - stackParser: stackParserFromStackParserOptions(passedOptions.stackParser || defaultStackParser), + stackParser: stackParserFromStackParserOptions(userOptions.stackParser || defaultStackParser), beforeBreadcrumb: chainedBeforeBreadcrumb, - initialScope: safeFactory(passedOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }), + initialScope: safeFactory(userOptions.initialScope, { loggerMessage: 'The initialScope threw an error' }), }; + + if (!('autoInitializeNativeSdk' in userOptions) && RN_GLOBAL_OBJ.__SENTRY_OPTIONS__) { + // We expect users to use the file options only in combination with manual native initialization + // eslint-disable-next-line no-console + console.info('Initializing Sentry JS with the options file. Expecting manual native initialization before JS. Native will not be initialized automatically.'); + options.autoInitializeNativeSdk = false; + } + if ('tracesSampler' in options) { options.tracesSampler = safeTracesSampler(options.tracesSampler); } @@ -132,12 +146,12 @@ export function init(passedOptions: ReactNativeOptions): void { options.environment = getDefaultEnvironment(); } - const defaultIntegrations: false | Integration[] = passedOptions.defaultIntegrations === undefined + const defaultIntegrations: false | Integration[] = userOptions.defaultIntegrations === undefined ? getDefaultIntegrations(options) - : passedOptions.defaultIntegrations; + : userOptions.defaultIntegrations; options.integrations = getIntegrationsToSetup({ - integrations: safeFactory(passedOptions.integrations, { loggerMessage: 'The integrations threw an error' }), + integrations: safeFactory(userOptions.integrations, { loggerMessage: 'The integrations threw an error' }), defaultIntegrations, }); initAndBind(ReactNativeClient, options); @@ -146,6 +160,10 @@ export function init(passedOptions: ReactNativeOptions): void { logger.info('Offline caching, native errors features are not available in Expo Go.'); logger.info('Use EAS Build / Native Release Build to test these features.'); } + + if (RN_GLOBAL_OBJ.__SENTRY_OPTIONS__) { + logger.info('Sentry JS initialized with options from the options file.'); + } } /** diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index e3c71b684f..aa4a2bfbb5 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -13,6 +13,8 @@ import { createSentryMetroSerializer, unstable_beforeAssetSerializationPlugin } import type { DefaultConfigOptions } from './vendor/expo/expoconfig'; export * from './sentryMetroSerializer'; import { withSentryMiddleware } from './metroMiddleware'; +import { withSentryOptionsFromFile } from './sentryOptionsSerializer'; +import type { MetroCustomSerializer } from './utils'; enableLogger(); @@ -37,6 +39,14 @@ export interface SentryMetroConfigOptions { * @default true */ enableSourceContextInDevelopment?: boolean; + /** + * Load Sentry Options from a file. If `true` it will use the default path. + * If `false` it will not load any options from a file. Only options provided in the code will be used. + * If `string` it will use the provided path. + * + * @default '{projectRoot}/sentry.options.json' + */ + optionsFile?: string | boolean; } export interface SentryExpoConfigOptions { @@ -58,6 +68,7 @@ export function withSentryConfig( annotateReactComponents = false, includeWebReplay = true, enableSourceContextInDevelopment = true, + optionsFile = true, }: SentryMetroConfigOptions = {}, ): MetroConfig { setSentryMetroDevServerEnvFlag(); @@ -75,6 +86,9 @@ export function withSentryConfig( if (enableSourceContextInDevelopment) { newConfig = withSentryMiddleware(newConfig); } + if (optionsFile) { + newConfig = withSentryOptionsFromFile(newConfig, optionsFile); + } return newConfig; } @@ -110,6 +124,10 @@ export function getSentryExpoConfig( newConfig = withSentryMiddleware(newConfig); } + if (options.optionsFile ?? true) { + newConfig = withSentryOptionsFromFile(newConfig, options.optionsFile ?? true); + } + return newConfig; } @@ -171,8 +189,6 @@ export function withSentryBabelTransformer( }; } -type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; - function withSentryDebugId(config: MetroConfig): MetroConfig { const customSerializer = createSentryMetroSerializer( config.serializer?.customSerializer || undefined, diff --git a/packages/core/src/js/tools/sentryMetroSerializer.ts b/packages/core/src/js/tools/sentryMetroSerializer.ts index fca0979440..feb1e65621 100644 --- a/packages/core/src/js/tools/sentryMetroSerializer.ts +++ b/packages/core/src/js/tools/sentryMetroSerializer.ts @@ -42,6 +42,7 @@ export function unstable_beforeAssetSerializationPlugin({ return [...addDebugIdModule(premodules, debugIdModule)]; } +// TODO: deprecate this and afterwards rename to createSentryDebugIdSerializer /** * Creates a Metro serializer that adds Debug ID module to the plain bundle. * The Debug ID module is a virtual module that provides a debug ID in runtime. diff --git a/packages/core/src/js/tools/sentryOptionsSerializer.ts b/packages/core/src/js/tools/sentryOptionsSerializer.ts new file mode 100644 index 0000000000..f2ab93b383 --- /dev/null +++ b/packages/core/src/js/tools/sentryOptionsSerializer.ts @@ -0,0 +1,104 @@ +import { logger } from '@sentry/core'; +import * as fs from 'fs'; +import type { MetroConfig, Module } from 'metro'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as countLines from 'metro/src/lib/countLines'; +import * as path from 'path'; + +import type { MetroCustomSerializer, VirtualJSOutput } from './utils'; +import { createSet } from './utils'; + +const DEFAULT_OPTIONS_FILE_NAME = 'sentry.options.json'; + +/** + * Loads Sentry options from a file in + */ +export function withSentryOptionsFromFile(config: MetroConfig, optionsFile: string | boolean): MetroConfig { + if (optionsFile === false) { + return config; + } + + const { projectRoot } = config; + if (!projectRoot) { + // eslint-disable-next-line no-console + console.error('[@sentry/react-native/metro] Project root is required to load Sentry options from a file'); + return config; + } + + let optionsPath = path.join(projectRoot, DEFAULT_OPTIONS_FILE_NAME); + if (typeof optionsFile === 'string' && path.isAbsolute(optionsFile)) { + optionsPath = optionsFile; + } else if (typeof optionsFile === 'string') { + optionsPath = path.join(projectRoot, optionsFile); + } + + const originalSerializer = config.serializer?.customSerializer; + if (!originalSerializer) { + // It's okay to bail here because we don't expose this for direct usage, but as part of `withSentryConfig` + // If used directly in RN, the user is responsible for providing a custom serializer first, Expo provides serializer in default config + // eslint-disable-next-line no-console + console.error( + '[@sentry/react-native/metro] `config.serializer.customSerializer` is required to load Sentry options from a file', + ); + return config; + } + + const sentryOptionsSerializer: MetroCustomSerializer = (entryPoint, preModules, graph, options) => { + const sentryOptionsModule = createSentryOptionsModule(optionsPath); + if (sentryOptionsModule) { + (preModules as Module[]).push(sentryOptionsModule); + } + return originalSerializer(entryPoint, preModules, graph, options); + }; + + return { + ...config, + serializer: { + ...config.serializer, + customSerializer: sentryOptionsSerializer, + }, + }; +} + +function createSentryOptionsModule(filePath: string): Module | null { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.debug(`[@sentry/react-native/metro] Sentry options file does not exist at ${filePath}`); + } else { + logger.error(`[@sentry/react-native/metro] Failed to read Sentry options file at ${filePath}`); + } + return null; + } + + let parsedContent: Record; + try { + parsedContent = JSON.parse(content); + } catch (error) { + logger.error(`[@sentry/react-native/metro] Failed to parse Sentry options file at ${filePath}`); + return null; + } + + const minifiedContent = JSON.stringify(parsedContent); + const optionsCode = `var __SENTRY_OPTIONS__=${minifiedContent};`; + + logger.debug(`[@sentry/react-native/metro] Sentry options added to the bundle from file at ${filePath}`); + return { + dependencies: new Map(), + getSource: () => Buffer.from(optionsCode), + inverseDependencies: createSet(), + path: '__sentry-options__', + output: [ + { + type: 'js/script/virtual', + data: { + code: optionsCode, + lineCount: countLines(optionsCode), + map: [], + }, + }, + ], + }; +} diff --git a/packages/core/src/js/tools/utils.ts b/packages/core/src/js/tools/utils.ts index 769dc9abd4..82ff4075e2 100644 --- a/packages/core/src/js/tools/utils.ts +++ b/packages/core/src/js/tools/utils.ts @@ -1,9 +1,11 @@ import * as crypto from 'crypto'; // eslint-disable-next-line import/no-extraneous-dependencies -import type { Module, ReadOnlyGraph, SerializerOptions } from 'metro'; +import type { MetroConfig, Module, ReadOnlyGraph, SerializerOptions } from 'metro'; // eslint-disable-next-line import/no-extraneous-dependencies import type CountingSet from 'metro/src/lib/CountingSet'; +export type MetroCustomSerializer = Required['serializer']>['customSerializer'] | undefined; + // Variant of MixedOutput // https://github.com/facebook/metro/blob/9b85f83c9cc837d8cd897aa7723be7da5b296067/packages/metro/src/DeltaBundler/types.flow.js#L21 export type VirtualJSOutput = { diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index 03327bac36..778e58edaf 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -2,6 +2,7 @@ import type { InternalGlobal } from '@sentry/core'; import { GLOBAL_OBJ } from '@sentry/core'; import type { ErrorUtils } from 'react-native/types'; +import type { ReactNativeOptions } from '../options'; import type { ExpoGlobalObject } from './expoglobalobject'; /** Internal Global object interface with common and Sentry specific properties */ @@ -26,6 +27,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { nativePerformanceNow?: () => number; TextEncoder?: TextEncoder; alert?: (message: string) => void; + __SENTRY_OPTIONS__?: ReactNativeOptions; } type TextEncoder = { diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index afd6137c8a..0e64264899 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -1,13 +1,15 @@ -import type { BaseTransportOptions, Breadcrumb, BreadcrumbHint, ClientOptions, Integration, Scope } from '@sentry/core'; +import type { Breadcrumb, BreadcrumbHint, Integration, Scope } from '@sentry/core'; import { initAndBind, logger } from '@sentry/core'; import { makeFetchTransport } from '@sentry/react'; import { getDevServer } from '../src/js/integrations/debugsymbolicatorutils'; +import type { ReactNativeClientOptions } from '../src/js/options'; import { init, withScope } from '../src/js/sdk'; import type { ReactNativeTracingIntegration } from '../src/js/tracing'; import { REACT_NATIVE_TRACING_INTEGRATION_NAME, reactNativeTracingIntegration } from '../src/js/tracing'; import { makeNativeTransport } from '../src/js/transports/native'; import { getDefaultEnvironment, isExpoGo, notWeb } from '../src/js/utils/environment'; +import { RN_GLOBAL_OBJ } from '../src/js/utils/worldwide'; import { NATIVE } from './mockWrapper'; import { firstArg, secondArg } from './testutils'; @@ -109,6 +111,60 @@ describe('Tests the SDK functionality', () => { }); }); + describe('initialization from sentry.options.json', () => { + it('initializes without __SENTRY_OPTIONS__', () => { + delete RN_GLOBAL_OBJ.__SENTRY_OPTIONS__; + init({}); + expect(initAndBind).toHaveBeenCalledOnce(); + }); + + it('adds options from __SENTRY_OPTIONS__', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = { + dsn: 'https://key@example.io/value', + }; + init({}); + expect(usedOptions()?.dsn).toBe('https://key@example.io/value'); + }); + + it('options init override options from __SENTRY_OPTIONS__', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = { + dsn: 'https://key@example.io/file', + }; + init({ + dsn: 'https://key@example.io/code', + }); + expect(usedOptions()?.dsn).toBe('https://key@example.io/code'); + }); + + it('initializing with __SENTRY_OPTIONS__ disabled native auto initialization', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({}); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(false); + }); + + it('initializing without __SENTRY_OPTIONS__ enables native auto initialization', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = undefined; + init({}); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(true); + }); + + it('initializing with __SENTRY_OPTIONS__ keeps native auto initialization true if set', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({ + autoInitializeNativeSdk: true, + }); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(true); + }); + + it('initializing with __SENTRY_OPTIONS__ keeps native auto initialization false if set', () => { + RN_GLOBAL_OBJ.__SENTRY_OPTIONS__ = {}; + init({ + autoInitializeNativeSdk: false, + }); + expect(usedOptions()?.autoInitializeNativeSdk).toBe(false); + }); + }); + describe('environment', () => { it('detect development environment', () => { (getDefaultEnvironment as jest.Mock).mockImplementation(() => 'development'); @@ -173,7 +229,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({}); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -182,7 +237,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({ enableNative: true }); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -191,7 +245,6 @@ describe('Tests the SDK functionality', () => { (NATIVE.isNativeAvailable as jest.Mock).mockImplementation(() => false); init({ enableNative: false }); expect(NATIVE.isNativeAvailable).not.toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); expect(usedOptions()?.transport).toEqual(makeFetchTransport); }); @@ -204,7 +257,6 @@ describe('Tests the SDK functionality', () => { }); expect(usedOptions()?.transport).toEqual(mockTransport); expect(NATIVE.isNativeAvailable).toBeCalled(); - // @ts-expect-error enableNative not publicly available here. expect(usedOptions()?.enableNative).toEqual(false); }); }); @@ -1058,7 +1110,7 @@ function createMockedIntegration({ name }: { name?: string } = {}): Integration }; } -function usedOptions(): ClientOptions | undefined { +function usedOptions(): ReactNativeClientOptions | undefined { return (initAndBind as jest.MockedFunction).mock.calls[0]?.[secondArg]; } diff --git a/packages/core/test/tools/sentryOptionsSerializer.test.ts b/packages/core/test/tools/sentryOptionsSerializer.test.ts new file mode 100644 index 0000000000..ed946d098a --- /dev/null +++ b/packages/core/test/tools/sentryOptionsSerializer.test.ts @@ -0,0 +1,209 @@ +import { logger } from '@sentry/core'; +import * as fs from 'fs'; +import type { Graph, Module, SerializerOptions } from 'metro'; + +import { withSentryOptionsFromFile } from '../../src/js/tools/sentryOptionsSerializer'; +import { createSet } from '../../src/js/tools/utils'; + +jest.mock('fs', () => ({ + readFileSync: jest.fn(), +})); + +const consoleErrorSpy = jest.spyOn(console, 'error'); +const loggerDebugSpy = jest.spyOn(logger, 'debug'); +const loggerErrorSpy = jest.spyOn(logger, 'error'); + +const customSerializerMock = jest.fn(); +let mockedPreModules: Module[] = []; + +describe('Sentry Options Serializer', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedPreModules = createMockedPreModules(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('returns original config if optionsFile is false', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const result = withSentryOptionsFromFile(config(), false); + expect(result).toEqual(config()); + }); + + test('logs error and returns original config if projectRoot is missing', () => { + const config = () => ({ + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const result = withSentryOptionsFromFile(config(), true); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Project root is required')); + expect(result).toEqual(config()); + }); + + test('logs error and returns original config if customSerializer is missing', () => { + const config = () => ({ + projectRoot: '/test', + serializer: {}, + }); + const consoleErrorSpy = jest.spyOn(console, 'error'); + + const result = withSentryOptionsFromFile(config(), true); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('`config.serializer.customSerializer` is required'), + ); + expect(result).toEqual(config()); + }); + + test('adds sentry options module when file exists and is valid JSON', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + const mockOptions = { test: 'value' }; + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockOptions)); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(mockedPreModules).toHaveLength(2); + expect(mockedPreModules.at(-1)).toEqual( + expect.objectContaining({ + getSource: expect.any(Function), + path: '__sentry-options__', + output: [ + { + type: 'js/script/virtual', + data: { + code: 'var __SENTRY_OPTIONS__={"test":"value"};', + lineCount: 1, + map: [], + }, + }, + ], + }), + ); + expect(mockedPreModules.at(-1).getSource().toString()).toEqual(mockedPreModules.at(-1).output[0].data.code); + expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('options added to the bundle')); + }); + + test('logs error and does not add module when file does not exist', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + (fs.readFileSync as jest.Mock).mockImplementation(() => { + throw { code: 'ENOENT' }; + }); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(loggerDebugSpy).toHaveBeenCalledWith(expect.stringContaining('options file does not exist')); + expect(mockedPreModules).toMatchObject(createMockedPreModules()); + }); + + test('logs error and does not add module when file contains invalid JSON', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + (fs.readFileSync as jest.Mock).mockReturnValue('invalid json'); + + const actualConfig = withSentryOptionsFromFile(config(), true); + actualConfig.serializer?.customSerializer(null, mockedPreModules, null, null); + + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse Sentry options file')); + expect(mockedPreModules).toMatchObject(createMockedPreModules()); + }); + + test('calls original serializer with correct arguments and returns its result', () => { + const mockedEntryPoint = 'entryPoint'; + const mockedGraph: Graph = jest.fn() as unknown as Graph; + const mockedOptions: SerializerOptions = jest.fn() as unknown as SerializerOptions; + const mockedResult = {}; + const originalSerializer = jest.fn().mockReturnValue(mockedResult); + + const actualConfig = withSentryOptionsFromFile( + { + projectRoot: '/test', + serializer: { + customSerializer: originalSerializer, + }, + }, + true, + ); + const actualResult = actualConfig.serializer?.customSerializer( + mockedEntryPoint, + mockedPreModules, + mockedGraph, + mockedOptions, + ); + + expect(originalSerializer).toHaveBeenCalledWith(mockedEntryPoint, mockedPreModules, mockedGraph, mockedOptions); + expect(actualResult).toEqual(mockedResult); + }); + + test('uses custom file path when optionsFile is a string', () => { + const config = () => ({ + projectRoot: '/test', + serializer: { + customSerializer: customSerializerMock, + }, + }); + + withSentryOptionsFromFile(config(), 'custom/path.json').serializer?.customSerializer( + null, + mockedPreModules, + null, + null, + ); + withSentryOptionsFromFile(config(), '/absolute/path.json').serializer?.customSerializer( + null, + mockedPreModules, + null, + null, + ); + + expect(fs.readFileSync).toHaveBeenCalledWith('/test/custom/path.json', expect.anything()); + expect(fs.readFileSync).toHaveBeenCalledWith('/absolute/path.json', expect.anything()); + }); +}); + +function createMockedPreModules(): Module[] { + return [createMinimalModule()]; +} + +function createMinimalModule(): Module { + return { + dependencies: new Map(), + getSource: getEmptySource, + inverseDependencies: createSet(), + path: '__sentry-options__', + output: [], + }; +} + +function getEmptySource(): Buffer { + return Buffer.from(''); +} diff --git a/samples/expo/sentry.options.json b/samples/expo/sentry.options.json new file mode 100644 index 0000000000..53ae525bc0 --- /dev/null +++ b/samples/expo/sentry.options.json @@ -0,0 +1,18 @@ +{ + "dsn": "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561", + "debug": true, + "environment": "dev", + "enableUserInteractionTracing": true, + "enableAutoSessionTracking": true, + "sessionTrackingIntervalMillis": 30000, + "enableTracing": true, + "tracesSampleRate": 1.0, + "attachStacktrace": true, + "attachScreenshot": true, + "attachViewHierarchy": true, + "enableCaptureFailedRequests": true, + "profilesSampleRate": 1.0, + "replaysSessionSampleRate": 1.0, + "replaysOnErrorSampleRate": 1.0, + "spotlight": true +} diff --git a/samples/react-native-macos/scripts/pod-install.sh b/samples/react-native-macos/scripts/pod-install.sh new file mode 100755 index 0000000000..a923f8c32a --- /dev/null +++ b/samples/react-native-macos/scripts/pod-install.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +echo "USE_FRAMEWORKS=$USE_FRAMEWORKS" +echo "ENABLE_PROD=$ENABLE_PROD" +echo "ENABLE_NEW_ARCH=$ENABLE_NEW_ARCH" + +cd "${thisFilePath}/.." +bundle install + +cd macos +PRODUCTION=$ENABLE_PROD RCT_NEW_ARCH_ENABLED=$ENABLE_NEW_ARCH bundle exec pod update + +cat Podfile.lock | grep $RN_SENTRY_POD_NAME diff --git a/samples/react-native/.detoxrc.js b/samples/react-native/.detoxrc.js new file mode 100644 index 0000000000..a3501515a9 --- /dev/null +++ b/samples/react-native/.detoxrc.js @@ -0,0 +1,74 @@ +const process = require('process'); + +/** @type {Detox.DetoxConfig} */ +module.exports = { + testRunner: {}, + apps: { + 'ci.android': { + type: 'android.apk', + binaryPath: 'app.apk', + testBinaryPath: 'app-androidTest.apk', + }, + 'ci.ios': { + type: 'ios.app', + binaryPath: 'sentryreactnativesample.app', + }, + }, + devices: { + 'ci.emulator': { + type: process.env.ANDROID_TYPE?.trim(), + device: { + avdName: process.env.ANDROID_AVD_NAME?.trim(), + adbName: process.env.ANDROID_ADB_NAME?.trim(), + }, + }, + 'ci.simulator': { + type: 'ios.simulator', + device: { + type: process.env.IOS_DEVICE?.trim(), + os: process.env.IOS_VERSION?.trim(), + }, + }, + }, + configurations: { + 'ci.android': { + device: 'ci.emulator', + app: 'ci.android', + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.android.js', + }, + jest: { + setupTimeout: 120000, + }, + }, + }, + 'ci.sim.auto': { + device: 'ci.simulator', + app: 'ci.ios', + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.ios.auto.js', + }, + jest: { + setupTimeout: 120000, + }, + }, + }, + 'ci.sim.manual': { + device: 'ci.simulator', + app: 'ci.ios', + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.ios.manual.js', + }, + jest: { + setupTimeout: 120000, + }, + }, + }, + }, +}; diff --git a/samples/react-native/.gitignore b/samples/react-native/.gitignore index 40824c4232..9eb5c6ab12 100644 --- a/samples/react-native/.gitignore +++ b/samples/react-native/.gitignore @@ -63,3 +63,5 @@ yarn-error.log .metro-health-check* *.xcarchive +*.apk +**/*.app diff --git a/samples/react-native/android/app/build.gradle b/samples/react-native/android/app/build.gradle index 6414bf9d58..8c544c2d20 100644 --- a/samples/react-native/android/app/build.gradle +++ b/samples/react-native/android/app/build.gradle @@ -138,6 +138,11 @@ android { targetSdkVersion rootProject.ext.targetSdkVersion versionCode 40 versionName "6.8.0" + buildConfigField "boolean", "SENTRY_DISABLE_NATIVE_START", System.getenv('SENTRY_DISABLE_NATIVE_START') ?: String.valueOf(sentryDisableNativeStart) + + // Detox + testBuildType System.getProperty('testBuildType', 'debug') + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } signingConfigs { @@ -192,11 +197,15 @@ android { signingConfig signingConfigs.debug minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro" } } } dependencies { + androidTestImplementation('com.wix:detox:+') + implementation 'androidx.appcompat:appcompat:1.7.0' + // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") diff --git a/samples/react-native/android/app/proguard-rules.pro b/samples/react-native/android/app/proguard-rules.pro index 11b025724a..f4ada6b5a1 100644 --- a/samples/react-native/android/app/proguard-rules.pro +++ b/samples/react-native/android/app/proguard-rules.pro @@ -8,3 +8,7 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: + +# Detox Release tests were failing on missing kotlin.Result +# It should be covered by node_modules/detox/android/detox/proguard-rules-app.pro but it seems missing +-keep class kotlin.** { *; } diff --git a/samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java b/samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java new file mode 100644 index 0000000000..28b9b28d1c --- /dev/null +++ b/samples/react-native/android/app/src/androidTest/java/io/sentry/reactnative/sample/DetoxTest.java @@ -0,0 +1,28 @@ +package io.sentry.reactnative.sample; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import com.wix.detox.Detox; +import com.wix.detox.config.DetoxConfig; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class DetoxTest { + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(MainActivity.class, false, false); + + @Test + public void runDetoxTests() { + DetoxConfig detoxConfig = new DetoxConfig(); + detoxConfig.idlePolicyConfig.masterTimeoutSec = 90; + detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60; + detoxConfig.rnContextLoadTimeoutSec = (BuildConfig.DEBUG ? 180 : 60); + + Detox.runTests(mActivityRule, detoxConfig); + } +} diff --git a/samples/react-native/android/app/src/main/AndroidManifest.xml b/samples/react-native/android/app/src/main/AndroidManifest.xml index e1892528b8..095bdca459 100644 --- a/samples/react-native/android/app/src/main/AndroidManifest.xml +++ b/samples/react-native/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" - android:supportsRtl="true"> + android:supportsRtl="true" + android:networkSecurityConfig="@xml/network_security_config"> - // Only options set here will apply to the Android SDK - // Options from JS are not passed to the Android SDK when initialized manually - options.dsn = "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561" - options.isDebug = true - - options.beforeSend = - BeforeSendCallback { event: SentryEvent, hint: Hint? -> - // React native internally throws a JavascriptException - // Since we catch it before that, we don't want to send this one - // because we would send it twice - try { - val ex = event.exceptions!![0] - if (null != ex && ex.type!!.contains("JavascriptException")) { - return@BeforeSendCallback null - } - } catch (ignored: Throwable) { - // We do nothing - } - - event - } - } - } } diff --git a/samples/react-native/android/app/src/main/res/xml/network_security_config.xml b/samples/react-native/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..fe0064a23f --- /dev/null +++ b/samples/react-native/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + 10.0.2.2 + localhost + + diff --git a/samples/react-native/android/build.gradle b/samples/react-native/android/build.gradle index 7b72203ef2..99224fc962 100644 --- a/samples/react-native/android/build.gradle +++ b/samples/react-native/android/build.gradle @@ -20,4 +20,12 @@ buildscript { } } +allprojects { + repositories { + maven { + url("$rootDir/../node_modules/detox/Detox-android") + } + } +} + apply plugin: "com.facebook.react.rootproject" diff --git a/samples/react-native/android/gradle.properties b/samples/react-native/android/gradle.properties index 600fea4b77..d71a974450 100644 --- a/samples/react-native/android/gradle.properties +++ b/samples/react-native/android/gradle.properties @@ -38,3 +38,8 @@ newArchEnabled=true # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. hermesEnabled=true + +# Only implemented in this sample project. +# It's used for testing the native SDK auto-start feature. +# true means manual native start is disabled and JS auto initializes native SDK. +sentryDisableNativeStart=false diff --git a/samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts b/samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts new file mode 100644 index 0000000000..ea6750434e --- /dev/null +++ b/samples/react-native/e2e/captureAppStartCrash.test.ios.manual.ts @@ -0,0 +1,121 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEvent, +} from './utils/mockedSentryServer'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture app start crash', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + const launchConfig = { + launchArgs: { + 0: '--sentry-crash-on-start', + }, + }; + + const envelopePromise = sentryServer.waitForEnvelope(containingEvent); + + device.launchApp(launchConfig); + device.launchApp(launchConfig); // This launch sends the crash event before the app crashes again + + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains sdk metadata', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + platform: 'cocoa', + sdk: { + features: [], + integrations: [ + 'SessionReplay', + 'WatchdogTerminationTracking', + 'Screenshot', + 'Crash', + 'ANRTracking', + 'ViewHierarchy', + 'AutoBreadcrumbTracking', + 'AutoSessionTracking', + 'NetworkTracking', + 'AppStartTracking', + 'FramesTracking', + ], + name: 'sentry.cocoa.react-native', + packages: [ + { + name: 'cocoapods:getsentry/sentry.cocoa.react-native', + version: expect.any(String), + }, + { + name: 'npm:@sentry/react-native', + version: expect.any(String), + }, + ], + version: expect.any(String), + }, + tags: { + 'event.environment': 'native', + 'event.origin': 'ios', + }, + }), + ]); + }); + + it('envelope contains the expected exception', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + mechanism: expect.objectContaining({ + handled: false, + meta: { + mach_exception: { + code: 0, + exception: 10, + name: 'EXC_CRASH', + subcode: 0, + }, + signal: { + code: 0, + name: 'SIGABRT', + number: 6, + }, + }, + type: 'nsexception', + }), + stacktrace: expect.objectContaining({ + frames: expect.any(Array), + }), + type: 'CrashOnStart', + value: 'This was intentional test crash before JS started.', + }), + ]), + }, + }), + ]); + }); +}); diff --git a/samples/react-native/e2e/captureMessage.test.android.ts b/samples/react-native/e2e/captureMessage.test.android.ts new file mode 100644 index 0000000000..c48d9553a5 --- /dev/null +++ b/samples/react-native/e2e/captureMessage.test.android.ts @@ -0,0 +1,148 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEventWithAndroidMessage, +} from './utils/mockedSentryServer'; +import { tap } from './utils/tap'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithAndroidMessage('Captured message'), + ); + await tap('Capture message'); + envelope = await envelopePromise; + }); + + 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), + battery_temperature: expect.any(Number), + boot_time: expect.any(String), + brand: expect.any(String), + charging: expect.any(Boolean), + connection_type: expect.any(String), + family: expect.any(String), + free_memory: expect.any(Number), + free_storage: expect.any(Number), + id: expect.any(String), + language: expect.any(String), + locale: expect.any(String), + low_memory: expect.any(Boolean), + manufacturer: expect.any(String), + memory_size: expect.any(Number), + model: expect.any(String), + model_id: expect.any(String), + online: expect.any(Boolean), + orientation: expect.any(String), + processor_count: expect.any(Number), + processor_frequency: expect.any(Number), + screen_density: expect.any(Number), + screen_dpi: expect.any(Number), + screen_height_pixels: expect.any(Number), + screen_width_pixels: expect.any(Number), + simulator: expect.any(Boolean), + storage_size: expect.any(Number), + timezone: expect.any(String), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_build: expect.any(String), + app_identifier: expect.any(String), + app_name: expect.any(String), + app_start_time: expect.any(String), + app_version: expect.any(String), + in_foreground: expect.any(Boolean), + view_names: ['ErrorsScreen'], + }), + }), + }), + ); + }); + + it('contains os context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + os: { + build: expect.any(String), + kernel_version: expect.any(String), + name: 'Android', + rooted: expect.any(Boolean), + version: expect.any(String), + }, + }), + }), + ); + }); + + it('contains react native context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + react_native_context: { + expo: false, + fabric: expect.any(Boolean), + hermes_debug_info: expect.any(Boolean), + hermes_version: expect.any(String), + js_engine: 'hermes', + react_native_version: expect.any(String), + turbo_module: expect.any(Boolean), + }, + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/captureMessage.test.ios.ts b/samples/react-native/e2e/captureMessage.test.ios.ts new file mode 100644 index 0000000000..40cfcf20c1 --- /dev/null +++ b/samples/react-native/e2e/captureMessage.test.ios.ts @@ -0,0 +1,128 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope, EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEventWithMessage, +} from './utils/mockedSentryServer'; +import { tap } from './utils/tap'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithMessage('Captured message'), + ); + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains message event', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item).toEqual([ + { + length: expect.any(Number), + type: 'event', + }, + expect.objectContaining({ + level: 'info', + 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({ + arch: expect.any(String), + family: expect.any(String), + free_memory: expect.any(Number), + locale: expect.any(String), + memory_size: expect.any(Number), + model: expect.any(String), + model_id: expect.any(String), + processor_count: expect.any(Number), + simulator: expect.any(Boolean), + thermal_state: expect.any(String), + usable_memory: expect.any(Number), + }), + }), + }), + ); + }); + + it('contains app context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + app: expect.objectContaining({ + app_build: expect.any(String), + app_identifier: expect.any(String), + app_name: expect.any(String), + app_start_time: expect.any(String), + app_version: expect.any(String), + in_foreground: expect.any(Boolean), + // view_names: ['ErrorsScreen-jn5qquvH9Nz'], // TODO: fix this generated hash should not be part of the name + }), + }), + }), + ); + }); + + it('contains os context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + os: { + build: expect.any(String), + kernel_version: expect.any(String), + name: 'iOS', + rooted: expect.any(Boolean), + version: expect.any(String), + }, + }), + }), + ); + }); + + it('contains react native context', async () => { + const item = getItemOfTypeFrom(envelope, 'event'); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + react_native_context: { + expo: false, + fabric: expect.any(Boolean), + hermes_debug_info: expect.any(Boolean), + hermes_version: expect.any(String), + js_engine: 'hermes', + react_native_version: expect.any(String), + turbo_module: expect.any(Boolean), + }, + }), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/captureTransaction.test.ts b/samples/react-native/e2e/captureTransaction.test.ts new file mode 100644 index 0000000000..95d109c4e4 --- /dev/null +++ b/samples/react-native/e2e/captureTransaction.test.ts @@ -0,0 +1,205 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { EventItem } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingTransactionWithName, +} from './utils/mockedSentryServer'; +import { tap } from './utils/tap'; +import { sleep } from './utils/sleep'; +import { getItemOfTypeFrom } from './utils/event'; + +describe('Capture transaction', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + const getErrorsEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Errors')); + + const getTrackerEnvelope = () => + sentryServer.getEnvelope(containingTransactionWithName('Tracker')); + + beforeAll(async () => { + await device.launchApp(); + + const waitForPerformanceTransaction = sentryServer.waitForEnvelope( + containingTransactionWithName('Tracker'), // The last created and sent transaction + ); + + await sleep(500); + await tap('Performance'); // Bottom tab + await sleep(200); + await tap('Auto Tracing Example'); // Screen with Full Display + + await waitForPerformanceTransaction; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('envelope contains transaction context', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect(item).toEqual([ + expect.objectContaining({ + length: expect.any(Number), + type: 'transaction', + }), + expect.objectContaining({ + platform: 'javascript', + transaction: 'ErrorsScreen', + contexts: expect.objectContaining({ + trace: { + data: { + 'route.has_been_seen': false, + 'route.key': expect.stringMatching(/^ErrorsScreen/), + 'route.name': 'ErrorsScreen', + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'ui.load', + 'sentry.origin': 'auto.app.start', + 'sentry.sample_rate': 1, + 'sentry.source': 'component', + }, + op: 'ui.load', + origin: 'auto.app.start', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + }), + ]); + }); + + it('contains app start measurements', async () => { + const item = getItemOfTypeFrom( + getErrorsEnvelope(), + 'transaction', + ); + + expect( + item?.[1].measurements?.app_start_warm || + item?.[1].measurements?.app_start_cold, + ).toBeDefined(); + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + // Expect warm or cold app start measurements + ...(item?.[1].measurements?.app_start_warm && { + app_start_warm: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + ...(item?.[1].measurements?.app_start_cold && { + app_start_cold: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + }), + ); + }); + + it('contains time to initial display measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains JS stall measurements', async () => { + const item = getItemOfTypeFrom( + await getErrorsEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + stall_count: { + unit: 'none', + value: expect.any(Number), + }, + stall_longest_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + stall_total_time: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains time to display measurements', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + measurements: expect.objectContaining({ + time_to_initial_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + time_to_full_display: { + unit: 'millisecond', + value: expect.any(Number), + }, + }), + }), + ); + }); + + it('contains at least one xhr breadcrumb of request to the tracker endpoint', async () => { + const item = getItemOfTypeFrom( + getTrackerEnvelope(), + 'transaction', + ); + + expect(item?.[1]).toEqual( + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + category: 'xhr', + data: { + end_timestamp: expect.any(Number), + method: 'GET', + response_body_size: expect.any(Number), + start_timestamp: expect.any(Number), + status_code: expect.any(Number), + url: expect.stringContaining('api.covid19api.com/summary'), + }, + level: 'info', + timestamp: expect.any(Number), + type: 'http', + }), + ]), + }), + ); + }); +}); diff --git a/samples/react-native/e2e/envelopeHeader.test.android.ts b/samples/react-native/e2e/envelopeHeader.test.android.ts new file mode 100644 index 0000000000..dc4a75676d --- /dev/null +++ b/samples/react-native/e2e/envelopeHeader.test.android.ts @@ -0,0 +1,65 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEventWithAndroidMessage, +} from './utils/mockedSentryServer'; +import { HEADER } from './utils/consts'; +import { tap } from './utils/tap'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithAndroidMessage('Captured message'), + ); + + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('contains event_id and sent_at in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + event_id: expect.any(String), + sent_at: expect.any(String), + }), + ); + }); + + it('contains sdk info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + sdk: { + name: 'sentry.javascript.react-native', + version: expect.any(String), + }, + sent_at: expect.any(String), + }), + ); + }); + + it('contains trace info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + trace: { + environment: expect.any(String), + public_key: expect.any(String), + replay_id: expect.any(String), + trace_id: expect.any(String), + }, + }), + ); + }); +}); diff --git a/samples/react-native/e2e/envelopeHeader.test.ios.ts b/samples/react-native/e2e/envelopeHeader.test.ios.ts new file mode 100644 index 0000000000..5798b07d5d --- /dev/null +++ b/samples/react-native/e2e/envelopeHeader.test.ios.ts @@ -0,0 +1,71 @@ +import { describe, it, beforeAll, expect, afterAll } from '@jest/globals'; +import { Envelope } from '@sentry/core'; +import { device } from 'detox'; +import { + createSentryServer, + containingEventWithMessage, +} from './utils/mockedSentryServer'; +import { HEADER } from './utils/consts'; +import { tap } from './utils/tap'; + +describe('Capture message', () => { + let sentryServer = createSentryServer(); + sentryServer.start(); + + let envelope: Envelope; + + beforeAll(async () => { + await device.launchApp(); + + const envelopePromise = sentryServer.waitForEnvelope( + containingEventWithMessage('Captured message'), + ); + + await tap('Capture message'); + envelope = await envelopePromise; + }); + + afterAll(async () => { + await sentryServer.close(); + }); + + it('contains event_id and sent_at in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + event_id: expect.any(String), + sent_at: expect.any(String), + }), + ); + }); + + it('contains sdk info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + sdk: { + features: [], + integrations: [], + name: 'sentry.javascript.react-native', + packages: [], + version: expect.any(String), + }, + sent_at: expect.any(String), + }), + ); + }); + + it('contains trace info in the envelope header', async () => { + expect(envelope[HEADER]).toEqual( + expect.objectContaining({ + trace: { + environment: expect.any(String), + public_key: expect.any(String), + replay_id: expect.any(String), + sample_rate: '1', + sampled: '1', + trace_id: expect.any(String), + transaction: 'ErrorsScreen', + }, + }), + ); + }); +}); diff --git a/samples/react-native/e2e/jest.config.android.js b/samples/react-native/e2e/jest.config.android.js new file mode 100644 index 0000000000..a07210b212 --- /dev/null +++ b/samples/react-native/e2e/jest.config.android.js @@ -0,0 +1,7 @@ +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + testMatch: [...baseConfig.testMatch, '/e2e/**/*.test.android.ts'], +}; diff --git a/samples/react-native/e2e/jest.config.base.js b/samples/react-native/e2e/jest.config.base.js new file mode 100644 index 0000000000..b52d19a014 --- /dev/null +++ b/samples/react-native/e2e/jest.config.base.js @@ -0,0 +1,13 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + preset: 'ts-jest', + rootDir: '..', + testMatch: ['/e2e/**/*.test.ts'], + testTimeout: 120000, + maxWorkers: 1, + globalSetup: 'detox/runners/jest/globalSetup', + globalTeardown: 'detox/runners/jest/globalTeardown', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, +}; diff --git a/samples/react-native/e2e/jest.config.ios.auto.js b/samples/react-native/e2e/jest.config.ios.auto.js new file mode 100644 index 0000000000..ebfadcaeb6 --- /dev/null +++ b/samples/react-native/e2e/jest.config.ios.auto.js @@ -0,0 +1,11 @@ +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + testMatch: [ + ...baseConfig.testMatch, + '/e2e/**/*.test.ios.ts', + '/e2e/**/*.test.ios.auto.ts', + ], +}; diff --git a/samples/react-native/e2e/jest.config.ios.manual.js b/samples/react-native/e2e/jest.config.ios.manual.js new file mode 100644 index 0000000000..fe271fca7d --- /dev/null +++ b/samples/react-native/e2e/jest.config.ios.manual.js @@ -0,0 +1,11 @@ +const baseConfig = require('./jest.config.base'); + +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...baseConfig, + testMatch: [ + ...baseConfig.testMatch, + '/e2e/**/*.test.ios.ts', + '/e2e/**/*.test.ios.manual.ts', + ], +}; diff --git a/samples/react-native/e2e/starter.test.ts b/samples/react-native/e2e/starter.test.ts new file mode 100644 index 0000000000..b88c9d0882 --- /dev/null +++ b/samples/react-native/e2e/starter.test.ts @@ -0,0 +1,12 @@ +import { describe, it, beforeAll } from '@jest/globals'; +import { device, expect } from 'detox'; + +describe('Shows HomeScreen', () => { + beforeAll(async () => { + await device.launchApp(); + }); + + it('Shows Bottom Tab Bar', async () => { + await expect(element(by.text('Performance'))).toBeVisible(); + }); +}); diff --git a/samples/react-native/e2e/utils/consts.ts b/samples/react-native/e2e/utils/consts.ts new file mode 100644 index 0000000000..9a751a5fa4 --- /dev/null +++ b/samples/react-native/e2e/utils/consts.ts @@ -0,0 +1,2 @@ +export const HEADER = 0; +export const ITEMS = 1; diff --git a/samples/react-native/e2e/utils/event.ts b/samples/react-native/e2e/utils/event.ts new file mode 100644 index 0000000000..df631feb4e --- /dev/null +++ b/samples/react-native/e2e/utils/event.ts @@ -0,0 +1,11 @@ +import { Envelope, EnvelopeItem } from '@sentry/core'; +import { HEADER, ITEMS } from './consts'; + +export function getItemOfTypeFrom( + envelope: Envelope, + type: string, +): T | undefined { + return (envelope[ITEMS] as [{ type?: string }, unknown][]).find( + i => i[HEADER].type === type, + ) as T | undefined; +} diff --git a/samples/react-native/e2e/utils/mockedSentryServer.ts b/samples/react-native/e2e/utils/mockedSentryServer.ts new file mode 100644 index 0000000000..9c738ce6a5 --- /dev/null +++ b/samples/react-native/e2e/utils/mockedSentryServer.ts @@ -0,0 +1,143 @@ +import { IncomingMessage, ServerResponse, createServer } from 'node:http'; +import { createGunzip } from 'node:zlib'; +import { Envelope, EnvelopeItem } from '@sentry/core'; +import { parseEnvelope } from './parseEnvelope'; +import { Event } from '@sentry/core'; + +type RecordedRequest = { + path: string | undefined; + headers: Record; + body: Buffer; + envelope: Envelope; +}; + +export function createSentryServer({ port = 8961 } = {}): { + waitForEnvelope: ( + predicate: (envelope: Envelope) => boolean, + ) => Promise; + close: () => Promise; + start: () => void; + getEnvelope: (predicate: (envelope: Envelope) => boolean) => Envelope; +} { + let onNextRequestCallback: (request: RecordedRequest) => void = () => {}; + const requests: RecordedRequest[] = []; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + let body: Buffer = Buffer.from([]); + + const gunzip = createGunzip(); + req.pipe(gunzip); + + gunzip.on('data', (chunk: Buffer) => { + body = Buffer.concat([body, chunk]); + }); + + gunzip.on('end', () => { + const request = { + path: req.url, + headers: req.headers, + body: body, + envelope: parseEnvelope(body), + }; + requests.push(request); + + body = Buffer.from([]); + + res.writeHead(200); + res.end('OK'); + + onNextRequestCallback(request); + }); + }); + + return { + start: () => { + server.listen(port); + }, + waitForEnvelope: async ( + predicate: (envelope: Envelope) => boolean, + ): Promise => { + return new Promise((resolve, reject) => { + onNextRequestCallback = (request: RecordedRequest) => { + try { + if (predicate(request.envelope)) { + resolve(request.envelope); + return; + } + } catch (e) { + reject(e); + return; + } + }; + }); + }, + close: async () => { + await new Promise(resolve => { + server.close(() => resolve()); + }); + }, + getEnvelope: (predicate: (envelope: Envelope) => boolean) => { + const envelope = requests.find( + request => request.envelope && predicate(request.envelope), + )?.envelope; + + if (!envelope) { + throw new Error('Envelope not found'); + } + + return envelope; + }, + }; +} + +export function containingEvent(envelope: Envelope) { + return envelope[1].some(item => itemHeaderIsType(item[0], 'event')); +} + +export function containingEventWithAndroidMessage(message: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'event') && + itemBodyIsEvent(item[1]) && + item[1].message && + (item[1].message as unknown as { message: string }).message === message, + ); +} + +export function containingEventWithMessage(message: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'event') && + itemBodyIsEvent(item[1]) && + item[1].message === message, + ); +} + +export function containingTransactionWithName(name: string) { + return (envelope: Envelope) => + envelope[1].some( + item => + itemHeaderIsType(item[0], 'transaction') && + itemBodyIsEvent(item[1]) && + item[1].transaction && + item[1].transaction.includes(name), + ); +} + +export function itemBodyIsEvent(itemBody: EnvelopeItem[1]): itemBody is Event { + return typeof itemBody === 'object' && 'event_id' in itemBody; +} + +export function itemHeaderIsType(itemHeader: EnvelopeItem[0], type: string) { + if (typeof itemHeader !== 'object' || !('type' in itemHeader)) { + return false; + } + + if (itemHeader.type !== type) { + return false; + } + + return true; +} diff --git a/samples/react-native/e2e/utils/parseEnvelope.ts b/samples/react-native/e2e/utils/parseEnvelope.ts new file mode 100644 index 0000000000..e6b29b201e --- /dev/null +++ b/samples/react-native/e2e/utils/parseEnvelope.ts @@ -0,0 +1,74 @@ +import { + Envelope, + BaseEnvelopeHeaders, + BaseEnvelopeItemHeaders, +} from '@sentry/core'; + +/** + * Parses an envelope + */ +export function parseEnvelope(env: string | Uint8Array): Envelope { + let buffer = typeof env === 'string' ? encodeUTF8(env) : env; + + function readBinary(length?: number): Uint8Array { + if (!length) { + throw new Error('Binary Envelope Items must have a length to be read'); + } + const bin = buffer.subarray(0, length); + // Replace the buffer with the remaining data excluding trailing newline + buffer = buffer.subarray(length + 1); + return bin; + } + + function readJson(): T { + let i = buffer.indexOf(0xa); + // If we couldn't find a newline, we must have found the end of the buffer + if (i < 0) { + i = buffer.length; + } + + return JSON.parse(decodeUTF8(readBinary(i))) as T; + } + + const envelopeHeader = readJson(); + + const items: [any, any][] = []; + + while (buffer.length) { + const itemHeader = readJson(); + const isBinaryAttachment = + itemHeader.type === 'attachment' && + itemHeader.content_type !== 'application/json'; + // TODO: Parse when needed for the tests + const isReplayVideo = (itemHeader.type as string) === 'replay_video'; + + try { + let item: any = {}; + if (isReplayVideo || isBinaryAttachment) { + item = readBinary(itemHeader.length); + } else { + item = readJson(); + } + items.push([itemHeader, item]); + } catch (e) { + console.error(e, 'itemHeader', itemHeader, 'buffer', buffer.toString()); + throw e; + } + } + + return [envelopeHeader, items]; +} + +/** + * Encode a string to UTF8 array. + */ +function encodeUTF8(input: string): Uint8Array { + return new TextEncoder().encode(input); +} + +/** + * Decode a UTF8 array to string. + */ +function decodeUTF8(input: Uint8Array): string { + return new TextDecoder().decode(input); +} diff --git a/samples/react-native/e2e/utils/sleep.ts b/samples/react-native/e2e/utils/sleep.ts new file mode 100644 index 0000000000..a3b7734163 --- /dev/null +++ b/samples/react-native/e2e/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/samples/react-native/e2e/utils/tap.ts b/samples/react-native/e2e/utils/tap.ts new file mode 100644 index 0000000000..3b12d61e31 --- /dev/null +++ b/samples/react-native/e2e/utils/tap.ts @@ -0,0 +1,14 @@ +import { element, by } from 'detox'; + +export const tap = async (text: string) => { + await element(by.text(createFlexibleRegex(text))).tap(); +}; + +/** + * Creates regex that matches case insensitive and allows flexible spacing between words + */ +function createFlexibleRegex(input: string) { + const words = input.trim().split(/\s+/); + const pattern = words.join('\\s*'); + return new RegExp(pattern, 'i'); +} diff --git a/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme b/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme index 15d942042b..1b6c7a3f15 100644 --- a/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme +++ b/samples/react-native/ios/sentryreactnativesample.xcodeproj/xcshareddata/xcschemes/sentryreactnativesample.xcscheme @@ -60,6 +60,16 @@ ReferencedContainer = "container:sentryreactnativesample.xcodeproj"> + + + + + + #endif +#import #import #import @@ -20,44 +21,18 @@ @implementation AppDelegate -- (void)initializeSentry -{ - [SentrySDK startWithConfigureOptions:^(SentryOptions *options) { - // Only options set here will apply to the iOS SDK - // Options from JS are not passed to the iOS SDK when initialized manually - options.dsn = @"https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561"; - options.debug = YES; // Enabled debug when first installing is always helpful - - options.beforeSend = ^SentryEvent *(SentryEvent *event) - { - // We don't want to send an event after startup that came from a Unhandled JS Exception - // of react native Because we sent it already before the app crashed. - if (nil != event.exceptions.firstObject.type && - [event.exceptions.firstObject.type rangeOfString:@"Unhandled JS Exception"].location - != NSNotFound) { - NSLog(@"Unhandled JS Exception"); - return nil; - } - - return event; - }; - - // Enable the App start and Frames tracking measurements - // If this is disabled the app start and frames tracking - // won't be passed from native to JS transactions - PrivateSentrySDKOnly.appStartMeasurementHybridSDKMode = true; -#if TARGET_OS_IPHONE || TARGET_OS_MACCATALYST - PrivateSentrySDKOnly.framesTrackingMeasurementHybridSDKMode = true; -#endif - }]; -} - - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - // When the native init is enabled the `autoInitializeNativeSdk` - // in JS has to be set to `false` - // [self initializeSentry]; + if ([self shouldStartSentry]) { + [RNSentrySDK start]; + } + + if ([self shoudCrashOnStart]) { + @throw [NSException exceptionWithName:@"CrashOnStart" + reason:@"This was intentional test crash before JS started." + userInfo:nil]; + } self.moduleName = @"sentry-react-native-sample"; @@ -108,4 +83,16 @@ - (BOOL)concurrentRootEnabled return nullptr; } +- (BOOL)shouldStartSentry +{ + NSArray *arguments = [[NSProcessInfo processInfo] arguments]; + return ![arguments containsObject:@"--sentry-disable-native-start"]; +} + +- (BOOL)shoudCrashOnStart +{ + NSArray *arguments = [[NSProcessInfo processInfo] arguments]; + return [arguments containsObject:@"--sentry-crash-on-start"]; +} + @end diff --git a/samples/react-native/jest.config.js b/samples/react-native/jest.config.js new file mode 100644 index 0000000000..27803eeafc --- /dev/null +++ b/samples/react-native/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + testMatch: [ + '/__tests__/**/*-test.ts', + '/__tests__/**/*-test.tsx', + ], +}; diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 9bf84c8903..8e80a5d598 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -4,16 +4,29 @@ "private": true, "scripts": { "postinstall": "patch-package", - "android": "react-native run-android", - "ios": "react-native run-ios", "start": "react-native start", + "build-android-release": "scripts/build-android-release.sh", + "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-ios-release": "scripts/build-ios-release.sh", + "build-ios-debug": "scripts/build-ios-debug.sh", "test": "jest", + "set-test-dsn-android": "scripts/set-dsn-aos.mjs", + "set-test-dsn-ios": "scripts/set-dsn-ios.mjs", + "test-android": "scripts/test-android.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", "fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", - "pod-install": "cd ios; RCT_NEW_ARCH_ENABLED=1 bundle exec pod install; cd ..", - "pod-install-production": "cd ios; PRODUCTION=1 RCT_NEW_ARCH_ENABLED=1 bundle exec pod install; cd ..", - "pod-install-legacy": "cd ios; bundle exec pod install; cd ..", - "pod-install-legacy-production": "cd ios; PRODUCTION=1 bundle exec pod install; cd ..", + "pod-install-debug-static": "scripts/pod-install-debug-static.sh", + "pod-install-debug-static-legacy": "scripts/pod-install-debug-static-legacy.sh", + "pod-install-debug-dynamic": "scripts/pod-install-debug-dynamic.sh", + "pod-install-debug-dynamic-legacy": "scripts/pod-install-debug-dynamic-legacy.sh", + "pod-install-release-static": "scripts/pod-install-release-static.sh", + "pod-install-release-static-legacy": "scripts/pod-install-release-static-legacy.sh", + "pod-install-release-dynamic": "scripts/pod-install-release-dynamic.sh", + "pod-install-release-dynamic-legacy": "scripts/pod-install-release-dynamic-legacy.sh", "clean-ios": "cd ios; rm -rf Podfile.lock Pods build; cd ..", "clean-watchman": "watchman watch-del-all", "set-build-number": "npx react-native-version --skip-tag --never-amend --set-build", @@ -29,8 +42,10 @@ "delay": "^6.0.0", "react": "18.3.1", "react-native": "0.77.1", + "react-native-build-config": "^0.3.2", "react-native-gesture-handler": "^2.22.1", "react-native-image-picker": "^8.0.0", + "react-native-launch-arguments": "^4.0.4", "react-native-reanimated": "3.16.7", "react-native-safe-area-context": "5.2.0", "react-native-screens": "4.6.0", @@ -52,6 +67,7 @@ "@react-native/metro-config": "0.77.1", "@react-native/typescript-config": "0.77.1", "@sentry/babel-plugin-component-annotate": "3.2.0", + "@sentry/core": "8.54.0", "@types/react": "^18.2.65", "@types/react-native-vector-icons": "^6.4.18", "@types/react-test-renderer": "^18.0.0", @@ -59,6 +75,7 @@ "@typescript-eslint/parser": "^7.18.0", "babel-jest": "^29.6.3", "babel-plugin-module-resolver": "^5.0.0", + "detox": "^20.33.0", "eslint": "^8.19.0", "eslint-plugin-ft-flow": "^3.0.11", "jest": "^29.6.3", @@ -66,6 +83,7 @@ "prettier": "2.8.8", "react-test-renderer": "18.3.1", "sentry-react-native-samples-utils": "workspace:^", + "ts-jest": "^29.2.5", "typescript": "5.0.4" }, "engines": { diff --git a/samples/react-native/scripts/build-android-debug-legacy.sh b/samples/react-native/scripts/build-android-debug-legacy.sh new file mode 100755 index 0000000000..ac0952892d --- /dev/null +++ b/samples/react-native/scripts/build-android-debug-legacy.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="legacy" +export CONFIG="debug" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android-debug.sh b/samples/react-native/scripts/build-android-debug.sh new file mode 100755 index 0000000000..89f9ae626c --- /dev/null +++ b/samples/react-native/scripts/build-android-debug.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="debug" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android-release-legacy.sh b/samples/react-native/scripts/build-android-release-legacy.sh new file mode 100755 index 0000000000..cf853c15cc --- /dev/null +++ b/samples/react-native/scripts/build-android-release-legacy.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="legacy" +export CONFIG="release" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android-release.sh b/samples/react-native/scripts/build-android-release.sh new file mode 100755 index 0000000000..3403a3c1bb --- /dev/null +++ b/samples/react-native/scripts/build-android-release.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +export RN_ARCHITECTURE="new" +export CONFIG="release" + +"${thisFilePath}/build-android.sh" diff --git a/samples/react-native/scripts/build-android.sh b/samples/react-native/scripts/build-android.sh new file mode 100755 index 0000000000..866b5cc130 --- /dev/null +++ b/samples/react-native/scripts/build-android.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/../android" + +rm -rf ../app.apk ../app-androidTest.apk + +if [[ "${RN_ARCHITECTURE}" == 'new' ]]; then + perl -i -pe's/newArchEnabled=false/newArchEnabled=true/g' gradle.properties + echo 'New Architecture enabled' +elif [[ "${RN_ARCHITECTURE}" == 'legacy' ]]; then + perl -i -pe's/newArchEnabled=true/newArchEnabled=false/g' gradle.properties + echo 'Legacy Architecture enabled' +else + echo "No changes for architecture: ${RN_ARCHITECTURE}" +fi + +echo "Building $CONFIG" + +assembleConfig=$(python -c "print(\"${CONFIG}\".capitalize())") + +./gradlew ":app:assemble${assembleConfig}" app:assembleAndroidTest -DtestBuildType=$CONFIG "$@" + +cp "app/build/outputs/apk/${CONFIG}/app-${CONFIG}.apk" ../app.apk +cp "app/build/outputs/apk/androidTest/${CONFIG}/app-${CONFIG}-androidTest.apk" ../app-androidTest.apk diff --git a/samples/react-native/scripts/build-ios-debug.sh b/samples/react-native/scripts/build-ios-debug.sh new file mode 100755 index 0000000000..088ed50f28 --- /dev/null +++ b/samples/react-native/scripts/build-ios-debug.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +export CONFIG='Debug' + +"${thisFilePath}/build-ios.sh" diff --git a/samples/react-native/scripts/build-ios-release.sh b/samples/react-native/scripts/build-ios-release.sh new file mode 100755 index 0000000000..4a21d04c17 --- /dev/null +++ b/samples/react-native/scripts/build-ios-release.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +export CONFIG='Release' + +"${thisFilePath}/build-ios.sh" diff --git a/samples/react-native/scripts/build-ios.sh b/samples/react-native/scripts/build-ios.sh new file mode 100755 index 0000000000..7897aba332 --- /dev/null +++ b/samples/react-native/scripts/build-ios.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/../ios" + +rm -rf ../sentryreactnativesample.app + +echo "Building $CONFIG" + +rm -rf xcodebuild.log + +mkdir -p "DerivedData" +derivedData="$(cd "DerivedData" ; pwd -P)" +set -o pipefail && xcodebuild \ + -workspace sentryreactnativesample.xcworkspace \ + -configuration "$CONFIG" \ + -scheme sentryreactnativesample \ + -sdk 'iphonesimulator' \ + -destination 'generic/platform=iOS Simulator' \ + ONLY_ACTIVE_ARCH=yes \ + -derivedDataPath "$derivedData" \ + build \ + | tee xcodebuild.log \ + | if [ "$CI" = "true" ]; then xcbeautify --quieter --is-ci --disable-colored-output; else xcbeautify; fi + +cp -r "DerivedData/Build/Products/${CONFIG}-iphonesimulator/sentryreactnativesample.app" ../sentryreactnativesample.app diff --git a/samples/react-native/scripts/detect-ios-sim.sh b/samples/react-native/scripts/detect-ios-sim.sh new file mode 100755 index 0000000000..1ba6d3b98c --- /dev/null +++ b/samples/react-native/scripts/detect-ios-sim.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +if [ -z "$IOS_DEVICE" ]; then + # Get the first booted simulator device type and version + BOOTED_DEVICE=$(xcrun simctl list devices | grep "Booted" | head -n 1) + + if [ -z "$BOOTED_DEVICE" ]; then + echo "No booted iOS simulator found" + exit 1 + fi + + # Extract device type from booted device + export IOS_DEVICE=$(echo "$BOOTED_DEVICE" | cut -d "(" -f1 | xargs) + echo "Using booted iOS simulator: $IOS_DEVICE" +fi diff --git a/samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh b/samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh new file mode 100755 index 0000000000..cea9690bb0 --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-dynamic-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-debug-dynamic.sh b/samples/react-native/scripts/pod-install-debug-dynamic.sh new file mode 100755 index 0000000000..ed3acafb8b --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-dynamic.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-debug-static-legacy.sh b/samples/react-native/scripts/pod-install-debug-static-legacy.sh new file mode 100755 index 0000000000..52b80ba450 --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-static-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-debug-static.sh b/samples/react-native/scripts/pod-install-debug-static.sh new file mode 100755 index 0000000000..86049e4425 --- /dev/null +++ b/samples/react-native/scripts/pod-install-debug-static.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=0 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-dynamic-legacy.sh b/samples/react-native/scripts/pod-install-release-dynamic-legacy.sh new file mode 100755 index 0000000000..d6f7449abc --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-dynamic-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-dynamic.sh b/samples/react-native/scripts/pod-install-release-dynamic.sh new file mode 100755 index 0000000000..9207b45dfe --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-dynamic.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=dynamic + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-static-legacy.sh b/samples/react-native/scripts/pod-install-release-static-legacy.sh new file mode 100755 index 0000000000..9742caa73a --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-static-legacy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=0 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install-release-static.sh b/samples/react-native/scripts/pod-install-release-static.sh new file mode 100755 index 0000000000..8de5b13a61 --- /dev/null +++ b/samples/react-native/scripts/pod-install-release-static.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error +set -e + +export ENABLE_PROD=1 +export ENABLE_NEW_ARCH=1 +export USE_FRAMEWORKS=static + +thisFilePath=$(dirname "$0") + +"${thisFilePath}/pod-install.sh" diff --git a/samples/react-native/scripts/pod-install.sh b/samples/react-native/scripts/pod-install.sh new file mode 100755 index 0000000000..5d1ada6789 --- /dev/null +++ b/samples/react-native/scripts/pod-install.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Exit on error +set -e + +thisFilePath=$(dirname "$0") + +echo "USE_FRAMEWORKS=$USE_FRAMEWORKS" +echo "ENABLE_PROD=$ENABLE_PROD" +echo "ENABLE_NEW_ARCH=$ENABLE_NEW_ARCH" + +cd "${thisFilePath}/.." +bundle install + +cd ios +PRODUCTION=$ENABLE_PROD RCT_NEW_ARCH_ENABLED=$ENABLE_NEW_ARCH bundle exec pod update + +cat Podfile.lock | grep $RN_SENTRY_POD_NAME diff --git a/samples/react-native/scripts/set-dsn-aos.mjs b/samples/react-native/scripts/set-dsn-aos.mjs new file mode 100755 index 0000000000..01bed2c984 --- /dev/null +++ b/samples/react-native/scripts/set-dsn-aos.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { setAndroidDsn } from './set-dsn.mjs'; + +setAndroidDsn(); diff --git a/samples/react-native/scripts/set-dsn-ios.mjs b/samples/react-native/scripts/set-dsn-ios.mjs new file mode 100755 index 0000000000..7757c1e7b7 --- /dev/null +++ b/samples/react-native/scripts/set-dsn-ios.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { setIosDsn } from './set-dsn.mjs'; + +setIosDsn(); diff --git a/samples/react-native/scripts/set-dsn.mjs b/samples/react-native/scripts/set-dsn.mjs new file mode 100644 index 0000000000..da2153f203 --- /dev/null +++ b/samples/react-native/scripts/set-dsn.mjs @@ -0,0 +1,24 @@ +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function setIosDsn() { + setDsn('http://key@localhost:8961/123456'); +} + +export function setAndroidDsn() { + setDsn('http://key@10.0.2.2:8961/123456'); +} + +function setDsn(dsn) { + const sentryOptionsPath = path.join(__dirname, '../sentry.options.json'); + const sentryOptions = JSON.parse(fs.readFileSync(sentryOptionsPath, 'utf8')); + sentryOptions.dsn = dsn; + fs.writeFileSync( + sentryOptionsPath, + JSON.stringify(sentryOptions, null, 2) + '\n', + ); + console.log('Dsn set to: ', dsn); +} diff --git a/samples/react-native/scripts/test-android.sh b/samples/react-native/scripts/test-android.sh new file mode 100755 index 0000000000..bbef32a81f --- /dev/null +++ b/samples/react-native/scripts/test-android.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/.." + +if [ -z "$ANDROID_AVD_NAME" ]; then + # Get the name of the first booted or connected Android device + DEVICE_NAME=$(adb devices | grep -w "device" | head -n 1 | cut -f 1) + + if [ -z "$DEVICE_NAME" ]; then + echo "No Android device or emulator found" + exit 1 + fi + + if [[ "$DEVICE_NAME" == *"emulator"* ]]; then + # Get the name of the first booted or connected Android emulator/device + EMULATOR_NAME=$(adb -s "${DEVICE_NAME}" emu avd name | head -n 1 | cut -f 1 ) + + if [ -z "$EMULATOR_NAME" ]; then + echo "No Android emulator found" + exit 1 + fi + + export ANDROID_TYPE="android.emulator" + export ANDROID_AVD_NAME="$EMULATOR_NAME" + echo "Using Android emulator: $EMULATOR_NAME" + else + export ANDROID_TYPE="android.attached" + export ANDROID_ADB_NAME="$DEVICE_NAME" + + adb reverse tcp:8081 tcp:8081 + adb reverse tcp:8961 tcp:8961 + + echo "Using Android device: $DEVICE_NAME" + fi +fi + +# Run the tests +detox test --configuration ci.android diff --git a/samples/react-native/scripts/test-ios-auto.sh b/samples/react-native/scripts/test-ios-auto.sh new file mode 100755 index 0000000000..9192a9619e --- /dev/null +++ b/samples/react-native/scripts/test-ios-auto.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/.." + +"${thisFilePath}/detect-ios-sim.sh" + +detox test --configuration ci.sim.auto --app-launch-args="--sentry-disable-native-start" diff --git a/samples/react-native/scripts/test-ios-manual.sh b/samples/react-native/scripts/test-ios-manual.sh new file mode 100755 index 0000000000..b1c295edda --- /dev/null +++ b/samples/react-native/scripts/test-ios-manual.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Exit on error and print commands +set -xe + +thisFilePath=$(dirname "$0") + +cd "${thisFilePath}/.." + +"${thisFilePath}/detect-ios-sim.sh" + +detox test --configuration ci.sim.manual diff --git a/samples/react-native/sentry.options.json b/samples/react-native/sentry.options.json new file mode 100644 index 0000000000..58425c35d5 --- /dev/null +++ b/samples/react-native/sentry.options.json @@ -0,0 +1,18 @@ +{ + "dsn": "https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561", + "debug": true, + "environment": "dev", + "enableUserInteractionTracing": true, + "enableAutoSessionTracking": true, + "sessionTrackingIntervalMillis": 30000, + "enableTracing": true, + "tracesSampleRate": 1, + "attachStacktrace": true, + "attachScreenshot": true, + "attachViewHierarchy": true, + "enableCaptureFailedRequests": true, + "profilesSampleRate": 1, + "replaysSessionSampleRate": 1, + "replaysOnErrorSampleRate": 1, + "spotlight": true +} diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index f0e612d862..d771b43343 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -18,7 +18,6 @@ import Animated, { import * as Sentry from '@sentry/react-native'; import { FeedbackWidget } from '@sentry/react-native'; -import { SENTRY_INTERNAL_DSN } from './dsn'; import ErrorsScreen from './Screens/ErrorsScreen'; import PerformanceScreen from './Screens/PerformanceScreen'; import TrackerScreen from './Screens/TrackerScreen'; @@ -32,13 +31,16 @@ import GesturesTracingScreen from './Screens/GesturesTracingScreen'; import { LogBox, Platform, StyleSheet, View } from 'react-native'; import Ionicons from 'react-native-vector-icons/Ionicons'; import PlaygroundScreen from './Screens/PlaygroundScreen'; -import { logWithoutTracing } from './utils'; +import { logWithoutTracing, shouldUseAutoStart } from './utils'; import { ErrorEvent } from '@sentry/core'; import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; import * as ImagePicker from 'react-native-image-picker'; +/* false by default to avoid issues in e2e tests waiting for the animation end */ +const RUNNING_INDICATOR = false; + if (typeof setImmediate === 'undefined') { require('setimmediate'); } @@ -53,10 +55,6 @@ const reactNavigationIntegration = Sentry.reactNavigationIntegration({ }); Sentry.init({ - // Replace the example DSN below with your own DSN: - dsn: SENTRY_INTERNAL_DSN, - debug: true, - environment: 'dev', beforeSend: (event: ErrorEvent) => { logWithoutTracing('Event beforeSend:', event.event_id); return event; @@ -72,7 +70,6 @@ Sentry.init({ didCallNativeInit, ); }, - enableUserInteractionTracing: true, integrations(integrations) { integrations.push( reactNavigationIntegration, @@ -121,31 +118,8 @@ Sentry.init({ ); return integrations.filter(i => i.name !== 'Dedupe'); }, - enableAutoSessionTracking: true, - // For testing, session close when 5 seconds (instead of the default 30) in the background. - sessionTrackingIntervalMillis: 30000, - // This will capture ALL TRACES and likely use up all your quota - enableTracing: true, - tracesSampleRate: 1.0, tracePropagationTargets: ['localhost', /^\//, /^https:\/\//, /^http:\/\//], - attachStacktrace: true, - // Attach screenshots to events. - attachScreenshot: true, - // Attach view hierarchy to events. - attachViewHierarchy: true, - // Enables capture failed requests in JS and native. - enableCaptureFailedRequests: true, - // Sets the `release` and `dist` on Sentry events. Make sure this matches EXACTLY with the values on your sourcemaps - // otherwise they will not work. - // release: 'myapp@1.2.3+1', - // dist: `1`, - profilesSampleRate: 1.0, - replaysSessionSampleRate: 1.0, - replaysOnErrorSampleRate: 1.0, - spotlight: true, - // This should be disabled when manually initializing the native SDK - // Note that options from JS are not passed to the native SDKs when initialized manually - autoInitializeNativeSdk: true, + autoInitializeNativeSdk: shouldUseAutoStart(), }); const Stack = isMobileOs @@ -322,6 +296,10 @@ function RunningIndicator() { return null; } + if (!RUNNING_INDICATOR) { + return null; + } + return ; } diff --git a/samples/react-native/src/dsn.ts b/samples/react-native/src/dsn.ts deleted file mode 100644 index 345276a627..0000000000 --- a/samples/react-native/src/dsn.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as Sentry from '@sentry/react-native'; - -export const SENTRY_INTERNAL_DSN = - 'https://1df17bd4e543fdb31351dee1768bb679@o447951.ingest.sentry.io/5428561'; - -export const getCurrentDsn = () => { - return Sentry.getCurrentHub().getClient()?.getOptions().dsn; -}; diff --git a/samples/react-native/src/utils.ts b/samples/react-native/src/utils.ts index 8681333e30..437787dcc3 100644 --- a/samples/react-native/src/utils.ts +++ b/samples/react-native/src/utils.ts @@ -1,3 +1,7 @@ +import { LaunchArguments } from 'react-native-launch-arguments'; +import BuildConfig from 'react-native-build-config'; +import { Platform } from 'react-native'; + export function logWithoutTracing(...args: unknown[]) { if ('__sentry_original__' in console.log) { console.log.__sentry_original__(...args); @@ -5,3 +9,20 @@ export function logWithoutTracing(...args: unknown[]) { console.log(...args); } } + +export function shouldUseAutoStart(): boolean { + if (Platform.OS === 'android') { + return !!( + BuildConfig as { + SENTRY_DISABLE_NATIVE_START?: boolean; + } + ).SENTRY_DISABLE_NATIVE_START; + } else if (Platform.OS === 'ios') { + const args = LaunchArguments.value<{ + sentrydisablenativestart?: boolean; + }>(); + return !!args.sentrydisablenativestart; + } else { + return false; + } +} diff --git a/yarn.lock b/yarn.lock index 1371a38389..d0220b5a0b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4694,6 +4694,13 @@ __metadata: languageName: node linkType: hard +"@flatten-js/interval-tree@npm:^1.1.2": + version: 1.1.3 + resolution: "@flatten-js/interval-tree@npm:1.1.3" + checksum: 8ff9dc4062b20bd1bcff735b6734d93489409af59f87db799abe534d745dd8cd9293a15e720a999058bc97c66b88f1cdb14f6142d122723ffe52032c5ca2efde + languageName: node + linkType: hard + "@hapi/hoek@npm:^9.0.0, @hapi/hoek@npm:^9.3.0": version: 9.3.0 resolution: "@hapi/hoek@npm:9.3.0" @@ -9973,7 +9980,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.9.0": +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.6.3, ajv@npm:^8.9.0": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -11255,7 +11262,7 @@ __metadata: languageName: node linkType: hard -"bluebird@npm:3.7.2, bluebird@npm:^3.1.1, bluebird@npm:^3.4.7, bluebird@npm:^3.5.1, bluebird@npm:^3.5.5, bluebird@npm:^3.7.2": +"bluebird@npm:3.7.2, bluebird@npm:^3.1.1, bluebird@npm:^3.4.7, bluebird@npm:^3.5.1, bluebird@npm:^3.5.4, bluebird@npm:^3.5.5, bluebird@npm:^3.7.2": version: 3.7.2 resolution: "bluebird@npm:3.7.2" checksum: 869417503c722e7dc54ca46715f70e15f4d9c602a423a02c825570862d12935be59ed9c7ba34a9b31f186c017c23cac6b54e35446f8353059c101da73eac22ef @@ -11362,6 +11369,13 @@ __metadata: languageName: node linkType: hard +"browser-process-hrtime@npm:^1.0.0": + version: 1.0.0 + resolution: "browser-process-hrtime@npm:1.0.0" + checksum: e30f868cdb770b1201afb714ad1575dd86366b6e861900884665fb627109b3cc757c40067d3bfee1ff2a29c835257ea30725a8018a9afd02ac1c24b408b1e45f + languageName: node + linkType: hard + "browserslist@npm:^4.23.1, browserslist@npm:^4.23.3": version: 4.23.3 resolution: "browserslist@npm:4.23.3" @@ -11487,6 +11501,87 @@ __metadata: languageName: node linkType: hard +"bunyamin@npm:^1.5.2": + version: 1.6.3 + resolution: "bunyamin@npm:1.6.3" + dependencies: + "@flatten-js/interval-tree": ^1.1.2 + multi-sort-stream: ^1.0.4 + stream-json: ^1.7.5 + trace-event-lib: ^1.3.1 + peerDependencies: + "@types/bunyan": ^1.8.8 + bunyan: ^1.8.15 || ^2.0.0 + peerDependenciesMeta: + "@types/bunyan": + optional: true + bunyan: + optional: true + checksum: 3422db179c2f1d9581740b18de79c925e2ab25ee49ea5e66a5b66db16372d6f641927de55010c997050049d9e9569f4b720d409ffa0a573ded86aef5d49768eb + languageName: node + linkType: hard + +"bunyan-debug-stream@npm:^3.1.0": + version: 3.1.1 + resolution: "bunyan-debug-stream@npm:3.1.1" + dependencies: + chalk: ^4.1.2 + peerDependencies: + bunyan: "*" + peerDependenciesMeta: + bunyan: + optional: true + checksum: e0dd2c42de27857bd7c70b600ac30ecf7ef5efe7837c6ea2d87b98e48c7cd16a4fcce1d08439d9fc5dbff2d672b191357ea579750c9cd6379703109f5077bca4 + languageName: node + linkType: hard + +"bunyan@npm:^1.8.12": + version: 1.8.15 + resolution: "bunyan@npm:1.8.15" + dependencies: + dtrace-provider: ~0.8 + moment: ^2.19.3 + mv: ~2 + safe-json-stringify: ~1 + dependenciesMeta: + dtrace-provider: + optional: true + moment: + optional: true + mv: + optional: true + safe-json-stringify: + optional: true + bin: + bunyan: bin/bunyan + checksum: a479e0787c3a0b6565b54bd15f0b6c729d624c5aba53523e140e49e279b7a78508df93000e758bf6d02361117d6b4e6e5fc1d5ece05366fb6c4ba41bf1ac7d52 + languageName: node + linkType: hard + +"bunyan@npm:^2.0.5": + version: 2.0.5 + resolution: "bunyan@npm:2.0.5" + dependencies: + dtrace-provider: ~0.8 + exeunt: 1.1.0 + moment: ^2.19.3 + mv: ~2 + safe-json-stringify: ~1 + dependenciesMeta: + dtrace-provider: + optional: true + moment: + optional: true + mv: + optional: true + safe-json-stringify: + optional: true + bin: + bunyan: bin/bunyan + checksum: a932e883387e5bef23eee0f1f9af94e8b885da32492eaf7164dc58e3b42e5a65845068beb7ac8fbcff31511a55728c1a826bf48ba3e4edd7e220ebf0fe2ab989 + languageName: node + linkType: hard + "byte-size@npm:8.1.1": version: 8.1.1 resolution: "byte-size@npm:8.1.1" @@ -11550,6 +11645,13 @@ __metadata: languageName: node linkType: hard +"caf@npm:^15.0.1": + version: 15.0.1 + resolution: "caf@npm:15.0.1" + checksum: 832cc5d3a6053efb458ed1c1f5e5d3ebbc7710f2275f033c6362dcfd1565f15e29dbee15fa0f3301ecb5c4dbdc753c070b5a4a6d3dc8e246cb784cb26c601e8b + languageName: node + linkType: hard + "call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.6, call-bind@npm:^1.0.7": version: 1.0.7 resolution: "call-bind@npm:1.0.7" @@ -11731,6 +11833,17 @@ __metadata: languageName: node linkType: hard +"child-process-promise@npm:^2.2.0": + version: 2.2.1 + resolution: "child-process-promise@npm:2.2.1" + dependencies: + cross-spawn: ^4.0.2 + node-version: ^1.0.0 + promise-polyfill: ^6.0.1 + checksum: fb72dda7ee78099f106d57bf3d7cc3225c16c9ddfe8e364e3535a52396482ee81aecd3eff0da7131ca17b7ba9fcbb8af827da63a03f0c3262c76268696898642 + languageName: node + linkType: hard + "chokidar@npm:^3.4.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -12596,6 +12709,16 @@ __metadata: languageName: node linkType: hard +"cross-spawn@npm:^4.0.2": + version: 4.0.2 + resolution: "cross-spawn@npm:4.0.2" + dependencies: + lru-cache: ^4.0.1 + which: ^1.2.9 + checksum: 8ce57b3e11c5c798542a21ddfdc1edef33ab6fe001958b31f3340a6ff684e3334a8baad2751efa78b6200aad442cf12b939396d758b0dd5c42c9b782c28fe06e + languageName: node + linkType: hard + "cross-spawn@npm:^6.0.0": version: 6.0.6 resolution: "cross-spawn@npm:6.0.6" @@ -12899,6 +13022,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^4.0.0": + version: 4.0.0 + resolution: "decamelize@npm:4.0.0" + checksum: b7d09b82652c39eead4d6678bb578e3bebd848add894b76d0f6b395bc45b2d692fb88d977e7cfb93c4ed6c119b05a1347cef261174916c2e75c0a8ca57da1809 + languageName: node + linkType: hard + "decamelize@npm:^6.0.0": version: 6.0.0 resolution: "decamelize@npm:6.0.0" @@ -13362,6 +13492,64 @@ __metadata: languageName: node linkType: hard +"detox-copilot@npm:^0.0.27": + version: 0.0.27 + resolution: "detox-copilot@npm:0.0.27" + checksum: 4f01ed1f21fe3128ee50037b63085fe95ccdc9e723c6b034d53720fa325123e39d4f83d18b1ab88a11a679258b0ff734e74f0738118e260f10945fadbe205443 + languageName: node + linkType: hard + +"detox@npm:^20.33.0": + version: 20.33.0 + resolution: "detox@npm:20.33.0" + dependencies: + ajv: ^8.6.3 + bunyan: ^1.8.12 + bunyan-debug-stream: ^3.1.0 + caf: ^15.0.1 + chalk: ^4.0.0 + child-process-promise: ^2.2.0 + detox-copilot: ^0.0.27 + execa: ^5.1.1 + find-up: ^5.0.0 + fs-extra: ^11.0.0 + funpermaproxy: ^1.1.0 + glob: ^8.0.3 + ini: ^1.3.4 + jest-environment-emit: ^1.0.8 + json-cycle: ^1.3.0 + lodash: ^4.17.11 + multi-sort-stream: ^1.0.3 + multipipe: ^4.0.0 + node-ipc: 9.2.1 + proper-lockfile: ^3.0.2 + resolve-from: ^5.0.0 + sanitize-filename: ^1.6.1 + semver: ^7.0.0 + serialize-error: ^8.0.1 + shell-quote: ^1.7.2 + signal-exit: ^3.0.3 + stream-json: ^1.7.4 + strip-ansi: ^6.0.1 + telnet-client: 1.2.8 + tempfile: ^2.0.0 + trace-event-lib: ^1.3.1 + which: ^1.3.1 + ws: ^7.0.0 + yargs: ^17.0.0 + yargs-parser: ^21.0.0 + yargs-unparser: ^2.0.0 + peerDependencies: + jest: 29.x.x || 28.x.x || ^27.2.5 + peerDependenciesMeta: + jest: + optional: true + bin: + detox: local-cli/cli.js + checksum: 14a9a230f02c6e7e535e96223a9aacbef05c06c20887eac3d1f1df1aca612a1c529f94e265064df39d1c98ee700fbc2a26ebdd18affea5e60f17a527ae42d6e0 + languageName: node + linkType: hard + "devtools-protocol@npm:0.0.1232444": version: 0.0.1232444 resolution: "devtools-protocol@npm:0.0.1232444" @@ -13516,6 +13704,25 @@ __metadata: languageName: node linkType: hard +"dtrace-provider@npm:~0.8": + version: 0.8.8 + resolution: "dtrace-provider@npm:0.8.8" + dependencies: + nan: ^2.14.0 + node-gyp: latest + checksum: f2dc89df6a9c443dc9bae3b53496e0685b5da89142951d451c1ce062c75d96698ffc0b3d90f621a59a6a18578be552378ad4e08210759038910ff2080be556b9 + languageName: node + linkType: hard + +"duplexer2@npm:^0.1.2": + version: 0.1.4 + resolution: "duplexer2@npm:0.1.4" + dependencies: + readable-stream: ^2.0.2 + checksum: 744961f03c7f54313f90555ac20284a3fb7bf22fdff6538f041a86c22499560eb6eac9d30ab5768054137cb40e6b18b40f621094e0261d7d8c35a37b7a5ad241 + languageName: node + linkType: hard + "duplexer@npm:^0.1.1, duplexer@npm:~0.1.1": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -13530,6 +13737,13 @@ __metadata: languageName: node linkType: hard +"easy-stack@npm:^1.0.1": + version: 1.0.1 + resolution: "easy-stack@npm:1.0.1" + checksum: 161a99e497b3857b0be4ec9e1ebbe90b241ea9d84702f9881b8e5b3f6822065b8c4e33436996935103e191bffba3607de70712a792f4d406a050def48c6bc381 + languageName: node + linkType: hard + "edge-paths@npm:^3.0.5": version: 3.0.5 resolution: "edge-paths@npm:3.0.5" @@ -14537,6 +14751,13 @@ __metadata: languageName: node linkType: hard +"event-pubsub@npm:4.3.0": + version: 4.3.0 + resolution: "event-pubsub@npm:4.3.0" + checksum: 6940f57790c01a967b7c637f1c9fd000ee968a1d5894186ffb3356ffbe174c70e22e62adbbcfcee3f305482d99b6abe7613c1c27c909b07adc9127dc16c8cf73 + languageName: node + linkType: hard + "event-target-shim@npm:^5.0.0, event-target-shim@npm:^5.0.1": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -14614,6 +14835,13 @@ __metadata: languageName: node linkType: hard +"exeunt@npm:1.1.0": + version: 1.1.0 + resolution: "exeunt@npm:1.1.0" + checksum: c0054fa49d7b3abbc2acecd4c6e34c6ce3a0370f9c31d18cdf64dad6be9a6d3fb84d93be892b7d1906f3f23051b3855bde7b255129fc49605a04392f69e98ea2 + languageName: node + linkType: hard + "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -15509,6 +15737,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^11.0.0": + version: 11.3.0 + resolution: "fs-extra@npm:11.3.0" + dependencies: + graceful-fs: ^4.2.0 + jsonfile: ^6.0.1 + universalify: ^2.0.0 + checksum: f983c706e0c22b0c0747a8e9c76aed6f391ba2d76734cf2757cd84da13417b402ed68fe25bace65228856c61d36d3b41da198f1ffbf33d0b34283a2f7a62c6e9 + languageName: node + linkType: hard + "fs-extra@npm:^11.1.0, fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" @@ -15636,6 +15875,13 @@ __metadata: languageName: node linkType: hard +"funpermaproxy@npm:^1.1.0": + version: 1.1.0 + resolution: "funpermaproxy@npm:1.1.0" + checksum: 74cf0aafeadbd79053324f1fb981c1a4358618722ad01c65bd1466b42498fd07acb7749ab9224b25fc8e81c2e1283b92ceee61dded265bd7527b225351db998b + languageName: node + linkType: hard + "gauge@npm:^5.0.0": version: 5.0.2 resolution: "gauge@npm:5.0.2" @@ -16000,7 +16246,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.1.0": +"glob@npm:^8.0.3, glob@npm:^8.1.0": version: 8.1.0 resolution: "glob@npm:8.1.0" dependencies: @@ -17119,6 +17365,13 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^2.1.0": + version: 2.1.0 + resolution: "is-plain-obj@npm:2.1.0" + checksum: cec9100678b0a9fe0248a81743041ed990c2d4c99f893d935545cfbc42876cbe86d207f3b895700c690ad2fa520e568c44afc1605044b535a7820c1d40e38daa + languageName: node + linkType: hard + "is-plain-obj@npm:^4.1.0": version: 4.1.0 resolution: "is-plain-obj@npm:4.1.0" @@ -17616,6 +17869,39 @@ __metadata: languageName: node linkType: hard +"jest-environment-emit@npm:^1.0.8": + version: 1.0.8 + resolution: "jest-environment-emit@npm:1.0.8" + dependencies: + bunyamin: ^1.5.2 + bunyan: ^2.0.5 + bunyan-debug-stream: ^3.1.0 + funpermaproxy: ^1.1.0 + lodash.merge: ^4.6.2 + node-ipc: 9.2.1 + strip-ansi: ^6.0.0 + tslib: ^2.5.3 + peerDependencies: + "@jest/environment": ">=27.2.5" + "@jest/types": ">=27.2.5" + jest: ">=27.2.5" + jest-environment-jsdom: ">=27.2.5" + jest-environment-node: ">=27.2.5" + peerDependenciesMeta: + "@jest/environment": + optional: true + "@jest/types": + optional: true + jest: + optional: true + jest-environment-jsdom: + optional: true + jest-environment-node: + optional: true + checksum: 0c7bafbd3a6e5952f6abb45958f0d2997371d29b29f3876afda48d1d734ccd703577aaac0d5afec2e19dc33a9db0e9458721fe73dbe797f0ced21481d908acfd + languageName: node + linkType: hard + "jest-environment-jsdom@npm:^29.2.1, jest-environment-jsdom@npm:^29.6.2": version: 29.7.0 resolution: "jest-environment-jsdom@npm:29.7.0" @@ -18118,6 +18404,22 @@ __metadata: languageName: node linkType: hard +"js-message@npm:1.0.7": + version: 1.0.7 + resolution: "js-message@npm:1.0.7" + checksum: 18dcc4d80356e8b5be978ca7838d96d4e350a1cb8adc5741c229dec6df09f53bfed7c75c1f360171d2d791a14e2f077d6c2b1013ba899ded7a27d7dfcd4f3784 + languageName: node + linkType: hard + +"js-queue@npm:2.0.2": + version: 2.0.2 + resolution: "js-queue@npm:2.0.2" + dependencies: + easy-stack: ^1.0.1 + checksum: 5049c3f648315ed13e46755704ff5453df70f7e8e1812acf1f98d6700efbec32421f76294a0e63fd2a9f8aabaf124233bbb308f9a2caec9d9f3d833ab5a73079 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -18343,6 +18645,13 @@ __metadata: languageName: node linkType: hard +"json-cycle@npm:^1.3.0": + version: 1.5.0 + resolution: "json-cycle@npm:1.5.0" + checksum: 0a44cd349676c6726093c64283fb75402f9104b32325b06c9270af6d639e7caac419f5301a39298aef2ac1659b273b167e02bd622e628c3392cf86f0e77a9f78 + languageName: node + linkType: hard + "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -19175,6 +19484,16 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^4.0.1": + version: 4.1.5 + resolution: "lru-cache@npm:4.1.5" + dependencies: + pseudomap: ^1.0.2 + yallist: ^2.1.2 + checksum: 4bb4b58a36cd7dc4dcec74cbe6a8f766a38b7426f1ff59d4cf7d82a2aa9b9565cd1cb98f6ff60ce5cd174524868d7bc9b7b1c294371851356066ca9ac4cf135a + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -20628,7 +20947,7 @@ __metadata: languageName: node linkType: hard -"moment@npm:2.30.1, moment@npm:^2.24.0, moment@npm:^2.29.4": +"moment@npm:2.30.1, moment@npm:^2.19.3, moment@npm:^2.24.0, moment@npm:^2.29.4": version: 2.30.1 resolution: "moment@npm:2.30.1" checksum: 859236bab1e88c3e5802afcf797fc801acdbd0ee509d34ea3df6eea21eb6bcc2abd4ae4e4e64aa7c986aa6cba563c6e62806218e6412a765010712e5fa121ba6 @@ -20683,6 +21002,13 @@ __metadata: languageName: node linkType: hard +"multi-sort-stream@npm:^1.0.3, multi-sort-stream@npm:^1.0.4": + version: 1.0.4 + resolution: "multi-sort-stream@npm:1.0.4" + checksum: b234754e0e7489623f5184ba0e887ffd8014fe829c846fd8a95569339b6e19a616ae1d44f3d064279adfbf92fa5c4d016a89fc5026e16dbd680ebd67067b19a0 + languageName: node + linkType: hard + "multimatch@npm:5.0.0": version: 5.0.0 resolution: "multimatch@npm:5.0.0" @@ -20696,6 +21022,16 @@ __metadata: languageName: node linkType: hard +"multipipe@npm:^4.0.0": + version: 4.0.0 + resolution: "multipipe@npm:4.0.0" + dependencies: + duplexer2: ^0.1.2 + object-assign: ^4.1.0 + checksum: 5a494ec2ce5bfdb389882ca595e3c4a33cae6c90dad879db2e3aa9a94484d8b164b0fb7b58ccf7593ae7e8c6213fd3f53a736b2c98e4f14c5ed1d38debc33f98 + languageName: node + linkType: hard + "mute-stream@npm:0.0.7": version: 0.0.7 resolution: "mute-stream@npm:0.0.7" @@ -20717,7 +21053,7 @@ __metadata: languageName: node linkType: hard -"mv@npm:2.1.1": +"mv@npm:2.1.1, mv@npm:~2": version: 2.1.1 resolution: "mv@npm:2.1.1" dependencies: @@ -20739,6 +21075,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.14.0": + version: 2.22.0 + resolution: "nan@npm:2.22.0" + dependencies: + node-gyp: latest + checksum: 222e3a090e326c72f6782d948f44ee9b81cfb2161d5fe53216f04426a273fd094deee9dcc6813096dd2397689a2b10c1a92d3885d2e73fd2488a51547beb2929 + languageName: node + linkType: hard + "nanoid@npm:3.3.7, nanoid@npm:^3.1.23, nanoid@npm:^3.3.7": version: 3.3.7 resolution: "nanoid@npm:3.3.7" @@ -20925,6 +21270,17 @@ __metadata: languageName: node linkType: hard +"node-ipc@npm:9.2.1": + version: 9.2.1 + resolution: "node-ipc@npm:9.2.1" + dependencies: + event-pubsub: 4.3.0 + js-message: 1.0.7 + js-queue: 2.0.2 + checksum: a38aa4c8ca4317b293e0ce21f0a3a4941fc51c054800b35e263fcfe3e0feeb60e7d2c497f015054b28783316c6e7d9cc3837af9d9958bcbd8c577d0cdf6964b7 + languageName: node + linkType: hard + "node-java-connector@npm:1.1.1": version: 1.1.1 resolution: "node-java-connector@npm:1.1.1" @@ -20995,6 +21351,13 @@ __metadata: languageName: node linkType: hard +"node-version@npm:^1.0.0": + version: 1.2.0 + resolution: "node-version@npm:1.2.0" + checksum: 74e92d2a7f0fe0fce3aafd6dcc30b3b440999df68b3d92fcefcad2a52b37bc29c6b542f33760229390bfdc1a4d993fb65b9c199b1f0d568969d07fc1c04bc1e7 + languageName: node + linkType: hard + "nopt@npm:^7.0.0, nopt@npm:^7.2.0, nopt@npm:^7.2.1": version: 7.2.1 resolution: "nopt@npm:7.2.1" @@ -22454,6 +22817,13 @@ __metadata: languageName: node linkType: hard +"promise-polyfill@npm:^6.0.1": + version: 6.1.0 + resolution: "promise-polyfill@npm:6.1.0" + checksum: 6f1899cca37e48f67a424842282acd525d8d99d3536f2d97e37a117cfc4a0006683330ceaf5a15fbc09b4450f319a680292f9970a5f8e9cf90acbce0bdb0f751 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -22512,6 +22882,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^3.0.2": + version: 3.2.0 + resolution: "proper-lockfile@npm:3.2.0" + dependencies: + graceful-fs: ^4.1.11 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 1be1bb702b9d47bdf18d75f22578f51370781feba7d2617f70ff8c66a86bcfa6e55b4f69c57fc326380110f2d1ffdb6e54a4900814bf156c04ee4eb2d3c065aa + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -22559,6 +22940,13 @@ __metadata: languageName: node linkType: hard +"pseudomap@npm:^1.0.2": + version: 1.0.2 + resolution: "pseudomap@npm:1.0.2" + checksum: 856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5 + languageName: node + linkType: hard + "psl@npm:^1.1.33": version: 1.9.0 resolution: "psl@npm:1.9.0" @@ -22852,6 +23240,13 @@ __metadata: languageName: node linkType: hard +"react-native-build-config@npm:^0.3.2": + version: 0.3.2 + resolution: "react-native-build-config@npm:0.3.2" + checksum: d2095580be7e6662c968bce5d64cc6524b7f34380999756fb9d4ad24a34e3566b203f93e9bc63bd1e0213d28e1e76dae0c0f39b0b65c55528a876e96dd6c5810 + languageName: node + linkType: hard + "react-native-codegen@npm:^0.70.7": version: 0.70.7 resolution: "react-native-codegen@npm:0.70.7" @@ -22959,6 +23354,16 @@ __metadata: languageName: node linkType: hard +"react-native-launch-arguments@npm:^4.0.4": + version: 4.0.4 + resolution: "react-native-launch-arguments@npm:4.0.4" + peerDependencies: + react: ">=16.8.1" + react-native: ">=0.60.0-rc.0 <1.0.x" + checksum: 7346af606cedc35c58bdccabd88a8ef9b2b55138accf490fe8291c6d7110679f9125af072eaaf896909554cd54be20e863c987d1ce91c39a2f401a999c7fde9f + languageName: node + linkType: hard + "react-native-macos@npm:0.73.34": version: 0.73.34 resolution: "react-native-macos@npm:0.73.34" @@ -23637,7 +24042,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -24329,6 +24734,13 @@ __metadata: languageName: node linkType: hard +"safe-json-stringify@npm:~1": + version: 1.2.0 + resolution: "safe-json-stringify@npm:1.2.0" + checksum: 5bb32db6d6a3ceb3752df51f4043a412419cd3d4fcd5680a865dfa34cd7e575ba659c077d13f52981ced084061df9c75c7fb12e391584d4264e6914c1cd3d216 + languageName: node + linkType: hard + "safe-regex-test@npm:^1.0.3": version: 1.0.3 resolution: "safe-regex-test@npm:1.0.3" @@ -24354,7 +24766,7 @@ __metadata: languageName: node linkType: hard -"sanitize-filename@npm:1.6.3": +"sanitize-filename@npm:1.6.3, sanitize-filename@npm:^1.6.1": version: 1.6.3 resolution: "sanitize-filename@npm:1.6.3" dependencies: @@ -24687,6 +25099,7 @@ __metadata: "@react-navigation/native-stack": ^7.2.0 "@react-navigation/stack": ^7.1.1 "@sentry/babel-plugin-component-annotate": 3.2.0 + "@sentry/core": 8.54.0 "@sentry/react-native": 6.8.0 "@types/react": ^18.2.65 "@types/react-native-vector-icons": ^6.4.18 @@ -24696,6 +25109,7 @@ __metadata: babel-jest: ^29.6.3 babel-plugin-module-resolver: ^5.0.0 delay: ^6.0.0 + detox: ^20.33.0 eslint: ^8.19.0 eslint-plugin-ft-flow: ^3.0.11 jest: ^29.6.3 @@ -24703,8 +25117,10 @@ __metadata: prettier: 2.8.8 react: 18.3.1 react-native: 0.77.1 + react-native-build-config: ^0.3.2 react-native-gesture-handler: ^2.22.1 react-native-image-picker: ^8.0.0 + react-native-launch-arguments: ^4.0.4 react-native-reanimated: 3.16.7 react-native-safe-area-context: 5.2.0 react-native-screens: 4.6.0 @@ -24715,6 +25131,7 @@ __metadata: react-test-renderer: 18.3.1 redux: ^4.2.1 sentry-react-native-samples-utils: "workspace:^" + ts-jest: ^29.2.5 typescript: 5.0.4 languageName: unknown linkType: soft @@ -24768,6 +25185,15 @@ __metadata: languageName: node linkType: hard +"serialize-error@npm:^8.0.1": + version: 8.1.0 + resolution: "serialize-error@npm:8.1.0" + dependencies: + type-fest: ^0.20.2 + checksum: 2eef236d50edd2d7926e602c14fb500dc3a125ee52e9f08f67033181b8e0be5d1122498bdf7c23c80683cddcad083a27974e9e7111ce23165f4d3bcdd6d65102 + languageName: node + linkType: hard + "serve-favicon@npm:2.5.0": version: 2.5.0 resolution: "serve-favicon@npm:2.5.0" @@ -24990,6 +25416,13 @@ __metadata: languageName: node linkType: hard +"shell-quote@npm:^1.7.2": + version: 1.8.2 + resolution: "shell-quote@npm:1.8.2" + checksum: 1e97b62ced1c4c5135015978ebf273bed1f425a68cf84163e83fbb0f34b3ff9471e656720dab2b7cbb4ae0f58998e686d17d166c28dfb3662acd009e8bd7faed + languageName: node + linkType: hard + "shelljs@npm:^0.8.3": version: 0.8.5 resolution: "shelljs@npm:0.8.5" @@ -25418,6 +25851,13 @@ __metadata: languageName: node linkType: hard +"stream-chain@npm:^2.2.5": + version: 2.2.5 + resolution: "stream-chain@npm:2.2.5" + checksum: c83cbf504bd11e2bcbe761a92801295b3decac7ffa4092ceffca2eb1b5d0763bcc511fa22cd8044e8a18c21ca66794fd10c8d9cd1292a3e6c0d83a4194c6b8ed + languageName: node + linkType: hard + "stream-combiner@npm:^0.2.2": version: 0.2.2 resolution: "stream-combiner@npm:0.2.2" @@ -25428,6 +25868,15 @@ __metadata: languageName: node linkType: hard +"stream-json@npm:^1.7.4, stream-json@npm:^1.7.5": + version: 1.9.1 + resolution: "stream-json@npm:1.9.1" + dependencies: + stream-chain: ^2.2.5 + checksum: 2ebf0648f9ed82ee79727a9a47805231a70d5032e0c21cee3e05cd3c449d3ce49c72b371555447eeef55904bae22ac64be8ae6086fc6cce0b83b3aa617736b64 + languageName: node + linkType: hard + "stream-slice@npm:^0.1.2": version: 0.1.2 resolution: "stream-slice@npm:0.1.2" @@ -26005,7 +26454,16 @@ __metadata: languageName: node linkType: hard -"temp-dir@npm:1.0.0": +"telnet-client@npm:1.2.8": + version: 1.2.8 + resolution: "telnet-client@npm:1.2.8" + dependencies: + bluebird: ^3.5.4 + checksum: d2430c5449a46f6f4f9a7c2c648164f014c308aa0d3207a4d6b5b7f0e443322d07b180ecac63ad43eadb6557c8ef5ae7dce1ea6276464c8c82c8c6a9c9c01bf2 + languageName: node + linkType: hard + +"temp-dir@npm:1.0.0, temp-dir@npm:^1.0.0": version: 1.0.0 resolution: "temp-dir@npm:1.0.0" checksum: cb2b58ddfb12efa83e939091386ad73b425c9a8487ea0095fe4653192a40d49184a771a1beba99045fbd011e389fd563122d79f54f82be86a55620667e08a6b2 @@ -26038,6 +26496,16 @@ __metadata: languageName: node linkType: hard +"tempfile@npm:^2.0.0": + version: 2.0.0 + resolution: "tempfile@npm:2.0.0" + dependencies: + temp-dir: ^1.0.0 + uuid: ^3.0.1 + checksum: 8a92a0f57e0ae457dfbc156b14c427b42048a86ca6bade311835cc2aeda61b25b82d688f71f2d663dde6f172f479ed07293b53f7981e41cb6f9120a3eb4fe797 + languageName: node + linkType: hard + "tempy@npm:^0.7.1": version: 0.7.1 resolution: "tempy@npm:0.7.1" @@ -26239,6 +26707,15 @@ __metadata: languageName: node linkType: hard +"trace-event-lib@npm:^1.3.1": + version: 1.4.1 + resolution: "trace-event-lib@npm:1.4.1" + dependencies: + browser-process-hrtime: ^1.0.0 + checksum: f10dbfeccee9ec80a8cf69ecadd49fa609fc2593fb50a83cc4b664524c0531f91009134bf54302f9c4911afed119b0eebb8d2724723fc44516e24a40aaae9219 + languageName: node + linkType: hard + "treeverse@npm:^3.0.0": version: 3.0.0 resolution: "treeverse@npm:3.0.0" @@ -26292,7 +26769,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.1.1": +"ts-jest@npm:^29.1.1, ts-jest@npm:^29.2.5": version: 29.2.5 resolution: "ts-jest@npm:29.2.5" dependencies: @@ -26420,6 +26897,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.5.3": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: e4aba30e632b8c8902b47587fd13345e2827fa639e7c3121074d5ee0880723282411a8838f830b55100cbe4517672f84a2472667d355b81e8af165a55dc6203a + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -27071,6 +27555,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^3.0.1": + version: 3.4.0 + resolution: "uuid@npm:3.4.0" + bin: + uuid: ./bin/uuid + checksum: 58de2feed61c59060b40f8203c0e4ed7fd6f99d42534a499f1741218a1dd0c129f4aa1de797bcf822c8ea5da7e4137aa3673431a96dae729047f7aca7b27866f + languageName: node + linkType: hard + "uuid@npm:^7.0.3": version: 7.0.3 resolution: "uuid@npm:7.0.3" @@ -27443,7 +27936,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^1.2.9": +"which@npm:^1.2.9, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -27661,7 +28154,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.5.1, ws@npm:^7.5.10": +"ws@npm:^7, ws@npm:^7.0.0, ws@npm:^7.5.1, ws@npm:^7.5.10": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: @@ -27792,6 +28285,13 @@ __metadata: languageName: node linkType: hard +"yallist@npm:^2.1.2": + version: 2.1.2 + resolution: "yallist@npm:2.1.2" + checksum: 9ba99409209f485b6fcb970330908a6d41fa1c933f75e08250316cce19383179a6b70a7e0721b89672ebb6199cc377bf3e432f55100da6a7d6e11902b0a642cb + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -27822,7 +28322,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": +"yargs-parser@npm:21.1.1, yargs-parser@npm:^21.0.0, yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c @@ -27846,7 +28346,19 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": +"yargs-unparser@npm:^2.0.0": + version: 2.0.0 + resolution: "yargs-unparser@npm:2.0.0" + dependencies: + camelcase: ^6.0.0 + decamelize: ^4.0.0 + flat: ^5.0.2 + is-plain-obj: ^2.1.0 + checksum: 68f9a542c6927c3768c2f16c28f71b19008710abd6b8f8efbac6dcce26bbb68ab6503bed1d5994bdbc2df9a5c87c161110c1dfe04c6a3fe5c6ad1b0e15d9a8a3 + languageName: node + linkType: hard + +"yargs@npm:17.7.2, yargs@npm:^17.0.0, yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: