diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml index d214037c95..adca5e3167 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -1,87 +1,89 @@ -name: "🐞 Bug Report" +name: '🐞 Bug Report' description: "Tell us about something that's not working the way we (probably) intend." -labels: ["Platform: React-Native", "Type: đŸĒ˛ Bug"] +labels: ['Platform: React-Native', 'Type: đŸĒ˛ Bug'] +type: Bug body: - type: dropdown id: environment validations: required: true attributes: - label: "What React Native libraries do you use?" - description: "Select all options that describe your application." + label: 'What React Native libraries do you use?' + description: 'Select all options that describe your application.' multiple: true options: - - "React Native without Frameworks" - - "React Navigation" - - "Hermes" - - "RN New Architecture" - - "Expo Application Services (EAS)" - - "Expo (mobile only)" - - "Expo Web" - - "Expo Router" - - "React Native Web" - - "React Native Navigation by Wix" + - 'React Native without Frameworks' + - 'React Navigation' + - 'Hermes' + - 'RN New Architecture' + - 'Expo Application Services (EAS)' + - 'Expo (mobile only)' + - 'Expo Web' + - 'Expo Router' + - 'React Native Web' + - 'React Native Navigation by Wix' - type: dropdown id: sentry validations: required: true attributes: - label: "Are you using sentry.io or on-premise?" - description: "Select exactly one option." + label: 'Are you using sentry.io or on-premise?' + description: 'Select exactly one option.' options: - - "sentry.io (SaS)" - - "on-premise (Self-Hosted)" + - 'sentry.io (SaS)' + - 'on-premise (Self-Hosted)' - type: input id: version validations: required: true attributes: - label: "@sentry/react-native SDK Version" - description: "If the issue started after the SDK upgrade, please input both old and new versions." - placeholder: "5.33.1 ← should look like this" + label: '@sentry/react-native SDK Version' + description: 'If the issue started after the SDK upgrade, please input both old and new versions.' + placeholder: '5.33.1 ← should look like this' - type: textarea id: doctor validations: required: true attributes: - label: "How does your development environment look like?" - description: "Output of the command `npx react-native@latest info` or manully describe your development environment?" - placeholder: |- - info Fetching system and libraries information... - OS: OS version - Node: Your version - Yarn: Yarn version - Expo SDK: Expo SDK version - react: React version - react-native: React Native version - hermesEnabled: bool - newArchEnabled: bool + label: 'How does your development environment look like?' + description: + 'Output of the command `npx react-native@latest info` or manully describe your development environment?' + value: |- + ```` + âŦ‡ Place the `npx react-native@latest info` output here. âŦ‡ + + + + + ```` - type: textarea id: init validations: required: true attributes: - label: "Sentry.init()" - description: "Code snipped of Sentry initialization from your application." - placeholder: |- + label: 'Sentry.init()' + description: 'Code snipped of Sentry initialization from your application.' + value: |- + ````js Sentry.init({ dsn: 'https://...@sentry.io/...' // other options }); + ```` - type: textarea id: repro validations: required: true attributes: - label: "Steps to Reproduce" + label: 'Steps to Reproduce' description: "How can we see what you're seeing? Specific is terrific." placeholder: |- - 1. Build Android using `npx react-native run-android --mode Debug` + 1. Build Android using `npx react-native run-android --mode Debug` 2. Start Metro Dev server using `npx react-native start` 3. Click on button executing `Sentry.capture(new Error("This is not captured :("))` @@ -90,15 +92,15 @@ body: validations: required: true attributes: - label: "Expected Result" + label: 'Expected Result' - type: textarea id: actual validations: required: true attributes: - label: "Actual Result" - description: "JS Console? iOS Console? Logcat? Screenshots? Yes, please." + label: 'Actual Result' + description: 'JS Console? iOS Console? Logcat? Screenshots? Yes, please.' - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md deleted file mode 100644 index 21a6560ed5..0000000000 --- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Request a new feature -about: New feature for the @sentry/react-native package. -labels: ["Platform: React-Native", "enhancement"] ---- - - diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml new file mode 100644 index 0000000000..84d067a56c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml @@ -0,0 +1,37 @@ +name: 💡 Feature Request +description: Tell us about a problem our SDK could solve but doesn't. +labels: ['Platform: React-Native', 'enhancement'] +type: Feature +body: + - type: textarea + id: problem + attributes: + label: Problem Statement + description: What problem could Sentry solve that it doesn't? + placeholder: |- + I want to make whirled peas, but Sentry doesn't blend. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Solution Brainstorm + description: We know you have bright ideas to share ... share away, friend. + placeholder: |- + Add a blender to Sentry. + + - type: dropdown + id: submit-a-pr + attributes: + label: Are you willing to submit a PR? + description: We accept contributions! + options: + - 'Yes' + - 'No' + + - type: markdown + attributes: + value: |- + ## Thanks 🙏 + Check our [triage docs](https://open.sentry.io/triage/) for what to expect next. diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml index 182341bc60..7da3d56afc 100644 --- a/.github/workflows/buildandtest.yml +++ b/.github/workflows/buildandtest.yml @@ -23,7 +23,7 @@ jobs: if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -41,7 +41,7 @@ jobs: if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -59,7 +59,7 @@ jobs: if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -82,7 +82,7 @@ jobs: if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -125,7 +125,7 @@ jobs: YARN_ENABLE_IMMUTABLE_INSTALLS: false steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -155,7 +155,7 @@ jobs: if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -189,7 +189,7 @@ jobs: dev: [true, false] steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml index f642eb97ba..9520480de2 100644 --- a/.github/workflows/codegen.yml +++ b/.github/workflows/codegen.yml @@ -37,7 +37,7 @@ jobs: --targetPlatform ios steps: - uses: actions/checkout@v4 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2158bf0e6e..0f0faeaf15 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,7 +44,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v3.28.9 + uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # pin@v3.28.19 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -55,7 +55,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v3.28.9 + uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # pin@v3.28.19 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions @@ -66,4 +66,4 @@ jobs: # make bootstrap # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # pin@v3.28.9 + uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # pin@v3.28.19 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e-v2.yml similarity index 94% rename from .github/workflows/e2e.yml rename to .github/workflows/e2e-v2.yml index 4fe74bd43d..e7a4738921 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e-v2.yml @@ -1,4 +1,4 @@ -name: End-to-End Tests +name: End-to-End Tests V2 on: push: @@ -14,7 +14,7 @@ concurrency: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - MAESTRO_VERSION: '1.39.0' + MAESTRO_VERSION: '1.40.3' IOS_DEVICE: 'iPhone 16' IOS_VERSION: '18.1' @@ -56,7 +56,7 @@ jobs: - run: sudo xcode-select -s /Applications/Xcode_14.2.app/Contents/Developer if: ${{ matrix.platform == 'ios' }} - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -151,18 +151,19 @@ jobs: react-native-build: name: Build RN ${{ matrix.rn-version }} ${{ matrix.rn-architecture }} ${{ matrix.engine }} ${{ matrix.platform }} ${{ matrix.build-type }} ${{ matrix.ios-use-frameworks }} runs-on: ${{ matrix.runs-on }} - needs: [diff_check] - if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} + needs: [diff_check, auth_token_check] + if: ${{ needs.diff_check.outputs.skip_ci != 'true' && needs.auth_token_check.outputs.skip_ci != 'true' && !startsWith(github.ref, 'refs/heads/release/') }} env: RN_VERSION: ${{ matrix.rn-version }} RN_ENGINE: ${{ matrix.engine }} USE_FRAMEWORKS: ${{ matrix.ios-use-frameworks }} PRODUCTION: ${{ matrix.build-type == 'production' && '1' || '0' }} RCT_NEW_ARCH_ENABLED: ${{ matrix.rn-architecture == 'new' && '1' || '0' }} + SENTRY_DISABLE_AUTO_UPLOAD: 'false' strategy: fail-fast: false # keeps matrix running if one fails matrix: - rn-version: ['0.65.3', '0.77.1'] + rn-version: ['0.65.3', '0.79.1'] rn-architecture: ['legacy', 'new'] platform: ['android', 'ios'] build-type: ['production'] @@ -170,16 +171,18 @@ jobs: engine: ['hermes', 'jsc'] include: - platform: ios - rn-version: '0.77.1' - runs-on: macos-14 + rn-version: '0.79.1' + xcode-version: '16.2' + runs-on: macos-15 - platform: ios rn-version: '0.65.3' + xcode-version: '14.2' runs-on: macos-13 - platform: android runs-on: ubuntu-latest exclude: # exclude JSC for new RN versions (keeping the matrix manageable) - - rn-version: '0.77.1' + - rn-version: '0.79.1' engine: 'jsc' # exclude all rn versions lower than 0.70.0 for new architecture - rn-version: '0.65.3' @@ -221,10 +224,10 @@ jobs: echo "SENTRY_RELEASE=$SENTRY_RELEASE" echo "SENTRY_DIST=$SENTRY_DIST" - - run: sudo xcode-select -s /Applications/Xcode_14.2.app/Contents/Developer - if: ${{ matrix.platform == 'ios' && matrix.rn-version == '0.65.3' }} + - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer + if: ${{ matrix.platform == 'ios' }} - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -298,7 +301,7 @@ jobs: strategy: fail-fast: false # keeps matrix running if one fails matrix: - rn-version: ['0.65.3', '0.77.1'] + rn-version: ['0.65.3', '0.79.1'] rn-architecture: ['legacy', 'new'] platform: ['android', 'ios'] build-type: ['production'] @@ -306,7 +309,7 @@ jobs: engine: ['hermes', 'jsc'] include: - platform: ios - rn-version: '0.77.1' + rn-version: '0.79.1' runs-on: macos-15 - platform: ios rn-version: '0.65.3' @@ -320,7 +323,7 @@ jobs: # e2e test only the default combinations - rn-version: '0.65.3' engine: 'hermes' - - rn-version: '0.77.1' + - rn-version: '0.79.1' engine: 'jsc' steps: @@ -350,9 +353,7 @@ jobs: path: dev-packages/e2e-tests - name: Enable Corepack - run: | - npm install -g corepack@0.29.4 - corepack enable + run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 20 @@ -384,7 +385,7 @@ jobs: - name: Run tests on Android if: ${{ matrix.platform == 'android' }} - uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 + uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2.34.0 with: api-level: 30 force-avd-creation: false diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 8382f3d3c1..afaa4909ff 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -25,9 +25,7 @@ jobs: - uses: actions/checkout@v4 - name: Enable Corepack - run: | - npm install -g corepack@0.29.4 - corepack enable + run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -88,7 +86,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: Run connected tests - uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d #pin@v2.33.0 + uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed #pin@v2.34.0 with: working-directory: packages/core/RNSentryAndroidTester api-level: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 525b4afe0c..9f785ded04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # v1.11.5 + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} @@ -28,7 +28,7 @@ jobs: with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/sample-application-expo.yml b/.github/workflows/sample-application-expo.yml index 024d54d389..5830f3e5fd 100644 --- a/.github/workflows/sample-application-expo.yml +++ b/.github/workflows/sample-application-expo.yml @@ -47,9 +47,7 @@ jobs: - uses: actions/checkout@v4 - name: Enable Corepack - run: | - npm install -g corepack@0.29.4 - corepack enable + run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/sample-application.yml b/.github/workflows/sample-application.yml index 899c341dbf..3520e11e92 100644 --- a/.github/workflows/sample-application.yml +++ b/.github/workflows/sample-application.yml @@ -13,6 +13,7 @@ concurrency: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + MAESTRO_VERSION: '1.40.3' 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 @@ -60,9 +61,7 @@ jobs: - uses: actions/checkout@v4 - name: Enable Corepack - run: | - npm install -g corepack@0.29.4 - corepack enable + run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -185,8 +184,8 @@ jobs: name: build-sample-${{ matrix.rn-architecture }}-${{ matrix.platform }}-${{ matrix.build-type }}-${{ matrix.ios-use-frameworks}}-logs path: ${{ env.REACT_NATIVE_SAMPLE_PATH }}/${{ matrix.platform }}/*.log - test-detox: - name: ${{ matrix.job-name }} + test: + name: Test ${{ matrix.platform }} ${{ matrix.build-type }} ${{ matrix.init-type }} REV2 runs-on: ${{ matrix.runs-on }} needs: [diff_check, build] if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} @@ -195,32 +194,34 @@ jobs: fail-fast: false matrix: include: - - job-name: 'Test iOS Release Auto Init' - platform: ios + - 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 + init-type: 'auto' - - job-name: 'Test iOS Release Manual Init' - platform: ios + - platform: ios runs-on: macos-15 rn-architecture: 'new' ios-use-frameworks: 'no-frameworks' build-type: 'production' - test-command: 'yarn test-ios-manual' + init-type: 'manual' - - job-name: 'Test Android Release Manual Init' - platform: android + - platform: android runs-on: ubuntu-latest rn-architecture: 'new' build-type: 'production' - test-command: 'yarn test-android' + init-type: 'manual' steps: - uses: actions/checkout@v4 + - name: Install Maestro + uses: dniHze/maestro-test-action@bda8a93211c86d0a05b7a4597c5ad134566fbde4 # pin@v1.0.0 + with: + version: ${{env.MAESTRO_VERSION}} + - name: Download iOS App Archive if: ${{ matrix.platform == 'ios' }} uses: actions/download-artifact@v4 @@ -243,12 +244,12 @@ jobs: - name: Unzip Android APK if: ${{ matrix.platform == 'android' }} working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - run: unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }} + run: | + unzip ${{ env.ANDROID_APP_ARCHIVE_PATH }} + rm app-androidTest.apk - name: Enable Corepack - run: | - npm install -g corepack@0.29.4 - corepack enable + run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 @@ -258,15 +259,6 @@ jobs: - 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 @@ -279,25 +271,21 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - uses: futureware-tech/simulator-action@dab10d813144ef59b48d401cd95da151222ef8cd # pin@v4 + - name: Boot ${{ env.IOS_DEVICE }} with iOS ${{ env.IOS_VERSION }} + 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 + - name: Run iOS Tests if: ${{ matrix.platform == 'ios' }} working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - run: ${{ matrix.test-command }} + run: yarn test-ios-${{ matrix.init-type }} - - name: Run tests on Android + - name: Run Android Tests on API ${{ env.ANDROID_API_LEVEL }} 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 + uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # pin@v2.34.0 with: api-level: ${{ env.ANDROID_API_LEVEL }} force-avd-creation: false @@ -315,4 +303,4 @@ jobs: -camera-front none -timezone US/Pacific working-directory: ${{ env.REACT_NATIVE_SAMPLE_PATH }} - script: ${{ matrix.test-command }} + script: yarn test-android-${{ matrix.init-type }} diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 3044c95448..06bba8da11 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -14,19 +14,19 @@ jobs: upload_to_testflight: name: Build and Upload React Native Sample to Testflight - runs-on: macos-14 + runs-on: macos-15 needs: [diff_check] if: ${{ needs.diff_check.outputs.skip_ci != 'true' }} steps: - uses: actions/checkout@v4 - - run: sudo xcode-select -s /Applications/Xcode_15.3.app/Contents/Developer + - run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - uses: ruby/setup-ruby@v1 with: working-directory: samples/react-native 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 - - run: corepack enable + - run: npm i -g corepack - uses: actions/setup-node@v4 with: node-version: 18 diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index c34d114f7f..8bdad428d5 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -75,3 +75,14 @@ jobs: changelog-entry: false secrets: api-token: ${{ secrets.CI_DEPLOY_KEY }} + + maestro: + uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 + with: + path: scripts/update-maestro.sh + name: Maestro + pattern: '^v[0-9.]+$' # only match non-preview versions + pr-strategy: update + changelog-entry: false + secrets: + api-token: ${{ secrets.CI_DEPLOY_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ef04ec37c7..5e78227557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,281 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. -## Unreleased +## 6.15.1 + +### Dependencies + +- Bump Cocoa SDK from v8.52.0 to v8.52.1 ([#4899](https://github.com/getsentry/sentry-react-native/pull/4899)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8521) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.52.0...8.52.1) + +## 6.15.0 + +### Features + +- User Feedback Widget Updates + - `FeedbackButton` for easy access to the widget ([#4378](https://github.com/getsentry/sentry-react-native/pull/4378)) + - `ScreenshotButton` for capturing the application visuals ([#4714](https://github.com/getsentry/sentry-react-native/issues/4714)) + - Theming support to better align with the application styles ([#4677](https://github.com/getsentry/sentry-react-native/pull/4677)) + + ```js + Sentry.init({ + integrations: [ + Sentry.feedbackIntegration({ + enableTakeScreenshot: true, // Enables `ScreenshotButton` + themeDark: { + // Add dark theme styles here + }, + themeLight: { + // Add light theme styles here + }, + }), + ], + }); + + Sentry.showFeedbackButton(); + Sentry.hideFeedbackButton(); + ``` + + To learn more visit [the documentation](https://docs.sentry.io/platforms/react-native/user-feedback). + +- Re-export `ErrorEvent` and `TransactionEvent` types ([#4859](https://github.com/getsentry/sentry-react-native/pull/4859)) + +### Fixes + +- crashedLastRun now returns the correct value ([#4829](https://github.com/getsentry/sentry-react-native/pull/4829)) +- Use engine-specific promise rejection tracking ([#4826](https://github.com/getsentry/sentry-react-native/pull/4826)) +- Fixes Feedback Widget accessibility issue on iOS ([#4739](https://github.com/getsentry/sentry-react-native/pull/4739)) +- Measuring TTID or TTFD could cause a crash when `parentSpanId` was removed ([#4881](https://github.com/getsentry/sentry-react-native/pull/4881)) + +### Dependencies + +- Bump Bundler Plugins from v3.4.0 to v3.5.0 ([#4850](https://github.com/getsentry/sentry-react-native/pull/4850)) + - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#350) + - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.4.0...3.5.0) +- Bump Cocoa SDK from v8.50.2 to v8.52.0 ([#4839](https://github.com/getsentry/sentry-react-native/pull/4839), [#4887](https://github.com/getsentry/sentry-react-native/pull/4887)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8520) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.50.2...8.52.0) +- Bump CLI from v2.45.0 to v2.46.0 ([#4866](https://github.com/getsentry/sentry-react-native/pull/4866)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2460) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.45.0...2.46.0) + +## 6.14.0 + +### Fixes + +- Expo Updates Context is passed to native after native init to be available for crashes ([#4808](https://github.com/getsentry/sentry-react-native/pull/4808)) +- Expo Updates Context values should all be lowercase ([#4809](https://github.com/getsentry/sentry-react-native/pull/4809)) +- Avoid duplicate network requests (fetch, xhr) by default ([#4816](https://github.com/getsentry/sentry-react-native/pull/4816)) + - `traceFetch` is disabled by default on mobile as RN uses a polyfill which will be traced by `traceXHR` + +### Changes + +- Renames `enableExperimentalViewRenderer` to `enableViewRendererV2` which is enabled by default for up to 5x times more performance in Session Replay on iOS ([#4815](https://github.com/getsentry/sentry-react-native/pull/4815)) + +### Dependencies + +- Bump CLI from v2.43.1 to v2.45.0 ([#4804](https://github.com/getsentry/sentry-react-native/pull/4804), [#4818](https://github.com/getsentry/sentry-react-native/pull/4818)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2450) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.43.1...2.45.0) +- Bump Bundler Plugins from v3.3.1 to v3.4.0 ([#4805](https://github.com/getsentry/sentry-react-native/pull/4805)) + - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#340) + - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.3.1...3.4.0) +- Bump Cocoa SDK from v8.49.2 to v8.50.2 ([#4807](https://github.com/getsentry/sentry-react-native/pull/4807), [#4821](https://github.com/getsentry/sentry-react-native/pull/4821), [#4830](https://github.com/getsentry/sentry-react-native/pull/4830)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8502) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.2...8.50.2) + +## 6.13.1 + +### Fixes + +- Disable native driver for Feedback Widget `backgroundColor` animation in unsupported React Native versions ([#4794](https://github.com/getsentry/sentry-react-native/pull/4794)) +- Fix Debug Symbolicator for local development builds (use RN 0.79 default exports) ([#4801](https://github.com/getsentry/sentry-react-native/pull/4801)) + +## 6.13.0 + +### Changes + +- Fallback to Current Activity Holder when React Context Activity is not present ([#4779](https://github.com/getsentry/sentry-react-native/pull/4779)) +- Support `REACT_NATIVE_PATH` env in Xcode Debug Files upload scripts ([#4789](https://github.com/getsentry/sentry-react-native/pull/4789)) + +### Fixes + +- Initialize Sentry Android with ApplicationContext if available ([#4780](https://github.com/getsentry/sentry-react-native/pull/4780)) + +### Dependencies + +- Bump Cocoa SDK from v8.49.1 to v8.49.2 ([#4792](https://github.com/getsentry/sentry-react-native/pull/4792)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8492) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.1...8.49.2) + +## 6.12.0 + +### Features + +- Add Expo Updates Event Context ([#4767](https://github.com/getsentry/sentry-react-native/pull/4767), [#4786](https://github.com/getsentry/sentry-react-native/pull/4786)) + - Automatically collects `updateId`, `channel`, Emergency Launch Reason and other Expo Updates constants + +### Fixes + +- Export `extraErrorDataIntegration` from `@sentry/core` ([#4762](https://github.com/getsentry/sentry-react-native/pull/4762)) +- Remove `@sentry-internal/replay` when `includeWebReplay: false` ([#4774](https://github.com/getsentry/sentry-react-native/pull/4774)) + +### Dependencies + +- Bump Cocoa SDK from v8.49.0 to v8.49.1 ([#4771](https://github.com/getsentry/sentry-react-native/pull/4771)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8491) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.49.0...8.49.1) +- Bump CLI from v2.43.0 to v2.43.1 ([#4787](https://github.com/getsentry/sentry-react-native/pull/4787)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2431) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.43.0...2.43.1) + +## 6.11.0 + +### Features + +- Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641), [#4695](https://github.com/getsentry/sentry-react-native/pull/4695)) +- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665)) +- Add support for measuring Time to Initial Display for already seen routes ([#4661](https://github.com/getsentry/sentry-react-native/pull/4661)) + - Introduce `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration. + + ```js + Sentry.reactNavigationIntegration({ + enableTimeToInitialDisplayForPreloadedRoutes: true, + }); + ``` + +- Add `useDispatchedActionData` option to the React Navigation integration to filter out navigation actions that should not create spans ([#4684](https://github.com/getsentry/sentry-react-native/pull/4684)) + - For example `PRELOAD`, `SET_PARAMS`, `TOGGLE_DRAWER` and others. + + ```js + Sentry.reactNavigationIntegration({ + useDispatchedActionData: true, + }); + ``` + +### Fixes + +- Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4680](https://github.com/getsentry/sentry-react-native/pull/4680)) +- Avoid loading Sentry native components in Expo Go ([#4696](https://github.com/getsentry/sentry-react-native/pull/4696)) +- Avoid silent failure when JS bundle was not created due to Sentry Xcode scripts failure ([#4690](https://github.com/getsentry/sentry-react-native/pull/4690)) +- Prevent crash on iOS during profiling stop when debug images are missing ([#4738](https://github.com/getsentry/sentry-react-native/pull/4738)) +- Attach only App Starts within the 60s threshold (fixed comparison units, use ms) ([#4746](https://github.com/getsentry/sentry-react-native/pull/4746)) +- Add missing `popTimeToDisplayFor` in to the Android Old Arch Native interface([#4751](https://github.com/getsentry/sentry-react-native/pull/4751)) + +### Changes + +- Change `gradle.projectsEvaluated` to `project.afterEvaluate` in the Sentry Gradle Plugin to fix tasks not being created when using `--configure-on-demand` ([#4687](https://github.com/getsentry/sentry-react-native/pull/4687)) +- Remove `SENTRY_FORCE_FOREGROUND` from Xcode Scripts as the underlying `--force-foreground` Sentry CLI is no-op since v2.37.0 ([#4689](https://github.com/getsentry/sentry-react-native/pull/4689)) +- TTID and TTFD use native getters instead od events to pass timestamps to the JS layer ([#4669](https://github.com/getsentry/sentry-react-native/pull/4669), [#4681](https://github.com/getsentry/sentry-react-native/pull/4681)) + +### Dependencies + +- Bump Bundler Plugins from v3.2.2 to v3.3.1 ([#4693](https://github.com/getsentry/sentry-react-native/pull/4693), [#4707](https://github.com/getsentry/sentry-react-native/pull/4707), [#4720](https://github.com/getsentry/sentry-react-native/pull/4720), [#4721](https://github.com/getsentry/sentry-react-native/pull/4721)) + - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#331) + - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.2.2...3.3.1) +- Bump CLI from v2.42.4 to v2.43.0 ([#4692](https://github.com/getsentry/sentry-react-native/pull/4692)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2430) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.42.4...2.43.0) +- Bump Cocoa SDK from v8.48.0 to v8.49.0 ([#4742](https://github.com/getsentry/sentry-react-native/pull/4742)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8490) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.48.0...8.49.0) + +## 6.11.0-beta.0 + +### Features + +- Improve Warm App Start reporting on Android ([#4641](https://github.com/getsentry/sentry-react-native/pull/4641), [#4695](https://github.com/getsentry/sentry-react-native/pull/4695)) +- Add `createTimeToInitialDisplay({useFocusEffect})` and `createTimeToFullDisplay({useFocusEffect})` to allow record full display on screen focus ([#4665](https://github.com/getsentry/sentry-react-native/pull/4665)) +- Add support for measuring Time to Initial Display for already seen routes ([#4661](https://github.com/getsentry/sentry-react-native/pull/4661)) + - Introduce `enableTimeToInitialDisplayForPreloadedRoutes` option to the React Navigation integration. + + ```js + Sentry.reactNavigationIntegration({ + enableTimeToInitialDisplayForPreloadedRoutes: true, + }); + ``` + +- Add `useDispatchedActionData` option to the React Navigation integration to filter out navigation actions that should not create spans ([#4684](https://github.com/getsentry/sentry-react-native/pull/4684)) + - For example `PRELOAD`, `SET_PARAMS`, `TOGGLE_DRAWER` and others. + + ```js + Sentry.reactNavigationIntegration({ + useDispatchedActionData: true, + }); + ``` + +### Fixes + +- Equalize TTID and TTFD duration when TTFD manual API is called and resolved before auto TTID ([#4680](https://github.com/getsentry/sentry-react-native/pull/4680)) +- Avoid loading Sentry native components in Expo Go ([#4696](https://github.com/getsentry/sentry-react-native/pull/4696)) + +### Changes + +- Change `gradle.projectsEvaluated` to `project.afterEvaluate` in the Sentry Gradle Plugin to fix tasks not being created when using `--configure-on-demand` ([#4687](https://github.com/getsentry/sentry-react-native/pull/4687)) +- Remove `SENTRY_FORCE_FOREGROUND` from Xcode Scripts as the underlying `--force-foreground` Sentry CLI is no-op since v2.37.0 ([#4689](https://github.com/getsentry/sentry-react-native/pull/4689)) +- TTID and TTFD use native getters instead od events to pass timestamps to the JS layer ([#4669](https://github.com/getsentry/sentry-react-native/pull/4669), [#4681](https://github.com/getsentry/sentry-react-native/pull/4681)) + +## 6.10.0 + +### Features + +- Add thread information to spans ([#4579](https://github.com/getsentry/sentry-react-native/pull/4579)) +- Exposed `getDataFromUri` as a public API to retrieve data from a URI ([#4638](https://github.com/getsentry/sentry-react-native/pull/4638)) +- Add `enableExperimentalViewRenderer` to enable up to 5x times more performance in Session Replay on iOS ([#4660](https://github.com/getsentry/sentry-react-native/pull/4660)) + + ```js + import * as Sentry from '@sentry/react-native'; + + Sentry.init({ + integrations: [ + Sentry.mobileReplayIntegration({ + enableExperimentalViewRenderer: true, + }), + ], + }); + ``` + +### Fixes + +- Considers the `SENTRY_DISABLE_AUTO_UPLOAD` and `SENTRY_DISABLE_NATIVE_DEBUG_UPLOAD` environment variables in the configuration of the Sentry Android Gradle Plugin for Expo plugin ([#4583](https://github.com/getsentry/sentry-react-native/pull/4583)) +- Handle non-string category in getCurrentScreen on iOS ([#4629](https://github.com/getsentry/sentry-react-native/pull/4629)) +- Use route name instead of route key for current route tracking ([#4650](https://github.com/getsentry/sentry-react-native/pull/4650)) + - Using key caused user interaction transaction names to contain route hash in the name. + +### Dependencies + +- Bump Bundler Plugins from v3.2.0 to v3.2.2 ([#4585](https://github.com/getsentry/sentry-react-native/pull/4585), [#4620](https://github.com/getsentry/sentry-react-native/pull/4620)) + - [changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md#322) + - [diff](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/3.2.0...3.2.2) +- Bump CLI from v2.42.1 to v2.42.4 ([#4586](https://github.com/getsentry/sentry-react-native/pull/4586), [#4655](https://github.com/getsentry/sentry-react-native/pull/4655), [#4671](https://github.com/getsentry/sentry-react-native/pull/4671)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2424) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.42.1...2.42.4) +- Bump Cocoa SDK from v8.45.0 to v8.48.0 ([#4621](https://github.com/getsentry/sentry-react-native/pull/4621), [#4651](https://github.com/getsentry/sentry-react-native/pull/4651), [#4662](https://github.com/getsentry/sentry-react-native/pull/4662)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8480) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.45.0...8.48.0) +- Bump Android SDK from v7.22.1 to v7.22.5 ([#4675](https://github.com/getsentry/sentry-react-native/pull/4675), [#4683](https://github.com/getsentry/sentry-react-native/pull/4683)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7225) + - [diff](https://github.com/getsentry/sentry-java/compare/7.22.1...7.22.5) + +## 6.9.1 + +### Fixes + +- Fixes missing Cold Start measurements by bumping the Android SDK version to v7.22.1 ([#4643](https://github.com/getsentry/sentry-react-native/pull/4643)) +- Attach App Start spans to the first created not the first processed root span ([#4618](https://github.com/getsentry/sentry-react-native/pull/4618), [#4644](https://github.com/getsentry/sentry-react-native/pull/4644)) + +### Dependencies + +- Bump Android SDK from v7.22.0 to v7.22.1 ([#4643](https://github.com/getsentry/sentry-react-native/pull/4643)) + - [changelog](https://github.com/getsentry/sentry-java/blob/7.x.x/CHANGELOG.md#7221) + - [diff](https://github.com/getsentry/sentry-java/compare/7.22.0...7.22.1) + +## 6.9.0 + +> [!WARNING] +> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android. +> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details. ### Features @@ -16,7 +290,7 @@ ```js import Sentry from "@sentry/react-native"; - + Sentry.showFeedbackWidget(); Sentry.wrap(RootComponent); @@ -43,6 +317,10 @@ ## 6.8.0 +> [!WARNING] +> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android. +> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details. + ### Features - Adds Sentry Android Gradle Plugin as an experimental Expo plugin feature ([#4440](https://github.com/getsentry/sentry-react-native/pull/4440)) @@ -143,6 +421,10 @@ ## 6.7.0 +> [!WARNING] +> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android. +> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details. + ### Features - Add `ignoredComponents` option to `annotateReactComponents` to exclude specific components from React component annotations ([#4517](https://github.com/getsentry/sentry-react-native/pull/4517)) @@ -175,6 +457,10 @@ ## 6.6.0 +> [!WARNING] +> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android. +> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details. + ### Features - Send Sentry React Native SDK version in the Session Replay Events on iOS ([#4450](https://github.com/getsentry/sentry-react-native/pull/4450)) @@ -211,6 +497,10 @@ ## 6.5.0 +> [!WARNING] +> This release contains an issue where Cold starts can be incorrectly reported as Warm starts on Android. We recommend staying on version 6.4.0 if you use this feature on Android. +> See issue [#4598](https://github.com/getsentry/sentry-react-native/issues/4598) for more details. + ### Features - Mobile Session Replay is now generally available and ready for production use ([#4384](https://github.com/getsentry/sentry-react-native/pull/4384)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e67889ffa..ca64d58f7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ This repository contains mono repository structure with multiple React Native an # Requirements -- nodejs 18 (with corepack enabled) +- nodejs 18 (with corepack globally installed) - yarn version specified in `package.json` (at the moment version 3.6) ## Building diff --git a/dev-packages/e2e-tests/cli.mjs b/dev-packages/e2e-tests/cli.mjs index cb910ef938..c9281549f3 100755 --- a/dev-packages/e2e-tests/cli.mjs +++ b/dev-packages/e2e-tests/cli.mjs @@ -234,16 +234,35 @@ if (actions.includes('test')) { if (!sentryAuthToken) { console.log('Skipping maestro test due to unavailable or empty SENTRY_AUTH_TOKEN'); } else { - execSync( - `maestro test maestro \ - --env=APP_ID="${appId}" \ - --env=SENTRY_AUTH_TOKEN="${sentryAuthToken}" \ - --debug-output maestro-logs \ - --flatten-debug-output`, - { - stdio: 'inherit', - cwd: e2eDir, - }, - ); + try { + execSync( + `maestro test maestro \ + --env=APP_ID="${appId}" \ + --env=SENTRY_AUTH_TOKEN="${sentryAuthToken}" \ + --debug-output maestro-logs \ + --flatten-debug-output`, + { + stdio: 'inherit', + cwd: e2eDir, + }, + ); + } finally { + // Always redact sensitive data, even if the test fails + const redactScript = ` + if [[ "$(uname)" == "Darwin" ]]; then + find ./maestro-logs -type f -exec sed -i '' "s/${sentryAuthToken}/[REDACTED]/g" {} + + echo 'Redacted sensitive data from logs on MacOS' + else + find ./maestro-logs -type f -exec sed -i "s/${sentryAuthToken}/[REDACTED]/g" {} + + echo 'Redacted sensitive data from logs on Ubuntu' + fi + `; + + try { + execSync(redactScript, { stdio: 'inherit', cwd: e2eDir, shell: '/bin/bash' }); + } catch (error) { + console.warn('Failed to redact sensitive data from logs:', error.message); + } + } } } diff --git a/dev-packages/e2e-tests/maestro/feedback.yml b/dev-packages/e2e-tests/maestro/feedback.yml new file mode 100644 index 0000000000..ce7d79b89d --- /dev/null +++ b/dev-packages/e2e-tests/maestro/feedback.yml @@ -0,0 +1,19 @@ +appId: ${APP_ID} +jsEngine: graaljs +--- +- runFlow: utils/launchTestAppClear.yml + + +# The following tests are happy path tests for the feedback widget on both iOS and Android. +# They verify that the feedback form can be opened, filled out, and submitted successfully. +# The tests are separate because iOS tests work better with `testID` and Android tests work better with `text`. + +- runFlow: + file: feedback/happyFlow-ios.yml + when: + platform: iOS + +- runFlow: + file: feedback/happyFlow-android.yml + when: + platform: Android diff --git a/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml b/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml new file mode 100644 index 0000000000..221b0cbf84 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/feedback/happyFlow-android.yml @@ -0,0 +1,39 @@ +# This is a happy path test for the feedback widget on Android. +# It verifies that the feedback form can be opened, filled out, and submitted successfully +appId: ${APP_ID} +jsEngine: graaljs +--- + +# Show feedback button +- tapOn: 'Feedback' + +# Open feedback widget +- tapOn: 'Report a Bug' + +# Assert that the feedback form is visible +- extendedWaitUntil: + visible: 'Report a Bug' + timeout: 5_000 + +# Fill out name field +- tapOn: 'Your Name' +- inputText: 'John Doe' + +# Fill out email field +- tapOn: 'your.email@example.org' +- inputText: 'test@email.com' + +# Fill out message field +- tapOn: "What's the bug? What did you expect?" +- inputText: 'This is a test feedback message from CI e2e tests' + +# Submit feedback +- scrollUntilVisible: + element: + text: 'Send Bug Report' +- tapOn: 'Send Bug Report' +- assertVisible: 'Thank you for your report!' +- tapOn: 'OK' + +# Verify feedback form is closed and the home screen is visible +- assertVisible: 'Welcome to React Native' diff --git a/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml b/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml new file mode 100644 index 0000000000..7f8c3340b1 --- /dev/null +++ b/dev-packages/e2e-tests/maestro/feedback/happyFlow-ios.yml @@ -0,0 +1,45 @@ +# This is a happy path test for the feedback widget on iOS. +# It verifies that the feedback form can be opened, filled out, and submitted successfully +appId: ${APP_ID} +jsEngine: graaljs +--- + +# Show feedback button +- tapOn: 'Feedback' + +# Open feedback widget +- tapOn: + id: 'sentry-feedback-button' + +# Assert that the feedback form is visible +- extendedWaitUntil: + visible: + id: 'sentry-feedback-form-title' + timeout: 5_000 + +# Fill out name field +- tapOn: + id: 'sentry-feedback-name-input' +- inputText: 'John Doe' + +# Fill out email field +- tapOn: + id: 'sentry-feedback-email-input' +- inputText: 'test@email.com' + +# Fill out message field +- tapOn: + id: 'sentry-feedback-message-input' +- inputText: 'This is a test feedback message from CI e2e tests' + +# Submit feedback +- scrollUntilVisible: + element: + id: 'sentry-feedback-submit-button' +- tapOn: + id: 'sentry-feedback-submit-button' +- assertVisible: 'Thank you for your report!' +- tapOn: 'OK' + +# Verify feedback form is closed and the home screen is visible +- assertVisible: 'Welcome to React Native' diff --git a/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml b/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml index 09ec63166f..ad669cedcc 100644 --- a/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml +++ b/dev-packages/e2e-tests/maestro/utils/launchTestAppClear.yml @@ -8,7 +8,6 @@ jsEngine: graaljs - launchApp: clearState: true arguments: - sentryAuthToken: ${SENTRY_AUTH_TOKEN} replaysOnErrorSampleRate: ${replaysOnErrorSampleRate} - runFlow: assertTestReady.yml diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 7f66e3884e..3c4fc4046e 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "sentry-react-native-e2e-tests", - "version": "6.8.0", + "version": "6.15.1", "private": true, "description": "Sentry React Native End to End Tests Library", "main": "dist/index.js", @@ -14,7 +14,7 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-typescript": "^7.18.6", "@sentry/core": "8.54.0", - "@sentry/react-native": "6.8.0", + "@sentry/react-native": "6.15.1", "@types/node": "^20.9.3", "@types/react": "^18.2.64", "appium": "2.4.1", diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js index a670278da1..48660f1a56 100755 --- a/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js +++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.app.js @@ -38,7 +38,8 @@ Sentry.init({ const e2eComponentPatch = ''; const lastImportRex = /^([^]*)(import\s+[^;]*?;$)/m; const patchRex = '@sentry/react-native'; -const headerComponentRex = /
/gm; +const headerComponentRex = / m + initPatch) - .replace(headerComponentRex, m => e2eComponentPatch + m); + .replace(headerComponentRex, m => e2eComponentPatch + m) + .replace(exportDefaultRex, 'export default Sentry.wrap(App);'); fs.writeFileSync(appPath, patched); logger.info('Patched RN App.(js|tsx) successfully!'); diff --git a/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js b/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js index f9be9e3329..d044817df2 100755 --- a/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js +++ b/dev-packages/e2e-tests/patch-scripts/rn.patch.xcode.js @@ -32,7 +32,6 @@ if (semver.satisfies(args['rn-version'], `< ${newBundleScriptRNVersion}`, { incl logger.info('Applying old bundle script patch'); bundleScript = ` export NODE_BINARY=node -export SENTRY_CLI_EXTRA_ARGS="--force-foreground" ../node_modules/@sentry/react-native/scripts/sentry-xcode.sh ../node_modules/react-native/scripts/react-native-xcode.sh `; bundleScriptRegex = /(packager|scripts)\/react-native-xcode\.sh\b/; @@ -40,7 +39,6 @@ export SENTRY_CLI_EXTRA_ARGS="--force-foreground" } else if (semver.satisfies(args['rn-version'], `>= ${newBundleScriptRNVersion}`, { includePrerelease: true })) { logger.info('Applying new bundle script patch'); bundleScript = ` -export SENTRY_CLI_EXTRA_ARGS="--force-foreground" WITH_ENVIRONMENT="../node_modules/react-native/scripts/xcode/with-environment.sh" REACT_NATIVE_XCODE="../node_modules/react-native/scripts/react-native-xcode.sh" diff --git a/dev-packages/e2e-tests/src/EndToEndTests.tsx b/dev-packages/e2e-tests/src/EndToEndTests.tsx index 173286e57b..bda3b591cf 100644 --- a/dev-packages/e2e-tests/src/EndToEndTests.tsx +++ b/dev-packages/e2e-tests/src/EndToEndTests.tsx @@ -1,28 +1,9 @@ import * as Sentry from '@sentry/react-native'; import * as React from 'react'; import { Text, View } from 'react-native'; -import { LaunchArguments } from "react-native-launch-arguments"; const E2E_TESTS_READY_TEXT = 'E2E Tests Ready'; -const getSentryAuthToken = (): - | { token: string } - | { error: string } => { - const { sentryAuthToken } = LaunchArguments.value<{ - sentryAuthToken: unknown; - }>(); - - if (typeof sentryAuthToken !== 'string') { - return { error: 'Sentry Auth Token is required' }; - } - - if (sentryAuthToken.length === 0) { - return { error: 'Sentry Auth Token must not be empty' }; - } - - return { token: sentryAuthToken }; -}; - const EndToEndTestsScreen = (): JSX.Element => { const [isReady, setIsReady] = React.useState(false); const [eventId, setEventId] = React.useState(null); @@ -62,6 +43,11 @@ const EndToEndTestsScreen = (): JSX.Element => { name: 'Unhandled Promise Rejection', action: async () => await Promise.reject(new Error('Unhandled Promise Rejection')), }, + { + id: 'feedback', + name: 'Feedback', + action: () => Sentry.showFeedbackButton(), + }, { id: 'close', name: 'Close', diff --git a/dev-packages/type-check/package.json b/dev-packages/type-check/package.json index 929d8e8071..ca2c6623d7 100644 --- a/dev-packages/type-check/package.json +++ b/dev-packages/type-check/package.json @@ -1,7 +1,7 @@ { "name": "sentry-react-native-type-check", "private": true, - "version": "6.8.0", + "version": "6.15.1", "scripts": { "type-check": "./run-type-check.sh" } diff --git a/dev-packages/type-check/run-type-check.sh b/dev-packages/type-check/run-type-check.sh index 0060cd914b..041cf231e8 100755 --- a/dev-packages/type-check/run-type-check.sh +++ b/dev-packages/type-check/run-type-check.sh @@ -14,6 +14,9 @@ yalc add @sentry/react-native yarn install +echo "Removing duplicate React types..." +rm -rf ./node_modules/@types/react-native/node_modules/@types/react + yarn type-check rm yarn.lock diff --git a/dev-packages/type-check/ts3.8-test/index.ts b/dev-packages/type-check/ts3.8-test/index.ts index 6f1ec6d6cc..1e9fda3cd2 100644 --- a/dev-packages/type-check/ts3.8-test/index.ts +++ b/dev-packages/type-check/ts3.8-test/index.ts @@ -20,6 +20,11 @@ declare global { } interface PerformanceEntry {} } + +declare module 'react-native' { + export interface TurboModule {} +} + import 'react-native'; // we need to import the SDK to ensure tsc check the types diff --git a/dev-packages/utils/package.json b/dev-packages/utils/package.json index c16095f686..87c27ab3ec 100644 --- a/dev-packages/utils/package.json +++ b/dev-packages/utils/package.json @@ -1,11 +1,11 @@ { "name": "sentry-react-native-samples-utils", - "version": "6.8.0", + "version": "6.15.1", "description": "Internal Samples Utils", "main": "index.js", "license": "MIT", "private": true, "dependencies": { - "metro-config": "^0.81.0" + "metro-config": "^0.82.2" } } diff --git a/lerna.json b/lerna.json index 1dc40ce9bd..28dd6410f8 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "6.8.0", + "version": "6.15.1", "packages": [ "packages/*", "dev-packages/*", diff --git a/package.json b/package.json index 3253554fad..7af7287f13 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@expo/swiftlint": "^0.57.1", "@naturalcycles/ktlint": "^1.13.0", - "@sentry/cli": "2.42.1", + "@sentry/cli": "2.46.0", "clang-format": "^1.8.0", "downlevel-dts": "^0.11.0", "google-java-format": "^1.4.0", diff --git a/packages/core/RNSentry.podspec b/packages/core/RNSentry.podspec index f59ef9daad..d24d88d420 100644 --- a/packages/core/RNSentry.podspec +++ b/packages/core/RNSentry.podspec @@ -37,7 +37,7 @@ Pod::Spec.new do |s| s.compiler_flags = other_cflags - s.dependency 'Sentry/HybridSDK', '8.45.0' + s.dependency 'Sentry/HybridSDK', '8.52.1' if defined? install_modules_dependencies # Default React Native dependencies for 0.71 and above (new and legacy architecture) diff --git a/packages/core/RNSentryAndroidTester/app/build.gradle b/packages/core/RNSentryAndroidTester/app/build.gradle index fc0a2b9462..26334b0f85 100644 --- a/packages/core/RNSentryAndroidTester/app/build.gradle +++ b/packages/core/RNSentryAndroidTester/app/build.gradle @@ -43,9 +43,11 @@ dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' + implementation 'androidx.test:core-ktx:1.6.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.10.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' + testImplementation 'org.robolectric:robolectric:4.14.1' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryAppStartTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryAppStartTest.kt new file mode 100644 index 0000000000..009d300ce4 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryAppStartTest.kt @@ -0,0 +1,159 @@ +package io.sentry.react + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.WritableMap +import io.sentry.ILogger +import io.sentry.SentryLevel +import io.sentry.android.core.performance.AppStartMetrics +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.MockedStatic +import org.mockito.Mockito.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RNSentryAppStartTest { + private lateinit var module: RNSentryModuleImpl + private lateinit var promise: Promise + private lateinit var logger: ILogger + private lateinit var metrics: AppStartMetrics + private lateinit var metricsDataBag: Map + + private var argumentsMock: MockedStatic? = null + + @Captor + private lateinit var writableMapCaptor: ArgumentCaptor + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + promise = mock(Promise::class.java) + logger = mock(ILogger::class.java) + + metrics = AppStartMetrics() + metrics.appStartTimeSpan.start() + metrics.appStartTimeSpan.stop() + metricsDataBag = mapOf() + + RNSentryModuleImpl.lastStartTimestampMs = -1 + + module = Utils.createRNSentryModuleWithMockedContext() + + // Mock the Arguments class + argumentsMock = mockStatic(Arguments::class.java) + whenever(Arguments.createMap()).thenReturn(JavaOnlyMap()) + } + + @After + fun tearDown() { + argumentsMock?.close() + } + + @Test + fun `fetchNativeAppStart resolves promise with null when app is not launched in the foreground`() { + val metrics = AppStartMetrics() + metrics.isAppLaunchedInForeground = false + + val metricsDataBag = mapOf() + + module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger) + + verifyWarnOnceWith( + logger, + "Invalid app start data: app not launched in foreground.", + ) + + verify(promise).resolve(null) + } + + @Test + fun `fetchNativeAppStart resolves promise with app start data when app is launched in the foreground`() { + metrics.isAppLaunchedInForeground = true + + module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger) + + verifyDebugOnceWith(logger, "App Start data reported to the RN layer for the first time.") + + val capturedMap = getWritableMapFromPromiseResolve(promise) + assertEquals(false, capturedMap.getBoolean("has_fetched")) + } + + @Test + fun `fetchNativeAppStart marks data as fetched when retried multiple times`() { + metrics.isAppLaunchedInForeground = true + + module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger) + + // Clear invocations from the first call + clearInvocations(promise) + clearInvocations(logger) + module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger) + + verifyDebugOnceWith(logger, "App Start data already fetched from native before.") + + val capturedMap = getWritableMapFromPromiseResolve(promise) + assertEquals(true, capturedMap.getBoolean("has_fetched")) + } + + @Test + fun `fetchNativeAppStart returns updated app start data as not fetched before`() { + metrics.isAppLaunchedInForeground = true + + module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger) + + // Clear invocations from the first call + clearInvocations(promise) + clearInvocations(logger) + + metrics.onAppStartSpansSent() + metrics.appStartTimeSpan.setStartUnixTimeMs(1741691014000) + metrics.appStartTimeSpan.stop() + module.fetchNativeAppStart(promise, metrics, metricsDataBag, logger) + + verifyDebugOnceWith(logger, "App Start data updated, reporting to the RN layer again.") + + val capturedMap = getWritableMapFromPromiseResolve(promise) + assertEquals(false, capturedMap.getBoolean("has_fetched")) + } + + private fun getWritableMapFromPromiseResolve(promise: Promise): WritableMap { + verify(promise).resolve(any(WritableMap::class.java)) + verify(promise).resolve(writableMapCaptor.capture()) + return writableMapCaptor.value + } + + private fun verifyWarnOnceWith( + logger: ILogger, + value: String, + ) { + verify( + logger, + org.mockito.kotlin.times(1), + ).log(eq(SentryLevel.WARNING), eq(value)) + } + + private fun verifyDebugOnceWith( + logger: ILogger, + value: String, + ) { + verify( + logger, + org.mockito.kotlin.times(1), + ).log(eq(SentryLevel.DEBUG), eq(value)) + } +} 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 deleted file mode 100644 index 34af996a76..0000000000 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleImplTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -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.Promise -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.WritableMap -import io.sentry.ILogger -import io.sentry.SentryLevel -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mockito.ArgumentCaptor -import org.mockito.Captor -import org.mockito.MockedStatic -import org.mockito.Mockito.any -import org.mockito.Mockito.anyInt -import org.mockito.Mockito.anyString -import org.mockito.Mockito.mock -import org.mockito.Mockito.mockStatic -import org.mockito.Mockito.verify -import org.mockito.MockitoAnnotations -import org.mockito.kotlin.whenever - -@RunWith(JUnit4::class) -class RNSentryModuleImplTest { - private lateinit var module: RNSentryModuleImpl - private lateinit var promise: Promise - private lateinit var logger: ILogger - private var argumentsMock: MockedStatic? = null - - @Captor - private lateinit var writableMapCaptor: ArgumentCaptor - - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - val reactContext = mock(ReactApplicationContext::class.java) - promise = mock(Promise::class.java) - logger = mock(ILogger::class.java) - val packageManager = mock(PackageManager::class.java) - val packageInfo = mock(PackageInfo::class.java) - - whenever(reactContext.packageManager).thenReturn(packageManager) - whenever(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) - - module = RNSentryModuleImpl(reactContext) - - // Mock the Arguments class - argumentsMock = mockStatic(Arguments::class.java) - val writableMap = mock(WritableMap::class.java) - whenever(Arguments.createMap()).thenReturn(writableMap) - } - - @After - fun tearDown() { - argumentsMock?.close() - } - - @Test - fun `fetchNativeAppStart resolves promise with null when app is not launched in the foreground`() { - // Mock the app start measurement - val appStartMeasurement = mapOf() - - // Call the method - module.fetchNativeAppStart(promise, appStartMeasurement, logger, false) - - // Verify a warning log is emitted - verify(logger, org.mockito.kotlin.times(1)).log( - SentryLevel.WARNING, - "Invalid app start data: app not launched in foreground.", - ) - - // Verify the promise is resolved with null - verify(promise).resolve(null) - } - - @Test - fun `fetchNativeAppStart resolves promise with app start data when app is launched in the foreground`() { - // Mock the app start measurement - val appStartMeasurement = mapOf() - - // Call the method - module.fetchNativeAppStart(promise, appStartMeasurement, logger, true) - - // Verify no logs are emitted - verify(logger, org.mockito.kotlin.times(0)).log(any(), any()) - - // Verify the promise is resolved with the expected data - verify(promise).resolve(any(WritableMap::class.java)) - verify(promise).resolve(writableMapCaptor.capture()) - val capturedMap = writableMapCaptor.value - assertEquals(false, capturedMap.getBoolean("has_fetched")) - } -} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleInitWithApplicationTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleInitWithApplicationTest.kt new file mode 100644 index 0000000000..7585335309 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryModuleInitWithApplicationTest.kt @@ -0,0 +1,29 @@ +package io.sentry.react + +import android.app.Application +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RNSentryModuleInitWithApplicationTest { + @Test + fun `when application context is null fallback to react context`() { + val mockedReactContext = Utils.makeReactContextMock() + whenever(mockedReactContext.applicationContext).thenReturn(null) + + assertSame(RNSentryModuleImpl(mockedReactContext).applicationContext, mockedReactContext) + } + + @Test + fun `use application context if available`() { + val mockedApplicationContext = mock(Application::class.java) + val mockedReactContext = Utils.makeReactContextMock() + whenever(mockedReactContext.applicationContext).thenReturn(mockedApplicationContext) + + assertSame(RNSentryModuleImpl(mockedReactContext).applicationContext, mockedApplicationContext) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt new file mode 100644 index 0000000000..183895561d --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryOnDrawReporterTest.kt @@ -0,0 +1,166 @@ +package io.sentry.react + +import android.app.Activity +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.facebook.react.bridge.ReactApplicationContext +import io.sentry.android.core.BuildInfoProvider +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RNSentryOnDrawReporterTest { + companion object { + private const val TTID_PREFIX = RNSentryOnDrawReporterManager.TTID_PREFIX + private const val TTFD_PREFIX = RNSentryOnDrawReporterManager.TTFD_PREFIX + private const val SPAN_ID = "test-span-id" + private const val NEW_SPAN_ID = "new-test-span-id" + } + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToFullDisplay are set the next render timestamp is saved - reversed order`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setParentSpanId(SPAN_ID) + reporter.setFullDisplay(true) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId and timeToInitialDisplay are set the next render timestamp is saved - reversed order`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setParentSpanId(SPAN_ID) + reporter.setInitialDisplay(true) + + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag and parentSpanId changes the next full display render is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(false) + reporter.setFullDisplay(true) + reporter.setParentSpanId(NEW_SPAN_ID) + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + NEW_SPAN_ID)) + } + + @Test + fun `when display flag and parentSpanId changes the next initial display render is saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(false) + reporter.setInitialDisplay(true) + reporter.setParentSpanId(NEW_SPAN_ID) + assertNotNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + NEW_SPAN_ID)) + } + + @Test + fun `when parentSpanId doesn't change the next full display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(false) + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when parentSpanId doesn't change the next initial display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(false) + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag doesn't change the next full display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setFullDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID) + + reporter.setFullDisplay(true) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTFD_PREFIX + SPAN_ID)) + } + + @Test + fun `when display flag doesn't change the next initial display render is not saved`() { + val reporter = createTestRNSentryOnDrawReporterView() + reporter.setInitialDisplay(true) + reporter.setParentSpanId(SPAN_ID) + RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID) + + reporter.setInitialDisplay(true) + assertNull(RNSentryTimeToDisplay.popTimeToDisplayFor(TTID_PREFIX + SPAN_ID)) + } + + class TestRNSentryOnDrawReporterView( + context: Context, + reactContext: ReactApplicationContext, + buildInfo: BuildInfoProvider, + ) : RNSentryOnDrawReporterManager.RNSentryOnDrawReporterView(context, reactContext, buildInfo) { + override fun registerForNextDraw( + activity: Activity, + callback: Runnable, + buildInfo: BuildInfoProvider, + ) { + callback.run() + } + } + + private fun createTestRNSentryOnDrawReporterView(): TestRNSentryOnDrawReporterView = + TestRNSentryOnDrawReporterView(ApplicationProvider.getApplicationContext(), mockReactContext(), mockBuildInfo()) + + private fun mockReactContext(): ReactApplicationContext { + val reactContext = mock() + whenever(reactContext.getCurrentActivity()).thenReturn(mock()) + return reactContext + } + + private fun mockBuildInfo(): BuildInfoProvider = mock() +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt new file mode 100644 index 0000000000..5ec953b287 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryTimeToDisplayTest.kt @@ -0,0 +1,48 @@ +package io.sentry.react + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class RNSentryTimeToDisplayTest { + companion object { + val TEST_ID = "test-id" + val TEST_VAL = 123.4 + } + + @Before + fun setUp() { + } + + @Test + fun `puts and pops record`() { + RNSentryTimeToDisplay.putTimeToDisplayFor(TEST_ID, TEST_VAL) + + val firstPop = RNSentryTimeToDisplay.popTimeToDisplayFor(TEST_ID) + val secondPop = RNSentryTimeToDisplay.popTimeToDisplayFor(TEST_ID) + + assertEquals(firstPop, TEST_VAL, 0.0) + assertNull(secondPop) + } + + @Test + fun `removes oldes entry when full`() { + val maxSize = RNSentryTimeToDisplay.ENTRIES_MAX_SIZE + 1 + for (i in 1..maxSize) { + RNSentryTimeToDisplay.putTimeToDisplayFor("$TEST_ID-$i", i.toDouble()) + } + + val oldestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-1") + val secondOldestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-2") + val newestEntry = RNSentryTimeToDisplay.popTimeToDisplayFor("$TEST_ID-$maxSize") + + assertNull(oldestEntry) + assertNotNull(secondOldestEntry) + assertNotNull(newestEntry) + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/Utils.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/Utils.kt new file mode 100644 index 0000000000..840e940917 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/Utils.kt @@ -0,0 +1,30 @@ +package io.sentry.react + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import com.facebook.react.bridge.ReactApplicationContext +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +class Utils { + companion object { + fun makeReactContextMock(): ReactApplicationContext { + val packageManager = mock(PackageManager::class.java) + val packageInfo = mock(PackageInfo::class.java) + + val reactContext = mock(ReactApplicationContext::class.java) + whenever(reactContext.packageManager).thenReturn(packageManager) + whenever(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + + return reactContext + } + + fun createRNSentryModuleWithMockedContext(): RNSentryModuleImpl { + RNSentryModuleImpl.lastStartTimestampMs = -1 + + return RNSentryModuleImpl(makeReactContextMock()) + } + } +} diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/utils/RNSentryActivityUtilsTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/utils/RNSentryActivityUtilsTest.kt new file mode 100644 index 0000000000..3887b9991d --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/utils/RNSentryActivityUtilsTest.kt @@ -0,0 +1,52 @@ +package io.sentry.react.utils + +import android.app.Activity +import com.facebook.react.bridge.ReactApplicationContext +import io.sentry.android.core.AndroidLogger +import io.sentry.android.core.CurrentActivityHolder +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever + +@RunWith(JUnit4::class) +class RNSentryActivityUtilsTest { + private val mockedLogger = mock(AndroidLogger::class.java) + + @After + fun clearActivityHolder() { + CurrentActivityHolder.getInstance().clearActivity() + } + + @Test + fun `returns react context activity`() { + val mockedCurrentActivity = mock(Activity::class.java) + val mockedReactContext = mock(ReactApplicationContext::class.java) + whenever(mockedReactContext.currentActivity).thenReturn(mockedCurrentActivity) + + assertSame(RNSentryActivityUtils.getCurrentActivity(mockedReactContext, mockedLogger), mockedCurrentActivity) + } + + @Test + fun `returns current activity holder activity`() { + val mockedCurrentActivity = mock(Activity::class.java) + + val mockedReactContext = mock(ReactApplicationContext::class.java) + whenever(mockedReactContext.currentActivity).thenReturn(null) + + CurrentActivityHolder.getInstance().setActivity(mockedCurrentActivity) + assertSame(RNSentryActivityUtils.getCurrentActivity(mockedReactContext, mockedLogger), mockedCurrentActivity) + } + + @Test + fun `returns null when no activity exists`() { + val mockedReactContext = mock(ReactApplicationContext::class.java) + whenever(mockedReactContext.currentActivity).thenReturn(null) + + assertNull(RNSentryActivityUtils.getCurrentActivity(mockedReactContext, mockedLogger)) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj index d922dce766..c9297fbfd1 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 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 */; }; + 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */; }; + 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */; }; + 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */; }; 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 33F58ACF2977037D008F60EA /* RNSentryTests.mm */; }; AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */; }; B5859A50A3E865EF5E61465A /* libPods-RNSentryCocoaTesterTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */; }; @@ -58,6 +61,11 @@ 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryDependencyContainerTests.m; sourceTree = ""; }; 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNSentryDependencyContainerTests.h; sourceTree = ""; }; 33AFE0132B8F31AF00AAB120 /* RNSentryDependencyContainer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryDependencyContainer.h; path = ../ios/RNSentryDependencyContainer.h; sourceTree = ""; }; + 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryOnDrawReporterTests.swift; sourceTree = ""; }; + 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "RNSentryOnDrawReporter+Test.h"; sourceTree = ""; }; + 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = "RNSentryOnDrawReporter+Test.mm"; sourceTree = ""; }; + 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryTimeToDisplay.h; path = ../ios/RNSentryTimeToDisplay.h; sourceTree = SOURCE_ROOT; }; + 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryTimeToDisplayTests.swift; sourceTree = ""; }; 33F58ACF2977037D008F60EA /* RNSentryTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RNSentryTests.mm; sourceTree = ""; }; 650CB718ACFBD05609BF2126 /* libPods-RNSentryCocoaTesterTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNSentryCocoaTesterTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; E2321E7CFA55AB617247098E /* Pods-RNSentryCocoaTesterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNSentryCocoaTesterTests.debug.xcconfig"; path = "Target Support Files/Pods-RNSentryCocoaTesterTests/Pods-RNSentryCocoaTesterTests.debug.xcconfig"; sourceTree = ""; }; @@ -109,6 +117,10 @@ children = ( 339C6C412D3FD39500CA72ED /* RNSentryStartFromFileTests.swift */, 339C6C3B2D3EB23B00CA72ED /* RNSentryStartTests.swift */, + 33DEDFEF2D9185E3006066E4 /* RNSentryTimeToDisplayTests.swift */, + 33DEDFEC2D8DC820006066E4 /* RNSentryOnDrawReporter+Test.mm */, + 33DEDFEB2D8DC800006066E4 /* RNSentryOnDrawReporter+Test.h */, + 33DEDFE92D8DBE5B006066E4 /* RNSentryOnDrawReporterTests.swift */, 3339C47F2D6625260088EB3A /* RNSentry+Test.h */, 332D334A2CDCC8EB00547D76 /* RNSentryCocoaTesterTests-Bridging-Header.h */, 332D33492CDCC8E100547D76 /* RNSentryTests.h */, @@ -119,7 +131,6 @@ 33AFDFEE2B8D14C200AAB120 /* RNSentryFramesTrackerListenerTests.h */, 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */, 33AFDFF22B8D15F600AAB120 /* RNSentryDependencyContainerTests.h */, - 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */, 3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */, 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */, 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */, @@ -154,6 +165,7 @@ 333B58AF2D36A7FD000F8D04 /* RNSentrySDK.h */, 333B58A92D35BB2D000F8D04 /* RNSentryStart+Test.h */, 333B58A82D35BA93000F8D04 /* RNSentryStart.h */, + 33DEDFEE2D8DD431006066E4 /* RNSentryTimeToDisplay.h */, 3380C6C02CDEC56B0018B9B6 /* Replay */, 332D33482CDBDC7300547D76 /* RNSentry.h */, 3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */, @@ -292,12 +304,14 @@ AEFB00422CC90C4B00EC8A9A /* RNSentryBreadcrumbTests.swift in Sources */, 332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */, 339C6C3C2D3EB25100CA72ED /* RNSentryStartTests.swift in Sources */, + 33DEDFEA2D8DBE67006066E4 /* RNSentryOnDrawReporterTests.swift in Sources */, 33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */, 339C6C422D3FD3AE00CA72ED /* RNSentryStartFromFileTests.swift in Sources */, 336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */, + 33DEDFED2D8DC825006066E4 /* RNSentryOnDrawReporter+Test.mm in Sources */, 33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */, - 33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */, 3339C4812D6625570088EB3A /* RNSentryUserTests.mm in Sources */, + 33DEDFF02D9185EB006066E4 /* RNSentryTimeToDisplayTests.swift in Sources */, 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */, 33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */, ); diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift index eb314766e8..0002c11e4f 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryBreadcrumbTests.swift @@ -54,6 +54,12 @@ final class RNSentryBreadcrumbTests: XCTestCase { XCTAssertNil(actual) } + func testNullForNonStringCategory() { + let map: [String: Any] = ["category": false] + let actual = RNSentryBreadcrumb.getCurrentScreen(from: map) + XCTAssertNil(actual) + } + func testNullForNonNavigationCategory() { let map: [String: Any] = ["category": "unknown"] let actual = RNSentryBreadcrumb.getCurrentScreen(from: map) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h index 08fddcbf8e..da0eb2b587 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h @@ -3,10 +3,12 @@ // #import "RNSentryBreadcrumb.h" +#import "RNSentryOnDrawReporter+Test.h" #import "RNSentryReplay.h" #import "RNSentryReplayBreadcrumbConverter.h" #import "RNSentryReplayMask.h" #import "RNSentryReplayUnmask.h" #import "RNSentrySDK+Test.h" #import "RNSentryStart.h" +#import "RNSentryTimeToDisplay.h" #import "RNSentryVersion.h" diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h new file mode 100644 index 0000000000..2ef701d215 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.h @@ -0,0 +1,10 @@ +#import "RNSentryOnDrawReporter.h" +#import + +@interface +RNSentryOnDrawReporterView (Testing) + ++ (instancetype)createWithMockedListener; +- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent; + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm new file mode 100644 index 0000000000..3aca532855 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporter+Test.mm @@ -0,0 +1,49 @@ +#import "RNSentryOnDrawReporter+Test.h" + +@interface MockedListener : NSObject +@property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; +- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent; +@end + +@implementation MockedListener + +- (instancetype)initWithMockedListener:(RNSentryEmitNewFrameEvent)emitNewFrameEvent +{ + self = [super init]; + if (self) { + _emitNewFrameEvent = [emitNewFrameEvent copy]; + } + return self; +} + +- (void)startListening +{ + self.emitNewFrameEvent(@([[NSDate date] timeIntervalSince1970])); +} + +- (void)framesTrackerHasNewFrame:(nonnull NSDate *)newFrameDate +{ + NSLog(@"Not implemented in the test mock"); +} + +@end + +@implementation +RNSentryOnDrawReporterView (Testing) + ++ (instancetype)createWithMockedListener +{ + return [[self alloc] initWithMockedListener]; +} + +- (instancetype)initWithMockedListener +{ + self = [super init]; + if (self) { + self.framesListener = + [[MockedListener alloc] initWithMockedListener:[self createEmitNewFrameEvent]]; + } + return self; +} + +@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m deleted file mode 100644 index 13de6a12c9..0000000000 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.m +++ /dev/null @@ -1,16 +0,0 @@ -#import "RNSentryOnDrawReporter.h" -#import - -@interface RNSentryOnDrawReporterTests : XCTestCase - -@end - -@implementation RNSentryOnDrawReporterTests - -- (void)testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs -{ - RNSentryOnDrawReporterView *view = [[RNSentryOnDrawReporterView alloc] init]; - XCTAssertNotNil(view); -} - -@end diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift new file mode 100644 index 0000000000..314ee57dbe --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryOnDrawReporterTests.swift @@ -0,0 +1,148 @@ +import Sentry +import XCTest + +final class RNSentryOnDrawReporterTests: XCTestCase { + private let ttidPrefix = "ttid-" + private let ttfdPrefix = "ttfd-" + private let spanId = "test-span-id" + private let newSpanId = "new-test-span-id" + + func testRNSentryOnDrawReporterViewIsAvailableWhenUIKitIs() { + let view = RNSentryOnDrawReporterView() + XCTAssertNotNil(view) + } + + func testWhenParentSpanIdAndTimeToFullDisplayAreSetTheNextRenderTimestampIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenParentSpanIdAndTimeToInitialDisplayAreSetTheNextRenderTimestampIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testWhenDisplayFlagAndParentSpanIdChangesTheNextFullDisplayRenderIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = false + reporter!.didSetProps(["fullDisplay"]) + reporter!.fullDisplay = true + reporter!.parentSpanId = newSpanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + newSpanId)) + } + + func testWhenDisplayFlagAndParentSpanIdChangesTheNextInitialDisplayRenderIsSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.initialDisplay = false + reporter!.didSetProps(["initalDisplay"]) + reporter!.initialDisplay = true + reporter!.parentSpanId = newSpanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNotNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + newSpanId)) + } + + func testWhenParentSpanIdDoesntChangeTheNextFullDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = false + reporter!.didSetProps(["fullDisplay"]) + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenParentSpanIdDoesntChangeTheNextInitialDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId) + + reporter!.initialDisplay = false + reporter!.didSetProps(["initalDisplay"]) + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testWhenDisplayFlagDoesntChangeTheNextFullDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.fullDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId) + + reporter!.fullDisplay = true + reporter!.didSetProps(["fullDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttfdPrefix + spanId)) + } + + func testWhenDisplayFlagDoesntChangeTheNextInitialDisplayRenderIsNotSaved() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + reporter!.initialDisplay = true + reporter!.parentSpanId = spanId + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId) + + reporter!.initialDisplay = true + reporter!.didSetProps(["initialDisplay", "parentSpanId"]) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: ttidPrefix + spanId)) + } + + func testFullDisplayEmitNewFrameCallbackHandlesMissingParentSpanId() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + + // We call the callback manually in this test to simulate a change between + // start of listening for next frame and the next frame render + let emitNewFrameCallback = reporter!.createEmitNewFrameEvent() + + reporter!.fullDisplay = true + reporter!.parentSpanId = nil + + emitNewFrameCallback!(1) + } + + func testInitialDisplayEmitNewFrameCallbackHandlesMissingParentSpanId() { + let reporter = RNSentryOnDrawReporterView.createWithMockedListener() + + // We call the callback manually in this test to simulate a change between + // start of listening for next frame and the next frame render + let emitNewFrameCallback = reporter!.createEmitNewFrameEvent() + + reporter!.initialDisplay = true + reporter!.parentSpanId = nil + + emitNewFrameCallback!(1) + } +} diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift index 0cbe2cefdf..200d6422ec 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift @@ -48,13 +48,15 @@ final class RNSentryReplayOptions: XCTestCase { } func assertAllDefaultReplayOptionsAreNotNil(replayOptions: [String: Any]) { - XCTAssertEqual(replayOptions.count, 6) + XCTAssertEqual(replayOptions.count, 8) XCTAssertNotNil(replayOptions["sessionSampleRate"]) XCTAssertNotNil(replayOptions["errorSampleRate"]) XCTAssertNotNil(replayOptions["maskAllImages"]) XCTAssertNotNil(replayOptions["maskAllText"]) XCTAssertNotNil(replayOptions["maskedViewClasses"]) XCTAssertNotNil(replayOptions["sdkInfo"]) + XCTAssertNotNil(replayOptions["enableViewRendererV2"]) + XCTAssertNotNil(replayOptions["enableFastViewRendering"]) } func testSessionSampleRate() { @@ -165,4 +167,86 @@ final class RNSentryReplayOptions: XCTestCase { XCTAssertEqual(actualOptions.sessionReplay.maskedViewClasses.count, 0) } + func testEnableViewRendererV2Default() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75 + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + + let actualOptions = try! Options(dict: optionsDict as! [String: Any]) + + XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2) + } + + func testEnableViewRendererV2True() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75, + "mobileReplayOptions": [ "enableViewRendererV2": true ] + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + + let actualOptions = try! Options(dict: optionsDict as! [String: Any]) + + XCTAssertTrue(actualOptions.sessionReplay.enableViewRendererV2) + } + + func testEnableViewRendererV2False() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75, + "mobileReplayOptions": [ "enableViewRendererV2": false ] + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + + let actualOptions = try! Options(dict: optionsDict as! [String: Any]) + + XCTAssertFalse(actualOptions.sessionReplay.enableViewRendererV2) + } + + func testEnableFastViewRenderingDefault() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75 + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + + let actualOptions = try! Options(dict: optionsDict as! [String: Any]) + + XCTAssertFalse(actualOptions.sessionReplay.enableFastViewRendering) + } + + func testEnableFastViewRenderingTrue() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75, + "mobileReplayOptions": [ "enableFastViewRendering": true ] + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + + let actualOptions = try! Options(dict: optionsDict as! [String: Any]) + + XCTAssertTrue(actualOptions.sessionReplay.enableFastViewRendering) + } + + func testEnableFastViewRenderingFalse() { + let optionsDict = ([ + "dsn": "https://abc@def.ingest.sentry.io/1234567", + "replaysOnErrorSampleRate": 0.75, + "mobileReplayOptions": [ "enableFastViewRendering": false ] + ] as NSDictionary).mutableCopy() as! NSMutableDictionary + + RNSentryReplay.updateOptions(optionsDict) + + let actualOptions = try! Options(dict: optionsDict as! [String: Any]) + + XCTAssertFalse(actualOptions.sessionReplay.enableFastViewRendering) + } + } diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift new file mode 100644 index 0000000000..cdeb8258a4 --- /dev/null +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryTimeToDisplayTests.swift @@ -0,0 +1,60 @@ +import Sentry +import XCTest + +final class RNSentryTimeToDisplayTests: XCTestCase { + private let TEST_ID = "test-id" + private let TEST_VAL = NSNumber(value: 123.4) + + func testPutsAndPopsRecords() { + RNSentryTimeToDisplay.put(for: TEST_ID, value: TEST_VAL) + + let firstPop = RNSentryTimeToDisplay.pop(for: TEST_ID) + let secondPop = RNSentryTimeToDisplay.pop(for: TEST_ID) + + XCTAssert(firstPop == TEST_VAL) + XCTAssertNil(secondPop) + } + + func testRemovesOldestEntryWhenFull() { + let maxSize = TIME_TO_DISPLAY_ENTRIES_MAX_SIZE + 1 + for i in 1...maxSize { + RNSentryTimeToDisplay.put(for: "\(TEST_ID)-\(i)", value: NSNumber(value: i)) + } + + let oldestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-1") + let secondOldestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-2") + let newestEntry = RNSentryTimeToDisplay.pop(for: "\(TEST_ID)-\(maxSize)") + + XCTAssertNil(oldestEntry) + XCTAssertNotNil(secondOldestEntry) + XCTAssertNotNil(newestEntry) + } + + func testHandlesEarlyPoppedValues() { + let maxSize = TIME_TO_DISPLAY_ENTRIES_MAX_SIZE + 1 + for i in 1...maxSize { + let key = "\(TEST_ID)-\(i)" + RNSentryTimeToDisplay.put(for: key, value: NSNumber(value: i)) + RNSentryTimeToDisplay.pop(for: key) + } + + // Age counter reached the max size, but storage is empty + // The internal structures should handle the situation + + let nextKey1 = "\(TEST_ID)-next-1" + let nextKey2 = "\(TEST_ID)-next-2" + let nextVal1 = NSNumber(value: 123.4) + let nextVal2 = NSNumber(value: 567.8) + RNSentryTimeToDisplay.put(for: nextKey1, value: nextVal1) + RNSentryTimeToDisplay.put(for: nextKey2, value: nextVal2) + + let nextActualVal1 = RNSentryTimeToDisplay.pop(for: nextKey1) + let nextActualVal2 = RNSentryTimeToDisplay.pop(for: nextKey2) + + XCTAssertEqual(nextVal1, nextActualVal1) + XCTAssertEqual(nextVal2, nextActualVal2) + + XCTAssertNil(RNSentryTimeToDisplay.pop(for: nextKey1)) + XCTAssertNil(RNSentryTimeToDisplay.pop(for: nextKey2)) + } +} diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index c96506fb3c..292f7f6417 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -54,5 +54,5 @@ android { dependencies { implementation 'com.facebook.react:react-native:+' - api 'io.sentry:sentry-android:7.22.0' + api 'io.sentry:sentry-android:7.22.5' } 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 52587e7cb8..39e206d678 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 @@ -12,6 +12,7 @@ import android.content.res.AssetManager; import android.net.Uri; import android.util.SparseIntArray; +import androidx.annotation.VisibleForTesting; import androidx.core.app.FrameMetricsAggregator; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; @@ -19,6 +20,7 @@ import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.UiThreadUtil; @@ -26,7 +28,6 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.modules.core.DeviceEventManagerModule; import io.sentry.Breadcrumb; import io.sentry.HubAdapter; import io.sentry.ILogger; @@ -74,6 +75,7 @@ import java.util.concurrent.CountDownLatch; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; public class RNSentryModuleImpl { @@ -89,7 +91,7 @@ public class RNSentryModuleImpl { private FrameMetricsAggregator frameMetricsAggregator = null; private boolean androidXAvailable; - private static boolean hasFetchedAppStart; + @VisibleForTesting static long lastStartTimestampMs = -1; // 700ms to constitute frozen frames. private static final int FROZEN_FRAME_THRESHOLD = 700; @@ -137,11 +139,7 @@ private ReactApplicationContext getReactApplicationContext() { private @NotNull Runnable createEmitNewFrameEvent() { return () -> { final SentryDate endDate = dateProvider.now(); - WritableMap event = Arguments.createMap(); - event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9); - getReactApplicationContext() - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) - .emit("rn_sentry_new_frame", event); + RNSentryTimeToDisplay.putTimeToInitialDisplayForActiveSpan(endDate.nanoTimestamp() / 1e9); }; } @@ -165,11 +163,22 @@ public void initNativeReactNavigationNewFrameTracking(Promise promise) { public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { RNSentryStart.startWithOptions( - this.getReactApplicationContext(), rnOptions, getCurrentActivity(), logger); + getApplicationContext(), rnOptions, getCurrentActivity(), logger); promise.resolve(true); } + @TestOnly + protected Context getApplicationContext() { + final Context context = this.getReactApplicationContext().getApplicationContext(); + if (context == null) { + logger.log( + SentryLevel.ERROR, "ApplicationContext is null, using ReactApplicationContext fallback."); + return this.getReactApplicationContext(); + } + return context; + } + public void crash() { throw new RuntimeException("TEST - Sentry Client Crash (only works in release mode)"); } @@ -212,31 +221,44 @@ public void fetchNativeRelease(Promise promise) { public void fetchNativeAppStart(Promise promise) { fetchNativeAppStart( - promise, - InternalSentrySdk.getAppStartMeasurement(), - logger, - AppStartMetrics.getInstance().isAppLaunchedInForeground()); + promise, AppStartMetrics.getInstance(), InternalSentrySdk.getAppStartMeasurement(), logger); } protected void fetchNativeAppStart( Promise promise, - final Map appStartMeasurement, - ILogger logger, - boolean isAppLaunchedInForeground) { - if (!isAppLaunchedInForeground) { + final AppStartMetrics metrics, + final Map metricsDataBag, + ILogger logger) { + if (!metrics.isAppLaunchedInForeground()) { logger.log(SentryLevel.WARNING, "Invalid app start data: app not launched in foreground."); promise.resolve(null); return; } WritableMap mutableMeasurement = - (WritableMap) RNSentryMapConverter.convertToWritable(appStartMeasurement); - mutableMeasurement.putBoolean("has_fetched", hasFetchedAppStart); + (WritableMap) RNSentryMapConverter.convertToWritable(metricsDataBag); + + long currentStartTimestampMs = metrics.getAppStartTimeSpan().getStartTimestampMs(); + boolean hasFetched = + lastStartTimestampMs > 0 && lastStartTimestampMs == currentStartTimestampMs; + mutableMeasurement.putBoolean("has_fetched", hasFetched); + + if (lastStartTimestampMs < 0) { + logger.log(SentryLevel.DEBUG, "App Start data reported to the RN layer for the first time."); + } else if (hasFetched) { + logger.log(SentryLevel.DEBUG, "App Start data already fetched from native before."); + } else { + logger.log(SentryLevel.DEBUG, "App Start data updated, reporting to the RN layer again."); + } + + // When activity is destroyed but the application process is kept alive + // the next activity creation is considered warm start. + // The app start metrics will be updated by the the Android SDK. + // To let the RN JS layer know these are new start data we compare the start timestamps. + lastStartTimestampMs = currentStartTimestampMs; - // This is always set to true, as we would only allow an app start fetch to only - // happen once in the case of a JS bundle reload, we do not want it to be - // instrumented again. - hasFetchedAppStart = true; + // Clears start metrics, making them ready for recording warm app start + metrics.onAppStartSpansSent(); promise.resolve(mutableMeasurement); } @@ -483,6 +505,19 @@ public void clearBreadcrumbs() { }); } + public void popTimeToDisplayFor(String screenId, Promise promise) { + if (screenId != null) { + promise.resolve(RNSentryTimeToDisplay.popTimeToDisplayFor(screenId)); + } else { + promise.resolve(null); + } + } + + public boolean setActiveSpanId(@Nullable String spanId) { + RNSentryTimeToDisplay.setActiveSpanId(spanId); + return true; // The return ensure RN executes the code synchronously + } + public void setExtra(String key, String extra) { if (key == null || extra == null) { logger.log( @@ -785,6 +820,15 @@ public void getDataFromUri(String uri, Promise promise) { } } + public void encodeToBase64(ReadableArray array, Promise promise) { + byte[] bytes = new byte[array.size()]; + for (int i = 0; i < array.size(); i++) { + bytes[i] = (byte) array.getInt(i); + } + String base64String = android.util.Base64.encodeToString(bytes, android.util.Base64.DEFAULT); + promise.resolve(base64String); + } + public void crashedLastRun(Promise promise) { promise.resolve(Sentry.isCrashedLastRun()); } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java index a412ab51b5..ce7497db7c 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryOnDrawReporterManager.java @@ -3,30 +3,29 @@ import android.app.Activity; import android.content.Context; import android.view.View; -import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.common.MapBuilder; import com.facebook.react.uimanager.SimpleViewManager; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.annotations.ReactProp; -import com.facebook.react.uimanager.events.RCTEventEmitter; import io.sentry.ILogger; -import io.sentry.SentryDate; import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import io.sentry.android.core.AndroidLogger; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.SentryAndroidDateProvider; import io.sentry.android.core.internal.util.FirstDrawDoneListener; -import java.util.Map; +import io.sentry.react.utils.RNSentryActivityUtils; +import java.util.Objects; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; public class RNSentryOnDrawReporterManager extends SimpleViewManager { public static final String REACT_CLASS = "RNSentryOnDrawReporter"; + public static final String TTID_PREFIX = "ttid-"; + public static final String TTFD_PREFIX = "ttfd-"; private final @NotNull ReactApplicationContext mCallerContext; public RNSentryOnDrawReporterManager(ReactApplicationContext reactContext) { @@ -57,12 +56,9 @@ public void setFullDisplay(RNSentryOnDrawReporterView view, boolean fullDisplay) view.setFullDisplay(fullDisplay); } - public Map getExportedCustomBubblingEventTypeConstants() { - return MapBuilder.builder() - .put( - "onDrawNextFrameView", - MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onDrawNextFrame"))) - .build(); + @ReactProp(name = "parentSpanId") + public void setParentSpanId(RNSentryOnDrawReporterView view, String parentSpanId) { + view.setParentSpanId(parentSpanId); } public static class RNSentryOnDrawReporterView extends View { @@ -71,16 +67,17 @@ public static class RNSentryOnDrawReporterView extends View { private final @Nullable ReactApplicationContext reactContext; private final @NotNull SentryDateProvider dateProvider = new SentryAndroidDateProvider(); - private final @Nullable Runnable emitInitialDisplayEvent; - private final @Nullable Runnable emitFullDisplayEvent; private final @Nullable BuildInfoProvider buildInfo; + private boolean isInitialDisplay = false; + private boolean isFullDisplay = false; + private boolean spanIdUsed = false; + private @Nullable String parentSpanId = null; + public RNSentryOnDrawReporterView(@NotNull Context context) { super(context); reactContext = null; buildInfo = null; - emitInitialDisplayEvent = null; - emitFullDisplayEvent = null; } public RNSentryOnDrawReporterView( @@ -88,35 +85,60 @@ public RNSentryOnDrawReporterView( super(context); reactContext = context; buildInfo = buildInfoProvider; - emitInitialDisplayEvent = () -> emitDisplayEvent("initialDisplay"); - emitFullDisplayEvent = () -> emitDisplayEvent("fullDisplay"); } - public void setFullDisplay(boolean fullDisplay) { - if (!fullDisplay) { - return; - } + @TestOnly + public RNSentryOnDrawReporterView( + @NotNull Context context, + @NotNull ReactApplicationContext reactContext, + @NotNull BuildInfoProvider buildInfoProvider) { + super(context); + this.reactContext = reactContext; + buildInfo = buildInfoProvider; + } - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter."); - registerForNextDraw(emitFullDisplayEvent); + public void setFullDisplay(boolean newIsFullDisplay) { + if (newIsFullDisplay != isFullDisplay) { + isFullDisplay = newIsFullDisplay; + processPropsChanged(); + } } - public void setInitialDisplay(boolean initialDisplay) { - if (!initialDisplay) { - return; + public void setInitialDisplay(boolean newIsInitialDisplay) { + if (newIsInitialDisplay != isInitialDisplay) { + isInitialDisplay = newIsInitialDisplay; + processPropsChanged(); } + } - logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); - registerForNextDraw(emitInitialDisplayEvent); + public void setParentSpanId(@Nullable String newParentSpanId) { + if (!Objects.equals(newParentSpanId, parentSpanId)) { + parentSpanId = newParentSpanId; + spanIdUsed = false; + processPropsChanged(); + } } - private void registerForNextDraw(@Nullable Runnable emitter) { - if (emitter == null) { + private void processPropsChanged() { + if (parentSpanId == null) { + return; + } + if (spanIdUsed) { logger.log( - SentryLevel.ERROR, - "[TimeToDisplay] Won't emit next frame drawn event, emitter is null."); + SentryLevel.DEBUG, + "[TimeToDisplay] Already recorded time to display for spanId: " + parentSpanId); + return; + } + + if (isInitialDisplay) { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register initial display event emitter."); + } else if (isFullDisplay) { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Register full display event emitter."); + } else { + logger.log(SentryLevel.DEBUG, "[TimeToDisplay] Not ready, missing displayType prop."); return; } + if (buildInfo == null) { logger.log( SentryLevel.ERROR, @@ -130,34 +152,45 @@ private void registerForNextDraw(@Nullable Runnable emitter) { return; } - @Nullable Activity activity = reactContext.getCurrentActivity(); + final @Nullable Activity activity = + RNSentryActivityUtils.getCurrentActivity(reactContext, logger); if (activity == null) { logger.log( SentryLevel.ERROR, - "[TimeToDisplay] Won't emit next frame drawn event, reactContext is null."); + "[TimeToDisplay] Won't emit next frame drawn event, activity is null."); return; } - FirstDrawDoneListener.registerForNextDraw(activity, emitter, buildInfo); + spanIdUsed = true; + registerForNextDraw( + activity, + () -> { + final Double now = dateProvider.now().nanoTimestamp() / 1e9; + if (parentSpanId == null) { + logger.log( + SentryLevel.ERROR, + "[TimeToDisplay] parentSpanId removed before frame was rendered."); + return; + } + + if (isInitialDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor(TTID_PREFIX + parentSpanId, now); + } else if (isFullDisplay) { + RNSentryTimeToDisplay.putTimeToDisplayFor(TTFD_PREFIX + parentSpanId, now); + } else { + logger.log( + SentryLevel.DEBUG, + "[TimeToDisplay] display type removed before frame was rendered."); + } + }, + buildInfo); } - private void emitDisplayEvent(String type) { - final SentryDate endDate = dateProvider.now(); - - WritableMap event = Arguments.createMap(); - event.putString("type", type); - event.putDouble("newFrameTimestampInSeconds", endDate.nanoTimestamp() / 1e9); - - if (reactContext == null) { - logger.log( - SentryLevel.ERROR, - "[TimeToDisplay] Recorded next frame draw but can't emit the event, reactContext is" - + " null."); - return; - } - reactContext - .getJSModule(RCTEventEmitter.class) - .receiveEvent(getId(), "onDrawNextFrameView", event); + protected void registerForNextDraw( + final @NotNull Activity activity, + final @NotNull Runnable callback, + final @NotNull BuildInfoProvider buildInfo) { + FirstDrawDoneListener.registerForNextDraw(activity, callback, buildInfo); } } } diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java index b6fab45492..1ef7c275bf 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryTimeToDisplay.java @@ -6,11 +6,48 @@ import com.facebook.react.bridge.Promise; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jetbrains.annotations.Nullable; public final class RNSentryTimeToDisplay { private RNSentryTimeToDisplay() {} + public static final int ENTRIES_MAX_SIZE = 50; + private static final Map screenIdToRenderDuration = + new LinkedHashMap(ENTRIES_MAX_SIZE + 1, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > ENTRIES_MAX_SIZE; + } + }; + + /** + * The active span id that is used to attribute the time to display to the active span in case of + * a screen navigation where native time to display is not available to assign the span id + * received from JS. + */ + private static @Nullable String activeSpanId = null; + + public static void setActiveSpanId(@Nullable String spanId) { + activeSpanId = spanId; + } + + public static Double popTimeToDisplayFor(String screenId) { + return screenIdToRenderDuration.remove(screenId); + } + + public static void putTimeToInitialDisplayForActiveSpan(Double value) { + if (activeSpanId != null) { + putTimeToDisplayFor("ttid-navigation-" + activeSpanId, value); + } + } + + public static void putTimeToDisplayFor(String screenId, Double value) { + screenIdToRenderDuration.put(screenId, value); + } + public static void getTimeToDisplay(Promise promise, SentryDateProvider dateProvider) { Looper mainLooper = Looper.getMainLooper(); diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java index 749c271605..a2527f94a8 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryVersion.java @@ -2,7 +2,7 @@ class RNSentryVersion { static final String REACT_NATIVE_SDK_PACKAGE_NAME = "npm:@sentry/react-native"; - static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.8.0"; + static final String REACT_NATIVE_SDK_PACKAGE_VERSION = "6.15.1"; static final String NATIVE_SDK_NAME = "sentry.native.android.react-native"; static final String ANDROID_SDK_NAME = "sentry.java.android.react-native"; static final String REACT_NATIVE_SDK_NAME = "sentry.javascript.react-native"; diff --git a/packages/core/android/src/main/java/io/sentry/react/utils/RNSentryActivityUtils.java b/packages/core/android/src/main/java/io/sentry/react/utils/RNSentryActivityUtils.java new file mode 100644 index 0000000000..c63ab29553 --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/utils/RNSentryActivityUtils.java @@ -0,0 +1,31 @@ +package io.sentry.react.utils; + +import android.app.Activity; +import com.facebook.react.bridge.ReactApplicationContext; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.CurrentActivityHolder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Utility class for React Native Activity related functionality. */ +public final class RNSentryActivityUtils { + + private RNSentryActivityUtils() { + // Prevent instantiation + } + + public static @Nullable Activity getCurrentActivity( + final @NotNull ReactApplicationContext reactContext, final @NotNull ILogger logger) { + final Activity activity = reactContext.getCurrentActivity(); + if (activity != null) { + return activity; + } + + logger.log( + SentryLevel.DEBUG, + "[RNSentryActivityUtils] Given ReactApplicationContext has no activity attached, using" + + " CurrentActivityHolder as a fallback."); + return CurrentActivityHolder.getInstance().getActivity(); + } +} diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index 92ef2c0614..5b14f05c92 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -182,4 +182,19 @@ public void getNewScreenTimeToDisplay(Promise promise) { public void getDataFromUri(String uri, Promise promise) { this.impl.getDataFromUri(uri, promise); } + + @Override + public void encodeToBase64(ReadableArray array, Promise promise) { + this.impl.encodeToBase64(array, promise); + } + + @Override + public void popTimeToDisplayFor(String key, Promise promise) { + this.impl.popTimeToDisplayFor(key, promise); + } + + @Override + public boolean setActiveSpanId(String spanId) { + return this.impl.setActiveSpanId(spanId); + } } diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 7896d45fde..103afeb890 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -152,11 +152,6 @@ public String fetchNativePackageName() { return this.impl.fetchNativePackageName(); } - @ReactMethod - public void getDataFromUri(String uri, Promise promise) { - this.impl.getDataFromUri(uri, promise); - } - @ReactMethod(isBlockingSynchronousMethod = true) public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) { // Not used on Android @@ -178,8 +173,28 @@ public void crashedLastRun(Promise promise) { this.impl.crashedLastRun(promise); } - @ReactMethod() + @ReactMethod public void getNewScreenTimeToDisplay(Promise promise) { this.impl.getNewScreenTimeToDisplay(promise); } + + @ReactMethod + public void getDataFromUri(String uri, Promise promise) { + this.impl.getDataFromUri(uri, promise); + } + + @ReactMethod + public void encodeToBase64(ReadableArray array, Promise promise) { + this.impl.encodeToBase64(array, promise); + } + + @ReactMethod + public void popTimeToDisplayFor(String key, Promise promise) { + this.impl.popTimeToDisplayFor(key, promise); + } + + @ReactMethod + public boolean setActiveSpanId(String spanId) { + return this.impl.setActiveSpanId(spanId); + } } diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index fa6654a563..7b479e1d53 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -121,13 +121,8 @@ - (instancetype)init - (void)initFramesTracking { #if SENTRY_HAS_UIKIT - RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { - if (self->hasListeners) { - [self - sendEventWithName:RNSentryNewFrameEvent - body:@{ @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds }]; - } + [RNSentryTimeToDisplay putTimeToInitialDisplayForActiveSpan:newFrameTimestampInSeconds]; }; [[RNSentryDependencyContainer sharedInstance] initializeFramesTrackerListenerWith:emitNewFrameEvent]; @@ -794,4 +789,42 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys [_timeToDisplay getTimeToDisplay:resolve]; } +RCT_EXPORT_METHOD(popTimeToDisplayFor + : (NSString *)key resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + resolve([RNSentryTimeToDisplay popTimeToDisplayFor:key]); +} + +RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, setActiveSpanId : (NSString *)spanId) +{ + [RNSentryTimeToDisplay setActiveSpanId:spanId]; + return @YES; // The return ensures that the method is synchronous +} + +RCT_EXPORT_METHOD(encodeToBase64 + : (NSArray *)array resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) +{ + NSUInteger count = array.count; + uint8_t *bytes = (uint8_t *)malloc(count); + + if (!bytes) { + reject(@"encodeToBase64", @"Memory allocation failed", nil); + return; + } + + for (NSUInteger i = 0; i < count; i++) { + bytes[i] = (uint8_t)[array[i] unsignedCharValue]; + } + + NSData *data = [NSData dataWithBytes:bytes length:count]; + free(bytes); + + NSString *base64String = [data base64EncodedStringWithOptions:0]; + resolve(base64String); +} + @end diff --git a/packages/core/ios/RNSentryBreadcrumb.m b/packages/core/ios/RNSentryBreadcrumb.m index db9ffce2d6..cc77dce70a 100644 --- a/packages/core/ios/RNSentryBreadcrumb.m +++ b/packages/core/ios/RNSentryBreadcrumb.m @@ -39,7 +39,8 @@ + (SentryBreadcrumb *)from:(NSDictionary *)dict + (NSString *_Nullable)getCurrentScreenFrom:(NSDictionary *_Nonnull)dict { NSString *_Nullable maybeCategory = [dict valueForKey:@"category"]; - if (![maybeCategory isEqualToString:@"navigation"]) { + if ([maybeCategory isKindOfClass:[NSString class]] + && ![maybeCategory isEqualToString:@"navigation"]) { return nil; } diff --git a/packages/core/ios/RNSentryFramesTrackerListener.h b/packages/core/ios/RNSentryFramesTrackerListener.h index e0de09dfd9..627b3059f4 100644 --- a/packages/core/ios/RNSentryFramesTrackerListener.h +++ b/packages/core/ios/RNSentryFramesTrackerListener.h @@ -8,13 +8,17 @@ typedef void (^RNSentryEmitNewFrameEvent)(NSNumber *newFrameTimestampInSeconds); -@interface RNSentryFramesTrackerListener : NSObject +@protocol RNSentryFramesTrackerListenerProtocol + +- (void)startListening; + +@end + +@interface RNSentryFramesTrackerListener : NSObject - (instancetype)initWithSentryFramesTracker:(SentryFramesTracker *)framesTracker andEventEmitter:(RNSentryEmitNewFrameEvent)emitNewFrameEvent; -- (void)startListening; - @property (strong, nonatomic) SentryFramesTracker *framesTracker; @property (strong, nonatomic) RNSentryEmitNewFrameEvent emitNewFrameEvent; diff --git a/packages/core/ios/RNSentryOnDrawReporter.h b/packages/core/ios/RNSentryOnDrawReporter.h index 1cf9fb6245..5c4083015d 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.h +++ b/packages/core/ios/RNSentryOnDrawReporter.h @@ -12,11 +12,15 @@ @interface RNSentryOnDrawReporterView : UIView -@property (nonatomic, strong) RNSentryFramesTrackerListener *framesListener; -@property (nonatomic, copy) RCTBubblingEventBlock onDrawNextFrame; +@property (nonatomic, strong) id framesListener; @property (nonatomic) bool fullDisplay; @property (nonatomic) bool initialDisplay; +@property (nonatomic) bool spanIdUsed; +@property (nonatomic, copy) NSString *parentSpanId; @property (nonatomic, weak) RNSentryOnDrawReporter *delegate; +@property (nonatomic) bool previousFullDisplay; +@property (nonatomic) bool previousInitialDisplay; +@property (nonatomic, copy) NSString *previousParentSpanId; @end diff --git a/packages/core/ios/RNSentryOnDrawReporter.m b/packages/core/ios/RNSentryOnDrawReporter.m index d8266c73a5..dc11bc9c51 100644 --- a/packages/core/ios/RNSentryOnDrawReporter.m +++ b/packages/core/ios/RNSentryOnDrawReporter.m @@ -1,4 +1,5 @@ #import "RNSentryOnDrawReporter.h" +#import "RNSentryTimeToDisplay.h" #if SENTRY_HAS_UIKIT @@ -7,9 +8,9 @@ @implementation RNSentryOnDrawReporter RCT_EXPORT_MODULE(RNSentryOnDrawReporter) -RCT_EXPORT_VIEW_PROPERTY(onDrawNextFrame, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(initialDisplay, BOOL) RCT_EXPORT_VIEW_PROPERTY(fullDisplay, BOOL) +RCT_EXPORT_VIEW_PROPERTY(parentSpanId, NSString) - (UIView *)view { @@ -19,29 +20,16 @@ - (UIView *)view @end -@implementation RNSentryOnDrawReporterView +@implementation RNSentryOnDrawReporterView { + BOOL isListening; +} - (instancetype)init { self = [super init]; if (self) { - RNSentryEmitNewFrameEvent emitNewFrameEvent = ^(NSNumber *newFrameTimestampInSeconds) { - if (self->_fullDisplay) { - self.onDrawNextFrame(@{ - @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds, - @"type" : @"fullDisplay" - }); - return; - } - - if (self->_initialDisplay) { - self.onDrawNextFrame(@{ - @"newFrameTimestampInSeconds" : newFrameTimestampInSeconds, - @"type" : @"initialDisplay" - }); - return; - } - }; + _spanIdUsed = NO; + RNSentryEmitNewFrameEvent emitNewFrameEvent = [self createEmitNewFrameEvent]; _framesListener = [[RNSentryFramesTrackerListener alloc] initWithSentryFramesTracker:[[SentryDependencyContainer sharedInstance] framesTracker] andEventEmitter:emitNewFrameEvent]; @@ -49,10 +37,53 @@ - (instancetype)init return self; } +- (RNSentryEmitNewFrameEvent)createEmitNewFrameEvent +{ + return ^(NSNumber *newFrameTimestampInSeconds) { + self->isListening = NO; + + if (![_parentSpanId isKindOfClass:[NSString class]]) { + return; + } + + if (self->_fullDisplay) { + [RNSentryTimeToDisplay + putTimeToDisplayFor:[@"ttfd-" stringByAppendingString:self->_parentSpanId] + value:newFrameTimestampInSeconds]; + return; + } + + if (self->_initialDisplay) { + [RNSentryTimeToDisplay + putTimeToDisplayFor:[@"ttid-" stringByAppendingString:self->_parentSpanId] + value:newFrameTimestampInSeconds]; + return; + } + }; +} + - (void)didSetProps:(NSArray *)changedProps { + if (![_parentSpanId isKindOfClass:[NSString class]]) { + _previousParentSpanId = nil; + return; + } + + if ([_parentSpanId isEqualToString:_previousParentSpanId] && _spanIdUsed) { + _previousInitialDisplay = _initialDisplay; + _previousFullDisplay = _fullDisplay; + return; + } + + _previousParentSpanId = _parentSpanId; + _spanIdUsed = NO; + if (_fullDisplay || _initialDisplay) { - [_framesListener startListening]; + if (!isListening && !_spanIdUsed) { + _spanIdUsed = YES; + isListening = YES; + [_framesListener startListening]; + } } } diff --git a/packages/core/ios/RNSentryReplay.mm b/packages/core/ios/RNSentryReplay.mm index b43fae4a49..994ec36189 100644 --- a/packages/core/ios/RNSentryReplay.mm +++ b/packages/core/ios/RNSentryReplay.mm @@ -27,6 +27,8 @@ + (void)updateOptions:(NSMutableDictionary *)options @"errorSampleRate" : options[@"replaysOnErrorSampleRate"] ?: [NSNull null], @"maskAllImages" : replayOptions[@"maskAllImages"] ?: [NSNull null], @"maskAllText" : replayOptions[@"maskAllText"] ?: [NSNull null], + @"enableViewRendererV2" : replayOptions[@"enableViewRendererV2"] ?: [NSNull null], + @"enableFastViewRendering" : replayOptions[@"enableFastViewRendering"] ?: [NSNull null], @"maskedViewClasses" : [RNSentryReplay getReplayRNRedactClasses:replayOptions], @"sdkInfo" : @ { @"name" : REACT_NATIVE_SDK_NAME, @"version" : REACT_NATIVE_SDK_PACKAGE_VERSION } diff --git a/packages/core/ios/RNSentryTimeToDisplay.h b/packages/core/ios/RNSentryTimeToDisplay.h index fbb468cb23..4b749eddc8 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.h +++ b/packages/core/ios/RNSentryTimeToDisplay.h @@ -1,7 +1,14 @@ #import +static const int TIME_TO_DISPLAY_ENTRIES_MAX_SIZE = 50; + @interface RNSentryTimeToDisplay : NSObject ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId; ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value; ++ (void)setActiveSpanId:(NSString *)spanId; ++ (void)putTimeToInitialDisplayForActiveSpan:(NSNumber *)timestampSeconds; + - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback; @end diff --git a/packages/core/ios/RNSentryTimeToDisplay.m b/packages/core/ios/RNSentryTimeToDisplay.m index 9404cf5088..41044b347c 100644 --- a/packages/core/ios/RNSentryTimeToDisplay.m +++ b/packages/core/ios/RNSentryTimeToDisplay.m @@ -7,6 +7,74 @@ @implementation RNSentryTimeToDisplay { RCTResponseSenderBlock resolveBlock; } +static NSMutableDictionary *screenIdToRenderDuration; +static NSMutableArray *screenIdAge; +static NSUInteger screenIdCurrentIndex; + +static NSString *activeSpanId; + ++ (void)initialize +{ + if (self == [RNSentryTimeToDisplay class]) { + screenIdToRenderDuration = + [[NSMutableDictionary alloc] initWithCapacity:TIME_TO_DISPLAY_ENTRIES_MAX_SIZE]; + screenIdAge = [[NSMutableArray alloc] initWithCapacity:TIME_TO_DISPLAY_ENTRIES_MAX_SIZE]; + screenIdCurrentIndex = 0; + + activeSpanId = nil; + } +} + ++ (void)setActiveSpanId:(NSString *)spanId +{ + activeSpanId = spanId; +} + ++ (NSNumber *)popTimeToDisplayFor:(NSString *)screenId +{ + NSNumber *value = screenIdToRenderDuration[screenId]; + [screenIdToRenderDuration removeObjectForKey:screenId]; + return value; +} + ++ (void)putTimeToInitialDisplayForActiveSpan:(NSNumber *)value +{ + if (activeSpanId != nil) { + NSString *prefixedSpanId = [@"ttid-navigation-" stringByAppendingString:activeSpanId]; + [self putTimeToDisplayFor:prefixedSpanId value:value]; + } +} + ++ (void)putTimeToDisplayFor:(NSString *)screenId value:(NSNumber *)value +{ + if (!screenId) + return; + + // If key already exists, just update the value, + // this should never happen as TTD is recorded once per navigation + // We avoid updating the age to avoid the age array shift + if ([screenIdToRenderDuration objectForKey:screenId]) { + [screenIdToRenderDuration setObject:value forKey:screenId]; + return; + } + + // If we haven't reached capacity yet, just append + if (screenIdAge.count < TIME_TO_DISPLAY_ENTRIES_MAX_SIZE) { + [screenIdToRenderDuration setObject:value forKey:screenId]; + [screenIdAge addObject:screenId]; + } else { + // Remove oldest entry, in most case will already be removed by pop + NSString *oldestKey = screenIdAge[screenIdCurrentIndex]; + [screenIdToRenderDuration removeObjectForKey:oldestKey]; + + [screenIdToRenderDuration setObject:value forKey:screenId]; + screenIdAge[screenIdCurrentIndex] = screenId; + + // Update circular index, point to the new oldest + screenIdCurrentIndex = (screenIdCurrentIndex + 1) % TIME_TO_DISPLAY_ENTRIES_MAX_SIZE; + } +} + // Rename requestAnimationFrame to getTimeToDisplay - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback { @@ -26,8 +94,7 @@ - (void)getTimeToDisplay:(RCTResponseSenderBlock)callback - (void)handleDisplayLink:(CADisplayLink *)link { // Get the current time - NSTimeInterval currentTime = - [[NSDate date] timeIntervalSince1970] * 1000.0; // Convert to milliseconds + NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970]; // Ensure the callback is valid and pass the current time back if (resolveBlock) { diff --git a/packages/core/ios/RNSentryVersion.m b/packages/core/ios/RNSentryVersion.m index ac2aeaaa2f..d868d3b7a6 100644 --- a/packages/core/ios/RNSentryVersion.m +++ b/packages/core/ios/RNSentryVersion.m @@ -3,4 +3,4 @@ NSString *const NATIVE_SDK_NAME = @"sentry.cocoa.react-native"; NSString *const REACT_NATIVE_SDK_NAME = @"sentry.javascript.react-native"; NSString *const REACT_NATIVE_SDK_PACKAGE_NAME = @"npm:@sentry/react-native"; -NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.8.0"; +NSString *const REACT_NATIVE_SDK_PACKAGE_VERSION = @"6.15.1"; diff --git a/packages/core/package.json b/packages/core/package.json index 13389eb819..489784211d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@sentry/react-native", "homepage": "https://github.com/getsentry/sentry-react-native", "repository": "https://github.com/getsentry/sentry-react-native", - "version": "6.8.0", + "version": "6.15.1", "description": "Official Sentry SDK for react-native", "typings": "dist/js/index.d.ts", "types": "dist/js/index.d.ts", @@ -65,9 +65,9 @@ "react-native": ">=0.65.0" }, "dependencies": { - "@sentry/babel-plugin-component-annotate": "3.2.0", + "@sentry/babel-plugin-component-annotate": "3.5.0", "@sentry/browser": "8.54.0", - "@sentry/cli": "2.42.1", + "@sentry/cli": "2.46.0", "@sentry/core": "8.54.0", "@sentry/react": "8.54.0", "@sentry/types": "8.54.0", @@ -75,13 +75,13 @@ }, "devDependencies": { "@babel/core": "^7.25.2", - "@expo/metro-config": "0.19.5", + "@expo/metro-config": "~0.20.0", "@mswjs/interceptors": "^0.25.15", "@react-native/babel-preset": "0.77.1", "@sentry-internal/eslint-config-sdk": "8.54.0", "@sentry-internal/eslint-plugin-sdk": "8.54.0", "@sentry-internal/typescript": "8.54.0", - "@sentry/wizard": "3.42.1", + "@sentry/wizard": "5.1.0", "@testing-library/react-native": "^12.7.2", "@types/jest": "^29.5.13", "@types/node": "^20.9.3", @@ -98,7 +98,7 @@ "eslint": "^7.6.0", "eslint-plugin-react": "^7.20.6", "eslint-plugin-react-native": "^3.8.1", - "expo": "^52.0.0", + "expo": "^53.0.0", "expo-module-scripts": "3.1.0", "jest": "^29.6.2", "jest-environment-jsdom": "^29.6.2", diff --git a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts index b4df682137..27a9a4d904 100644 --- a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts +++ b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts @@ -84,13 +84,13 @@ export function withSentryAndroidGradlePlugin( const sentryPlugin = `apply plugin: "io.sentry.android.gradle"`; const sentryConfig = ` sentry { - autoUploadProguardMapping = ${autoUploadProguardMapping} + autoUploadProguardMapping = ${autoUploadProguardMapping ? 'shouldSentryAutoUpload()' : 'false'} includeProguardMapping = ${includeProguardMapping} dexguardEnabled = ${dexguardEnabled} - uploadNativeSymbols = ${uploadNativeSymbols} - autoUploadNativeSymbols = ${autoUploadNativeSymbols} + uploadNativeSymbols = ${uploadNativeSymbols ? 'shouldSentryAutoUpload()' : 'false'} + autoUploadNativeSymbols = ${autoUploadNativeSymbols ? 'shouldSentryAutoUpload()' : 'false'} includeNativeSources = ${includeNativeSources} - includeSourceContext = ${includeSourceContext} + includeSourceContext = ${includeSourceContext ? 'shouldSentryAutoUpload()' : 'false'} tracingInstrumentation { enabled = false } diff --git a/packages/core/scripts/sentry-xcode-debug-files.sh b/packages/core/scripts/sentry-xcode-debug-files.sh index dc6e48aed7..29e8e3967c 100755 --- a/packages/core/scripts/sentry-xcode-debug-files.sh +++ b/packages/core/scripts/sentry-xcode-debug-files.sh @@ -5,7 +5,10 @@ # print commands before executing them set -x -[ -z "$WITH_ENVIRONMENT" ] && WITH_ENVIRONMENT="../node_modules/react-native/scripts/xcode/with-environment.sh" +# REACT_NATIVE_PATH first used in RN 0.74.0 Template https://github.com/facebook/react-native/commit/289e78388a87408e215a25108cb02511a05f5c80 +LOCAL_REACT_NATIVE_PATH="${REACT_NATIVE_PATH:-"../node_modules/react-native"}" + +[ -z "$WITH_ENVIRONMENT" ] && WITH_ENVIRONMENT="${LOCAL_REACT_NATIVE_PATH}/scripts/xcode/with-environment.sh" if [ -f "$WITH_ENVIRONMENT" ]; then # load envs if loader file exists (since rn 0.68) @@ -26,12 +29,9 @@ RN_PROJECT_ROOT="${PROJECT_DIR}/.." [ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_PACKAGE_PATH=$("$LOCAL_NODE_BINARY" --print "require('path').dirname(require.resolve('@sentry/cli/package.json'))") [ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_EXECUTABLE="${SENTRY_CLI_PACKAGE_PATH}/bin/sentry-cli" -[ -z "$SENTRY_FORCE_FOREGROUND"] && SENTRY_FORCE_FOREGROUND=true - -[[ "$SENTRY_FORCE_FOREGROUND" == true ]] && SENTRY_FORCE_FOREGROUND_FLAG="--force-foreground" [[ $SENTRY_INCLUDE_NATIVE_SOURCES == "true" ]] && INCLUDE_SOURCES_FLAG="--include-sources" || INCLUDE_SOURCES_FLAG="" -EXTRA_ARGS="$SENTRY_FORCE_FOREGROUND_FLAG $SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_DEBUG_FILES_UPLOAD_EXTRA_ARGS $INCLUDE_SOURCES_FLAG" +EXTRA_ARGS="$SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_DEBUG_FILES_UPLOAD_EXTRA_ARGS $INCLUDE_SOURCES_FLAG" UPLOAD_DEBUG_FILES="\"$SENTRY_CLI_EXECUTABLE\" debug-files upload $EXTRA_ARGS \"$DWARF_DSYM_FOLDER_PATH\"" diff --git a/packages/core/scripts/sentry-xcode.sh b/packages/core/scripts/sentry-xcode.sh index 336d393220..6d0764b90a 100755 --- a/packages/core/scripts/sentry-xcode.sh +++ b/packages/core/scripts/sentry-xcode.sh @@ -19,16 +19,15 @@ RN_PROJECT_ROOT="${PROJECT_DIR}/.." [ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_PACKAGE_PATH=$("$LOCAL_NODE_BINARY" --print "require('path').dirname(require.resolve('@sentry/cli/package.json'))") [ -z "$SENTRY_CLI_EXECUTABLE" ] && SENTRY_CLI_EXECUTABLE="${SENTRY_CLI_PACKAGE_PATH}/bin/sentry-cli" -[ -z "$SENTRY_FORCE_FOREGROUND"] && SENTRY_FORCE_FOREGROUND=true - REACT_NATIVE_XCODE=$1 -[[ "$SENTRY_FORCE_FOREGROUND" == true ]] && SENTRY_FORCE_FOREGROUND_FLAG="--force-foreground" [[ "$AUTO_RELEASE" == false ]] && [[ -z "$BUNDLE_COMMAND" || "$BUNDLE_COMMAND" != "ram-bundle" ]] && NO_AUTO_RELEASE="--no-auto-release" -ARGS="$SENTRY_FORCE_FOREGROUND_FLAG $NO_AUTO_RELEASE $SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_RN_XCODE_EXTRA_ARGS" +ARGS="$NO_AUTO_RELEASE $SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_RN_XCODE_EXTRA_ARGS" REACT_NATIVE_XCODE_WITH_SENTRY="\"$SENTRY_CLI_EXECUTABLE\" react-native xcode $ARGS \"$REACT_NATIVE_XCODE\"" +exitCode=0 + if [ "$SENTRY_DISABLE_AUTO_UPLOAD" != true ]; then # 'warning:' triggers a warning in Xcode, 'error:' triggers an error set +x +e # disable printing commands otherwise we might print `error:` by accident and allow continuing on error @@ -38,6 +37,7 @@ if [ "$SENTRY_DISABLE_AUTO_UPLOAD" != true ]; then else echo "error: sentry-cli - To disable source maps auto upload, set SENTRY_DISABLE_AUTO_UPLOAD=true in your environment variables. Or to allow failing upload, set SENTRY_ALLOW_FAILURE=true" echo "error: sentry-cli - $SENTRY_XCODE_COMMAND_OUTPUT" + exitCode=1 fi set -x -e # re-enable else @@ -70,3 +70,5 @@ if [ "$SENTRY_COPY_OPTIONS_FILE" = true ]; then echo "[Sentry] Copied $SENTRY_OPTIONS_FILE_PATH to $SENTRY_OPTIONS_FILE_DESTINATION_PATH" fi fi + +exit $exitCode diff --git a/packages/core/sentry.gradle b/packages/core/sentry.gradle index 990703527f..66f020d7bc 100644 --- a/packages/core/sentry.gradle +++ b/packages/core/sentry.gradle @@ -21,7 +21,7 @@ project.ext.shouldCopySentryOptionsFile = { -> // If not set, default to true def config = project.hasProperty("sentryCli") ? project.sentryCli : []; -def configFile = "sentry.options.json" // Sentry condiguration file +def configFile = "sentry.options.json" // Sentry configuration file def androidAssetsDir = new File("$rootDir/app/src/main/assets") // Path to Android assets folder tasks.register("copySentryJsonConfiguration") { @@ -57,7 +57,9 @@ tasks.register("cleanupTemporarySentryJsonConfiguration") { } } -gradle.projectsEvaluated { +// gradle.projectsEvaluated doesn't work with --configure-on-demand +// the task are create too late and not executed +project.afterEvaluate { // Add a task that copies the sentry.options.json file before the build starts tasks.named("preBuild").configure { dependsOn("copySentryJsonConfiguration") diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index 125dc3b082..5b00b62116 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -49,6 +49,9 @@ export interface Spec extends TurboModule { getCurrentReplayId(): string | undefined | null; crashedLastRun(): Promise; getDataFromUri(uri: string): Promise; + popTimeToDisplayFor(key: string): Promise; + setActiveSpanId(spanId: string): boolean; + encodeToBase64(data: number[]): Promise; } export type NativeStackFrame = { diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index d838167093..6fcad6e513 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -140,6 +140,26 @@ export class ReactNativeClient extends BaseClient { this._initNativeSdk(); } + /** + * Register a hook on this client. + * + * (Generic method signature to allow for custom React Native Client events.) + */ + public on(hook: string, callback: unknown): () => void { + // @ts-expect-error on from the base class doesn't support generic types + return super.on(hook, callback); + } + + /** + * Emit a hook that was previously registered via `on()`. + * + * (Generic method signature to allow for custom React Native Client events.) + */ + public emit(hook: string, ...rest: unknown[]): void { + // @ts-expect-error emit from the base class doesn't support generic types + super.emit(hook, ...rest); + } + /** * Starts native client with dsn and options */ @@ -165,6 +185,7 @@ export class ReactNativeClient extends BaseClient { ) .then((didCallNativeInit: boolean) => { this._options.onReady?.({ didCallNativeInit }); + this.emit('afterInit'); }) .then(undefined, error => { logger.error('The OnReady callback threw an error: ', error); diff --git a/packages/core/src/js/feedback/FeedbackButton.tsx b/packages/core/src/js/feedback/FeedbackButton.tsx new file mode 100644 index 0000000000..fbb546db8d --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackButton.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import type { NativeEventSubscription} from 'react-native'; +import { Appearance, Image, Text, TouchableOpacity } from 'react-native'; + +import { defaultButtonConfiguration } from './defaults'; +import { defaultButtonStyles } from './FeedbackWidget.styles'; +import { getTheme } from './FeedbackWidget.theme'; +import type { FeedbackButtonProps, FeedbackButtonStyles, FeedbackButtonTextConfiguration } from './FeedbackWidget.types'; +import { showFeedbackWidget } from './FeedbackWidgetManager'; +import { feedbackIcon } from './icons'; +import { lazyLoadFeedbackIntegration } from './lazy'; + +/** + * @beta + * Implements a feedback button that opens the FeedbackForm. + */ +export class FeedbackButton extends React.Component { + private _themeListener: NativeEventSubscription; + + public constructor(props: FeedbackButtonProps) { + super(props); + lazyLoadFeedbackIntegration(); + } + + /** + * Adds a listener for theme changes. + */ + public componentDidMount(): void { + this._themeListener = Appearance.addChangeListener(() => { + this.forceUpdate(); + }); + } + + /** + * Removes the theme listener. + */ + public componentWillUnmount(): void { + if (this._themeListener) { + this._themeListener.remove(); + } + } + + /** + * Renders the feedback button. + */ + public render(): React.ReactNode { + const theme = getTheme(); + const text: FeedbackButtonTextConfiguration = { ...defaultButtonConfiguration, ...this.props }; + const styles: FeedbackButtonStyles = { + triggerButton: { ...defaultButtonStyles(theme).triggerButton, ...this.props.styles?.triggerButton }, + triggerText: { ...defaultButtonStyles(theme).triggerText, ...this.props.styles?.triggerText }, + triggerIcon: { ...defaultButtonStyles(theme).triggerIcon, ...this.props.styles?.triggerIcon }, + }; + + return ( + + + {text.triggerLabel} + + ); + } +} diff --git a/packages/core/src/js/feedback/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts index aebdb181e3..94df799d21 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.styles.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts @@ -1,104 +1,153 @@ import type { ViewStyle } from 'react-native'; -import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; +import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; +import type { FeedbackButtonStyles, FeedbackWidgetStyles } from './FeedbackWidget.types'; -const PURPLE = 'rgba(88, 74, 192, 1)'; -const FOREGROUND_COLOR = '#2b2233'; -const BACKGROUND_COLOR = '#ffffff'; -const BORDER_COLOR = 'rgba(41, 35, 47, 0.13)'; +const defaultStyles = (theme: FeedbackWidgetTheme): FeedbackWidgetStyles => { + return { + container: { + flex: 1, + padding: 20, + backgroundColor: theme.background, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'left', + flex: 1, + color: theme.foreground, + }, + label: { + marginBottom: 4, + fontSize: 16, + color: theme.foreground, + }, + input: { + height: 50, + borderColor: theme.border, + borderWidth: 1, + borderRadius: 5, + paddingHorizontal: 10, + marginBottom: 15, + fontSize: 16, + color: theme.foreground, + }, + textArea: { + height: 100, + textAlignVertical: 'top', + color: theme.foreground, + }, + screenshotButton: { + backgroundColor: theme.background, + padding: 15, + borderRadius: 5, + alignItems: 'center', + flex: 1, + borderWidth: 1, + borderColor: theme.border, + }, + screenshotContainer: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + marginBottom: 20, + }, + screenshotThumbnail: { + width: 50, + height: 50, + borderRadius: 5, + marginRight: 10, + }, + screenshotText: { + color: theme.foreground, + fontSize: 16, + }, + takeScreenshotButton: { + backgroundColor: theme.background, + padding: 15, + borderRadius: 5, + alignItems: 'center', + borderWidth: 1, + borderColor: theme.border, + marginTop: -10, + marginBottom: 20, + }, + takeScreenshotText: { + color: theme.foreground, + fontSize: 16, + }, + submitButton: { + backgroundColor: theme.accentBackground, + paddingVertical: 15, + borderRadius: 5, + alignItems: 'center', + marginBottom: 10, + }, + submitText: { + color: theme.accentForeground, + fontSize: 18, + }, + cancelButton: { + backgroundColor: theme.background, + padding: 15, + borderRadius: 5, + alignItems: 'center', + borderWidth: 1, + borderColor: theme.border, + }, + cancelText: { + color: theme.foreground, + fontSize: 16, + }, + titleContainer: { + flexDirection: 'row', + width: '100%', + }, + sentryLogo: { + width: 40, + height: 40, + tintColor: theme.sentryLogo, + }, + }; +}; -const defaultStyles: FeedbackWidgetStyles = { - container: { - flex: 1, - padding: 20, - backgroundColor: BACKGROUND_COLOR, - }, - title: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 20, - textAlign: 'left', - flex: 1, - color: FOREGROUND_COLOR, - }, - label: { - marginBottom: 4, - fontSize: 16, - color: FOREGROUND_COLOR, - }, - input: { - height: 50, - borderColor: BORDER_COLOR, - borderWidth: 1, - borderRadius: 5, - paddingHorizontal: 10, - marginBottom: 15, - fontSize: 16, - color: FOREGROUND_COLOR, - }, - textArea: { - height: 100, - textAlignVertical: 'top', - color: FOREGROUND_COLOR, - }, - screenshotButton: { - backgroundColor: BACKGROUND_COLOR, - padding: 15, - borderRadius: 5, - alignItems: 'center', - flex: 1, - borderWidth: 1, - borderColor: BORDER_COLOR, - }, - screenshotContainer: { - flexDirection: 'row', - alignItems: 'center', - width: '100%', - marginBottom: 20, - }, - screenshotThumbnail: { - width: 50, - height: 50, - borderRadius: 5, - marginRight: 10, - }, - screenshotText: { - color: FOREGROUND_COLOR, - fontSize: 16, - }, - submitButton: { - backgroundColor: PURPLE, - paddingVertical: 15, - borderRadius: 5, - alignItems: 'center', - marginBottom: 10, - }, - submitText: { - color: BACKGROUND_COLOR, - fontSize: 18, - }, - cancelButton: { - backgroundColor: BACKGROUND_COLOR, - padding: 15, - borderRadius: 5, - alignItems: 'center', - borderWidth: 1, - borderColor: BORDER_COLOR, - }, - cancelText: { - color: FOREGROUND_COLOR, - fontSize: 16, - }, - titleContainer: { - flexDirection: 'row', - width: '100%', - }, - sentryLogo: { - width: 40, - height: 40, - }, +export const defaultButtonStyles = (theme: FeedbackWidgetTheme): FeedbackButtonStyles => { + return { + triggerButton: { + position: 'absolute', + bottom: 30, + right: 30, + backgroundColor: theme.background, + padding: 15, + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + elevation: 5, + shadowColor: theme.border, + shadowOffset: { width: 1, height: 2 }, + shadowOpacity: 0.5, + shadowRadius: 3, + flexDirection: 'row', + borderWidth: 1, + borderColor: theme.border, + }, + triggerText: { + color: theme.foreground, + fontSize: 18, + }, + triggerIcon: { + width: 24, + height: 24, + padding: 2, + marginEnd: 6, + tintColor: theme.sentryLogo, + }, + }; }; +export const defaultScreenshotButtonStyles = defaultButtonStyles; + export const modalWrapper: ViewStyle = { position: 'absolute', top: 0, @@ -107,18 +156,20 @@ export const modalWrapper: ViewStyle = { bottom: 0, }; -export const modalSheetContainer: ViewStyle = { - backgroundColor: '#ffffff', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - overflow: 'hidden', - alignSelf: 'stretch', - shadowColor: '#000', - shadowOffset: { width: 0, height: -3 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 5, - flex: 1, +export const modalSheetContainer = (theme: FeedbackWidgetTheme): ViewStyle => { + return { + backgroundColor: theme.background, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + overflow: 'hidden', + alignSelf: 'stretch', + shadowColor: '#000', + shadowOffset: { width: 0, height: -3 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 5, + flex: 1, + }; }; export const topSpacer: ViewStyle = { diff --git a/packages/core/src/js/feedback/FeedbackWidget.theme.ts b/packages/core/src/js/feedback/FeedbackWidget.theme.ts new file mode 100644 index 0000000000..aa8711a934 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidget.theme.ts @@ -0,0 +1,71 @@ +import { Appearance } from 'react-native'; + +import { getColorScheme, getFeedbackDarkTheme, getFeedbackLightTheme } from './integration'; + +/** + * Get the theme for the feedback widget based on the current color scheme + */ +export function getTheme(): FeedbackWidgetTheme { + const userTheme = getColorScheme(); + const colorScheme = userTheme === 'system' ? Appearance.getColorScheme() : userTheme; + const lightTheme = { ...LightTheme, ...getFeedbackLightTheme() }; + const darkTheme = { ...DarkTheme, ...getFeedbackDarkTheme() }; + return colorScheme === 'dark' ? darkTheme : lightTheme; +} + +export interface FeedbackWidgetTheme { + /** + * Background color for surfaces + */ + background: string; + + /** + * Foreground color (i.e. text color) + */ + foreground: string; + + /** + * Foreground color for accented elements + */ + accentForeground?: string; + + /** + * Background color for accented elements + */ + accentBackground?: string; + + /** + * Border color + */ + border?: string; + + /** + * Color for feedback icon + */ + feedbackIcon?: string; + + /** + * Color for Sentry logo + */ + sentryLogo?: string; +} + +export const LightTheme: FeedbackWidgetTheme = { + accentBackground: 'rgba(88, 74, 192, 1)', + accentForeground: '#ffffff', + foreground: '#2b2233', + background: '#ffffff', + border: 'rgba(41, 35, 47, 0.13)', + feedbackIcon: 'rgba(54, 45, 89, 1)', + sentryLogo: 'rgba(54, 45, 89, 1)', +}; + +export const DarkTheme: FeedbackWidgetTheme = { + accentBackground: 'rgba(88, 74, 192, 1)', + accentForeground: '#ffffff', + foreground: '#ebe6ef', + background: '#29232f', + border: 'rgba(235, 230, 239, 0.15)', + feedbackIcon: '#ffffff', + sentryLogo: '#ffffff', +}; diff --git a/packages/core/src/js/feedback/FeedbackWidget.tsx b/packages/core/src/js/feedback/FeedbackWidget.tsx index a7b9fe62ea..b84dd3b1b9 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.tsx +++ b/packages/core/src/js/feedback/FeedbackWidget.tsx @@ -1,8 +1,11 @@ +/* eslint-disable max-lines */ import type { SendFeedbackParams } from '@sentry/core'; import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/core'; import * as React from 'react'; -import type { KeyboardTypeOptions } from 'react-native'; +import type { KeyboardTypeOptions , + NativeEventSubscription} from 'react-native'; import { + Appearance, Image, Keyboard, Text, @@ -12,13 +15,17 @@ import { View } from 'react-native'; -import { isWeb, notWeb } from '../utils/environment'; -import { NATIVE } from '../wrapper'; +import { isExpoGo, isWeb, notWeb } from '../utils/environment'; +import type { Screenshot } from '../wrapper'; +import { getDataFromUri, NATIVE } from '../wrapper'; import { sentryLogo } from './branding'; import { defaultConfiguration } from './defaults'; import defaultStyles from './FeedbackWidget.styles'; +import { getTheme } from './FeedbackWidget.theme'; import type { FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackWidgetProps, FeedbackWidgetState, FeedbackWidgetStyles, ImagePickerConfiguration } from './FeedbackWidget.types'; +import { hideFeedbackButton, showScreenshotButton } from './FeedbackWidgetManager'; import { lazyLoadFeedbackIntegration } from './lazy'; +import { getCapturedScreenshot } from './ScreenshotButton'; import { base64ToUint8Array, feedbackAlertDialog, isValidEmail } from './utils'; /** @@ -39,6 +46,8 @@ export class FeedbackWidget extends React.Component void = () => { const { name, email, description } = this.state; const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props; @@ -118,7 +141,7 @@ export class FeedbackWidget extends React.Component void = async () => { - if (!this.state.filename && !this.state.attachment) { + if (!this._hasScreenshot()) { const imagePickerConfiguration: ImagePickerConfiguration = this.props; if (imagePickerConfiguration.imagePicker) { const launchImageLibrary = imagePickerConfiguration.imagePicker.launchImageLibraryAsync @@ -154,13 +177,15 @@ export class FeedbackWidget extends React.Component { + getDataFromUri(imageUri).then((data) => { if (data != null) { this.setState({ filename, attachment: data, attachmentUri: imageUri }); } else { + this._showImageRetrievalDevelopmentNote(); logger.error('Failed to read image data from uri:', imageUri); } }).catch((error) => { + this._showImageRetrievalDevelopmentNote(); logger.error('Failed to read image data from uri:', imageUri, 'error: ', error); }); } @@ -169,14 +194,15 @@ export class FeedbackWidget extends React.Component { - NATIVE.getDataFromUri(uri).then((data) => { + getDataFromUri(uri).then((data) => { if (data != null) { this.setState({ filename: 'feedback_screenshot', attachment: data, attachmentUri: uri }); } else { + this._showImageRetrievalDevelopmentNote(); logger.error('Failed to read image data from uri:', uri); } - }) - .catch((error) => { + }).catch((error) => { + this._showImageRetrievalDevelopmentNote(); logger.error('Failed to read image data from uri:', uri, 'error: ', error); }); }); @@ -187,7 +213,16 @@ export class FeedbackWidget extends React.Component { + this.forceUpdate(); + }); + } + + /** + * Save the state before unmounting the component and remove the theme listener. */ public componentWillUnmount(): void { if (this._didSubmitForm) { @@ -196,18 +231,22 @@ export class FeedbackWidget extends React.Component { if (onFormClose) { onFormClose(); @@ -220,11 +259,24 @@ export class FeedbackWidget extends React.Component { + feedbackAlertDialog(text.errorTitle, text.captureScreenshotError); + }, 100); + } else if (screenshot) { + this._setCapturedScreenshot(screenshot); + } + return ( - + - {text.formTitle} + {text.formTitle} {config.showBranding && ( this.setState({ name: value })} @@ -257,6 +310,7 @@ export class FeedbackWidget extends React.Component this.setState({ description: value })} multiline /> - {(config.enableScreenshot || imagePickerConfiguration.imagePicker) && ( + {(config.enableScreenshot || imagePickerConfiguration.imagePicker || this._hasScreenshot()) && ( {this.state.attachmentUri && ( - {!this.state.filename && !this.state.attachment + {!this._hasScreenshot() ? text.addScreenshotButtonLabel : text.removeScreenshotButtonLabel} )} + {notWeb() && config.enableTakeScreenshot && !this.state.attachmentUri && ( + { + hideFeedbackButton(); + onCancel(); + showScreenshotButton(); + }}> + {text.captureScreenshotButtonLabel} + + )} - {text.submitButtonLabel} + {text.submitButtonLabel} @@ -305,6 +369,24 @@ export class FeedbackWidget extends React.Component { + if (screenshot.data != null) { + logger.debug('Setting captured screenshot:', screenshot.filename); + NATIVE.encodeToBase64(screenshot.data).then((base64String) => { + if (base64String != null) { + const dataUri = `data:${screenshot.contentType};base64,${base64String}`; + this.setState({ filename: screenshot.filename, attachment: screenshot.data, attachmentUri: dataUri }); + } else { + logger.error('Failed to read image data from:', screenshot.filename); + } + }).catch((error) => { + logger.error('Failed to read image data from:', screenshot.filename, 'error: ', error); + }); + } else { + logger.error('Failed to read image data from:', screenshot.filename); + } + } + private _saveFormState = (): void => { FeedbackWidget._savedState = { ...this.state }; }; @@ -319,4 +401,17 @@ export class FeedbackWidget extends React.Component { + return this.state.filename !== undefined && this.state.attachment !== undefined && this.state.attachmentUri !== undefined; + } + + private _showImageRetrievalDevelopmentNote = (): void => { + if (isExpoGo()) { + feedbackAlertDialog( + 'Development note', + 'The feedback widget cannot retrieve image data in Expo Go. Please build your app to test this functionality.', + ); + } + } } diff --git a/packages/core/src/js/feedback/FeedbackWidget.types.ts b/packages/core/src/js/feedback/FeedbackWidget.types.ts index af08c2ffc3..22b6b0911f 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.types.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.types.ts @@ -54,6 +54,12 @@ export interface FeedbackGeneralConfiguration { */ enableScreenshot?: boolean; + /** + * This flag determines whether the "Take Screenshot" button is displayed + * @default false + */ + enableTakeScreenshot?: boolean; + /** * Fill in email/name input fields with Sentry user context if it exists. * The value of the email/name keys represent the properties of your user context. @@ -124,15 +130,20 @@ export interface FeedbackTextConfiguration { isRequiredLabel?: string; /** - * The label for the button that adds a screenshot and renders the image editor + * The label for the button that adds a screenshot */ addScreenshotButtonLabel?: string; /** - * The label for the button that removes a screenshot and hides the image editor + * The label for the button that removes a screenshot */ removeScreenshotButtonLabel?: string; + /** + * The label for the button that shows the capture screenshot button + */ + captureScreenshotButtonLabel?: string; + /** * The title of the error dialog */ @@ -148,12 +159,47 @@ export interface FeedbackTextConfiguration { */ emailError?: string; + /** + * The error message when the capture screenshot fails + */ + captureScreenshotError?: string; + /** * Message when there is a generic error */ genericError?: string; } +/** + * The FeedbackButton text labels that can be customized + */ +export interface FeedbackButtonTextConfiguration { + /** + * The label for the Feedback widget button that opens the dialog + */ + triggerLabel?: string; + + /** + * The aria label for the Feedback widget button that opens the dialog + */ + triggerAriaLabel?: string; +} + +/** + * The ScreenshotButton text labels that can be customized + */ +export interface ScreenshotButtonTextConfiguration { + /** + * The label for the Screenshot button + */ + triggerLabel?: string; + + /** + * The aria label for the Screenshot button + */ + triggerAriaLabel?: string; +} + /** * The public callbacks available for the feedback integration */ @@ -243,10 +289,44 @@ export interface FeedbackWidgetStyles { screenshotContainer?: ViewStyle; screenshotThumbnail?: ImageStyle; screenshotText?: TextStyle; + takeScreenshotButton?: ViewStyle; + takeScreenshotText?: TextStyle; titleContainer?: ViewStyle; sentryLogo?: ImageStyle; } +/** + * The props for the feedback button + */ +export interface FeedbackButtonProps extends FeedbackButtonTextConfiguration { + styles?: FeedbackButtonStyles; +} + +/** + * The styles for the feedback button + */ +export interface FeedbackButtonStyles { + triggerButton?: ViewStyle; + triggerText?: TextStyle; + triggerIcon?: ImageStyle; +} + +/** + * The props for the screenshot button + */ +export interface ScreenshotButtonProps extends ScreenshotButtonTextConfiguration { + styles?: ScreenshotButtonStyles; +} + +/** + * The styles for the screenshot button + */ +export interface ScreenshotButtonStyles { + triggerButton?: ViewStyle; + triggerText?: TextStyle; + triggerIcon?: ImageStyle; +} + /** * The state of the feedback form */ diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index 856298382e..e554715586 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -1,23 +1,19 @@ import { logger } from '@sentry/core'; -import * as React from 'react'; -import type { NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import { Animated, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; - -import { notWeb } from '../utils/environment'; -import { FeedbackWidget } from './FeedbackWidget'; -import { modalSheetContainer, modalWrapper, topSpacer } from './FeedbackWidget.styles'; -import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; -import { getFeedbackOptions } from './integration'; -import { lazyLoadAutoInjectFeedbackIntegration } from './lazy'; -import { isModalSupported } from './utils'; - -const PULL_DOWN_CLOSE_THRESHOLD = 200; -const SLIDE_ANIMATION_DURATION = 200; -const BACKGROUND_ANIMATION_DURATION = 200; - -class FeedbackWidgetManager { - private static _isVisible = false; - private static _setVisibility: (visible: boolean) => void; + +import { isWeb } from '../utils/environment'; +import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration, lazyLoadAutoInjectScreenshotButtonIntegration } from './lazy'; + +export const PULL_DOWN_CLOSE_THRESHOLD = 200; +export const SLIDE_ANIMATION_DURATION = 200; +export const BACKGROUND_ANIMATION_DURATION = 200; + +abstract class FeedbackManager { + protected static _isVisible = false; + protected static _setVisibility: (visible: boolean) => void; + + protected static get _feedbackComponentName(): string { + throw new Error('Subclasses must override feedbackComponentName'); + } public static initialize(setVisibility: (visible: boolean) => void): void { this._setVisibility = setVisibility; @@ -38,7 +34,7 @@ class FeedbackWidgetManager { } else { // This message should be always shown otherwise it's not possible to use the widget. // eslint-disable-next-line no-console - console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.'); + console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' to be called before 'show${this._feedbackComponentName}()'.`); } } @@ -49,7 +45,7 @@ class FeedbackWidgetManager { } else { // This message should be always shown otherwise it's not possible to use the widget. // eslint-disable-next-line no-console - console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` before interacting with the widget.'); + console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' before interacting with the widget.`); } } @@ -58,170 +54,40 @@ class FeedbackWidgetManager { } } -interface FeedbackWidgetProviderProps { - children: React.ReactNode; - styles?: FeedbackWidgetStyles; -} - -interface FeedbackWidgetProviderState { - isVisible: boolean; - backgroundOpacity: Animated.Value; - panY: Animated.Value; - isScrollAtTop: boolean; -} - -class FeedbackWidgetProvider extends React.Component { - public state: FeedbackWidgetProviderState = { - isVisible: false, - backgroundOpacity: new Animated.Value(0), - panY: new Animated.Value(Dimensions.get('screen').height), - isScrollAtTop: true, - }; - - private _panResponder = PanResponder.create({ - onStartShouldSetPanResponder: (_, gestureState) => { - return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; - }, - onMoveShouldSetPanResponder: (_, gestureState) => { - return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; - }, - onPanResponderMove: (_, gestureState) => { - if (gestureState.dy > 0) { - this.state.panY.setValue(gestureState.dy); - } - }, - onPanResponderRelease: (_, gestureState) => { - if (gestureState.dy > PULL_DOWN_CLOSE_THRESHOLD) { - // Close on swipe below a certain threshold - Animated.timing(this.state.panY, { - toValue: Dimensions.get('screen').height, - duration: SLIDE_ANIMATION_DURATION, - useNativeDriver: true, - }).start(() => { - this._handleClose(); - }); - } else { - // Animate it back to the original position - Animated.spring(this.state.panY, { - toValue: 0, - useNativeDriver: true, - }).start(); - } - }, - }); - - public constructor(props: FeedbackWidgetProviderProps) { - super(props); - FeedbackWidgetManager.initialize(this._setVisibilityFunction); +/** + * Provides functionality to show and hide the feedback widget. + */ +export class FeedbackWidgetManager extends FeedbackManager { + /** + * Returns the name of the feedback component. + */ + protected static get _feedbackComponentName(): string { + return 'FeedbackWidget'; } +} +/** + * Provides functionality to show and hide the feedback button. + */ +export class FeedbackButtonManager extends FeedbackManager { /** - * Animates the background opacity when the modal is shown. + * Returns the name of the feedback component. */ - public componentDidUpdate(_prevProps: any, prevState: FeedbackWidgetProviderState): void { - if (!prevState.isVisible && this.state.isVisible) { - Animated.parallel([ - Animated.timing(this.state.backgroundOpacity, { - toValue: 1, - duration: BACKGROUND_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.in(Easing.quad), - }), - Animated.timing(this.state.panY, { - toValue: 0, - duration: SLIDE_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.in(Easing.quad), - }) - ]).start(() => { - logger.info('FeedbackWidgetProvider componentDidUpdate'); - }); - } else if (prevState.isVisible && !this.state.isVisible) { - this.state.backgroundOpacity.setValue(0); - } + protected static get _feedbackComponentName(): string { + return 'FeedbackButton'; } +} +/** + * Provides functionality to show and hide the screenshot button. + */ +export class ScreenshotButtonManager extends FeedbackManager { /** - * Renders the feedback form modal. + * Returns the name of the feedback component. */ - public render(): React.ReactNode { - if (!isModalSupported()) { - logger.error('FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.'); - return <>{this.props.children}; - } - - const { isVisible, backgroundOpacity } = this.state; - - const backgroundColor = backgroundOpacity.interpolate({ - inputRange: [0, 1], - outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'], - }); - - // Wrapping the `Modal` component in a `View` component is necessary to avoid - // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035 - return ( - <> - {this.props.children} - {isVisible && - - - - - - - - - - - } - - ); + protected static get _feedbackComponentName(): string { + return 'ScreenshotButton'; } - - private _handleScroll = (event: NativeSyntheticEvent): void => { - this.setState({ isScrollAtTop: event.nativeEvent.contentOffset.y <= 0 }); - }; - - private _setVisibilityFunction = (visible: boolean): void => { - const updateState = (): void => { - this.setState({ isVisible: visible }); - }; - if (!visible) { - Animated.parallel([ - Animated.timing(this.state.panY, { - toValue: Dimensions.get('screen').height, - duration: SLIDE_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.out(Easing.quad), - }), - Animated.timing(this.state.backgroundOpacity, { - toValue: 0, - duration: BACKGROUND_ANIMATION_DURATION, - useNativeDriver: true, - easing: Easing.out(Easing.quad), - }) - ]).start(() => { - // Change of the state unmount the component - // which would cancel the animation - updateState(); - }); - } else { - updateState(); - } - }; - - private _handleClose = (): void => { - FeedbackWidgetManager.hide(); - }; } const showFeedbackWidget = (): void => { @@ -233,4 +99,34 @@ const resetFeedbackWidgetManager = (): void => { FeedbackWidgetManager.reset(); }; -export { showFeedbackWidget, FeedbackWidgetProvider, resetFeedbackWidgetManager }; +const showFeedbackButton = (): void => { + lazyLoadAutoInjectFeedbackButtonIntegration(); + FeedbackButtonManager.show(); +}; + +const hideFeedbackButton = (): void => { + FeedbackButtonManager.hide(); +}; + +const resetFeedbackButtonManager = (): void => { + FeedbackButtonManager.reset(); +}; + +const showScreenshotButton = (): void => { + if (isWeb()) { + logger.warn('ScreenshotButton is not supported on Web.'); + return; + } + lazyLoadAutoInjectScreenshotButtonIntegration(); + ScreenshotButtonManager.show(); +}; + +const hideScreenshotButton = (): void => { + ScreenshotButtonManager.hide(); +}; + +const resetScreenshotButtonManager = (): void => { + ScreenshotButtonManager.reset(); +}; + +export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, showScreenshotButton, hideScreenshotButton, resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager }; diff --git a/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx new file mode 100644 index 0000000000..9e90ed785f --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackWidgetProvider.tsx @@ -0,0 +1,223 @@ +import { logger } from '@sentry/core'; +import * as React from 'react'; +import { type NativeEventSubscription, type NativeScrollEvent,type NativeSyntheticEvent, Animated, Appearance, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; + +import { notWeb } from '../utils/environment'; +import { FeedbackButton } from './FeedbackButton'; +import { FeedbackWidget } from './FeedbackWidget'; +import { modalSheetContainer,modalWrapper, topSpacer } from './FeedbackWidget.styles'; +import { getTheme } from './FeedbackWidget.theme'; +import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; +import { BACKGROUND_ANIMATION_DURATION,FeedbackButtonManager, FeedbackWidgetManager, PULL_DOWN_CLOSE_THRESHOLD, ScreenshotButtonManager, SLIDE_ANIMATION_DURATION } from './FeedbackWidgetManager'; +import { getFeedbackButtonOptions, getFeedbackOptions, getScreenshotButtonOptions } from './integration'; +import { ScreenshotButton } from './ScreenshotButton'; +import { isModalSupported, isNativeDriverSupportedForColorAnimations } from './utils'; + +const useNativeDriverForColorAnimations = isNativeDriverSupportedForColorAnimations(); + +export interface FeedbackWidgetProviderProps { + children: React.ReactNode; + styles?: FeedbackWidgetStyles; +} + +export interface FeedbackWidgetProviderState { + isButtonVisible: boolean; + isScreenshotButtonVisible: boolean; + isVisible: boolean; + backgroundOpacity: Animated.Value; + panY: Animated.Value; + isScrollAtTop: boolean; +} + +/** + * FeedbackWidgetProvider is a component that wraps the feedback widget and provides + * functionality to show and hide the widget. It also manages the visibility of the + * feedback button and screenshot button. + */ +export class FeedbackWidgetProvider extends React.Component { + public state: FeedbackWidgetProviderState = { + isButtonVisible: false, + isScreenshotButtonVisible: false, + isVisible: false, + backgroundOpacity: new Animated.Value(0), + panY: new Animated.Value(Dimensions.get('screen').height), + isScrollAtTop: true, + }; + + private _themeListener: NativeEventSubscription; + + private _panResponder = PanResponder.create({ + onStartShouldSetPanResponder: (_, gestureState) => { + return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; + }, + onMoveShouldSetPanResponder: (_, gestureState) => { + return notWeb() && this.state.isScrollAtTop && gestureState.dy > 0; + }, + onPanResponderMove: (_, gestureState) => { + if (gestureState.dy > 0) { + this.state.panY.setValue(gestureState.dy); + } + }, + onPanResponderRelease: (_, gestureState) => { + if (gestureState.dy > PULL_DOWN_CLOSE_THRESHOLD) { + // Close on swipe below a certain threshold + Animated.timing(this.state.panY, { + toValue: Dimensions.get('screen').height, + duration: SLIDE_ANIMATION_DURATION, + useNativeDriver: true, + }).start(() => { + this._handleClose(); + }); + } else { + // Animate it back to the original position + Animated.spring(this.state.panY, { + toValue: 0, + useNativeDriver: true, + }).start(); + } + }, + }); + + public constructor(props: FeedbackWidgetProviderProps) { + super(props); + FeedbackButtonManager.initialize(this._setButtonVisibilityFunction); + ScreenshotButtonManager.initialize(this._setScreenshotButtonVisibilityFunction); + FeedbackWidgetManager.initialize(this._setVisibilityFunction); + } + + /** + * Add a listener to the theme change event. + */ + public componentDidMount(): void { + this._themeListener = Appearance.addChangeListener(() => { + this.forceUpdate(); + }); + } + + /** + * Clean up the theme listener. + */ + public componentWillUnmount(): void { + if (this._themeListener) { + this._themeListener.remove(); + } + } + + /** + * Animates the background opacity when the modal is shown. + */ + public componentDidUpdate(_prevProps: any, prevState: FeedbackWidgetProviderState): void { + if (!prevState.isVisible && this.state.isVisible) { + Animated.parallel([ + Animated.timing(this.state.backgroundOpacity, { + toValue: 1, + duration: BACKGROUND_ANIMATION_DURATION, + useNativeDriver: useNativeDriverForColorAnimations, + easing: Easing.in(Easing.quad), + }), + Animated.timing(this.state.panY, { + toValue: 0, + duration: SLIDE_ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.in(Easing.quad), + }) + ]).start(() => { + logger.info('FeedbackWidgetProvider componentDidUpdate'); + }); + } else if (prevState.isVisible && !this.state.isVisible) { + this.state.backgroundOpacity.setValue(0); + } + } + + /** + * Renders the feedback form modal. + */ + public render(): React.ReactNode { + if (!isModalSupported()) { + logger.error('FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.'); + return <>{this.props.children}; + } + + const theme = getTheme(); + + const { isButtonVisible, isScreenshotButtonVisible, isVisible, backgroundOpacity } = this.state; + + const backgroundColor = backgroundOpacity.interpolate({ + inputRange: [0, 1], + outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'], + }); + + // Wrapping the `Modal` component in a `View` component is necessary to avoid + // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035 + return ( + <> + {this.props.children} + {isButtonVisible && } + {isScreenshotButtonVisible && } + {isVisible && + + + + + + + + + + } + + ); + } + + private _handleScroll = (event: NativeSyntheticEvent): void => { + this.setState({ isScrollAtTop: event.nativeEvent.contentOffset.y <= 0 }); + }; + + private _setVisibilityFunction = (visible: boolean): void => { + const updateState = (): void => { + this.setState({ isVisible: visible }); + }; + if (!visible) { + Animated.parallel([ + Animated.timing(this.state.panY, { + toValue: Dimensions.get('screen').height, + duration: SLIDE_ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.out(Easing.quad), + }), + Animated.timing(this.state.backgroundOpacity, { + toValue: 0, + duration: BACKGROUND_ANIMATION_DURATION, + useNativeDriver: useNativeDriverForColorAnimations, + easing: Easing.out(Easing.quad), + }) + ]).start(() => { + // Change of the state unmount the component + // which would cancel the animation + updateState(); + }); + } else { + updateState(); + } + }; + + private _setButtonVisibilityFunction = (visible: boolean): void => { + this.setState({ isButtonVisible: visible }); + }; + + private _setScreenshotButtonVisibilityFunction = (visible: boolean): void => { + this.setState({ isScreenshotButtonVisible: visible }); + }; + + private _handleClose = (): void => { + FeedbackWidgetManager.hide(); + }; +} diff --git a/packages/core/src/js/feedback/ScreenshotButton.tsx b/packages/core/src/js/feedback/ScreenshotButton.tsx new file mode 100644 index 0000000000..18cfa19239 --- /dev/null +++ b/packages/core/src/js/feedback/ScreenshotButton.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import type { NativeEventSubscription} from 'react-native'; +import { Appearance, Image, Text, TouchableOpacity } from 'react-native'; + +import type { Screenshot } from '../wrapper'; +import { NATIVE } from '../wrapper'; +import { defaultScreenshotButtonConfiguration } from './defaults'; +import { defaultScreenshotButtonStyles } from './FeedbackWidget.styles'; +import { getTheme } from './FeedbackWidget.theme'; +import type { ScreenshotButtonProps, ScreenshotButtonStyles, ScreenshotButtonTextConfiguration } from './FeedbackWidget.types'; +import { hideScreenshotButton, showFeedbackWidget } from './FeedbackWidgetManager'; +import { screenshotIcon } from './icons'; +import { lazyLoadFeedbackIntegration } from './lazy'; + +let capturedScreenshot: Screenshot | 'ErrorCapturingScreenshot' | undefined; + +const takeScreenshot = async (): Promise => { + hideScreenshotButton(); + setTimeout(async () => { // Delay capture to allow the button to hide + const screenshots: Screenshot[] | null = await NATIVE.captureScreenshot(); + if (screenshots && screenshots.length > 0) { + capturedScreenshot = screenshots[0]; + } else { + capturedScreenshot = 'ErrorCapturingScreenshot'; + } + showFeedbackWidget(); + }, 100); +}; + +export const getCapturedScreenshot = (): Screenshot | 'ErrorCapturingScreenshot' | undefined => { + const screenshot = capturedScreenshot; + capturedScreenshot = undefined; + return screenshot; +} + +/** + * @beta + * Implements a screenshot button that takes a screenshot. + */ +export class ScreenshotButton extends React.Component { + private _themeListener: NativeEventSubscription; + + public constructor(props: ScreenshotButtonProps) { + super(props); + lazyLoadFeedbackIntegration(); + } + + /** + * Adds a listener for theme changes. + */ + public componentDidMount(): void { + this._themeListener = Appearance.addChangeListener(() => { + this.forceUpdate(); + }); + } + + /** + * Removes the theme listener. + */ + public componentWillUnmount(): void { + if (this._themeListener) { + this._themeListener.remove(); + } + } + + /** + * Renders the screenshot button. + */ + public render(): React.ReactNode { + const theme = getTheme(); + const text: ScreenshotButtonTextConfiguration = { ...defaultScreenshotButtonConfiguration, ...this.props }; + const styles: ScreenshotButtonStyles = { + triggerButton: { ...defaultScreenshotButtonStyles(theme).triggerButton, ...this.props.styles?.triggerButton }, + triggerText: { ...defaultScreenshotButtonStyles(theme).triggerText, ...this.props.styles?.triggerText }, + triggerIcon: { ...defaultScreenshotButtonStyles(theme).triggerIcon, ...this.props.styles?.triggerIcon }, + }; + + return ( + + + {text.triggerLabel} + + ); + } +} diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index 90d9534874..2158b69a41 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -1,4 +1,4 @@ -import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types'; import { feedbackAlertDialog } from './utils'; const FORM_TITLE = 'Report a Bug'; @@ -11,11 +11,15 @@ const MESSAGE_LABEL = 'Description'; const IS_REQUIRED_LABEL = '(required)'; const SUBMIT_BUTTON_LABEL = 'Send Bug Report'; const CANCEL_BUTTON_LABEL = 'Cancel'; +const TRIGGER_LABEL = 'Report a Bug'; +const TRIGGER_SCREENSHOT_LABEL = 'Take Screenshot'; const ERROR_TITLE = 'Error'; const FORM_ERROR = 'Please fill out all required fields.'; const EMAIL_ERROR = 'Please enter a valid email address.'; +const CAPTURE_SCREENSHOT_ERROR = 'Error capturing screenshot. Please try again.'; const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!'; const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +const CAPTURE_SCREENSHOT_LABEL = 'Take a screenshot'; const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; const GENERIC_ERROR_TEXT = 'Unable to send feedback due to an unexpected error.'; @@ -60,6 +64,7 @@ export const defaultConfiguration: Partial = { showEmail: true, showName: true, enableScreenshot: false, + enableTakeScreenshot: false, // FeedbackTextConfiguration cancelButtonLabel: CANCEL_BUTTON_LABEL, @@ -75,8 +80,20 @@ export const defaultConfiguration: Partial = { errorTitle: ERROR_TITLE, formError: FORM_ERROR, emailError: EMAIL_ERROR, + captureScreenshotError: CAPTURE_SCREENSHOT_ERROR, successMessageText: SUCCESS_MESSAGE_TEXT, addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL, removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, + captureScreenshotButtonLabel: CAPTURE_SCREENSHOT_LABEL, genericError: GENERIC_ERROR_TEXT, }; + +export const defaultButtonConfiguration: Partial = { + triggerLabel: TRIGGER_LABEL, + triggerAriaLabel: '', +}; + +export const defaultScreenshotButtonConfiguration: Partial = { + triggerLabel: TRIGGER_SCREENSHOT_LABEL, + triggerAriaLabel: '', +}; diff --git a/packages/core/src/js/feedback/icons.ts b/packages/core/src/js/feedback/icons.ts new file mode 100644 index 0000000000..b73ecdde86 --- /dev/null +++ b/packages/core/src/js/feedback/icons.ts @@ -0,0 +1,32 @@ +export const feedbackIcon = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAk1BMVEUAAAAqIDEqIjMmIDArIjIrIjMoHjErITMrIjMqITIqITIqITIqIjIqITIqITIhHi4qITIqITIeAConHi8rITMqITIqITIcHCopITIpIDEpIDIlGC8qITIqITIpIDIpHjAqITIqIDIqIDEqIDAqITInHjAqIDIqIDEqITIqITIqIDIiFCgpIDEqITIoHS8qIDIrIjMN1S0HAAAAMHRSTlMAhnknzMQy1zyF6/H55PYQ0rIGGPudjAmUTEQUv7p2K6lmWDZxHV9S36J+DD7bI208pRBPAAAGrUlEQVR42uzd6XLaMBSG4Y/YhuAVbAwx+xp2eu7/6tq0nfaHIAizSNbR+5vJJM8EG45lGfrm7EZRSNL5bvcAg1oM6fZaBQxp/0HlylOY0PuRyjbco/L1unRHbVS9RYPuaoxKlzY7dF9xhgpXb9Hd1VDddiHdX4SqNnHpIU1QzcYhPaY5qpjj0aNaooL9iOlhvaFynYZEnAFmAXEGWI2IOAN8BsQZIGsTcQaY+0SMAQZT+hVfgLVPX3EFSLb0J6YARUR/YwmQ5PQvjgD9DcmWJ7hUraoANwy9owIwDuCGofc2gXEANwy9/TVgHMANQ+/pAMYBpLUOSebPAeMAbhh6tzOYByA/9A4+AeMAbhh6j1YwD0B+6B3MAOMAbhh6D09QAdCvtT33ecUkWfwDeD1Ab+yTHnknKABYHEmPwjGgAKAISY/cCVQAvGvy94c7QAVAEpEWtepQA/BBOtRppngYwDaDfAMt3gCNBVAeQKzxUciC/iAN6vZwJ4BYsC0gU5uUd3wHHgAgFu0yXE39IfCjhwcBiHWmE1yJFLfpA+UBJJpOtAbI93gwgFieaQsQFcDzAShYagqwTfAUADF3oiGAvwZeBUDxUjuA6QBPBBAbDbQC8OfAawFoU9cIoJ3h5QAUz3UBCD4BBQBESz0ARisoAqCmBgDBDFAGQB/KAaYrqASgrlKAVtcB1ALQThJgXX94px4A5QD0KQfgQP/KAYQH5gDkZ8wByOMOQE3DAfJZHtF3dd7NBngDsKh9Z9BIjQf41dyjizU5AACHFl0odFgAADOfztdmAoBsROfrMwEAxh06l8cGAEVM5+qzAcDCpzMN+QBgEtCZ6nwAcAhJbMsIAHMSixNGAPggsRkngF6DhDxOAKiHJJRxAjj3JvjBCmAgngvbrACwE88DKSuARPxIfGAFgFx8JS+AungQ4AUAYUAUMQMQ79pNeAH0hZe+8wJIY+FKKS8AuMLFcmYAubBihBnAUliwwgxgLQwGmQEIpwGXGcBCWLXEDGAiXCZmBlDn/h9w4H4MKIS5KDOAmfB9mBmA8HUwZwYgLBaoMQOIhItDvAAccZEAL4Cl8NIVLwDhEBDwmgmKeyV4vADG4v0jvACO4m0hrAAKEspYAYjrZl1WV4c/SajGCWAfkdCCE0BOQhtOa4TWJNZlBLDySczhA5A0SMzjs1I0HdKZ5mwAem06U8RmtXji0bmWXACcBp3L7zEBuPQcpyWPW2ayKZ1vk3IASMcBXWjN4La5/exIlxqZf+fopBvQxeKT2QD7fvdI3zU2++7xgK40NHz/gGsFK+YABfMtNJrMd5GZQgpgE4RR47F50/FEPYDXU7qXmFsoBmglKAGg316KZQEaGcoBaLWZanmAVqZ8O72vtokiAC/RYEPFr6K+EoB2T48tNb/66L0eoKnRrrJEjcWLAYK1VvsKE3V2LwVwHd12lhZ+pzIA8toa7i1OFC9fBODW9dxdnmi4egGAP8M3dahMemwyLQUQ1BJ8l/KnrLUHzwQIatd+vEeq84unARyXCa7VJPXl+2cAxNM+JJqQBm0Ojwbwp+se5BqRDnXTBwK0xnXIV++QDrXqT7k2WJmnjRGFb88CqMgD94i8kyIAjEmP4pkiALy3SI9GmRoAoL/dkA75czUAXyUnx3HpfHPnWjmdz/nTW0iSTRMFAFc/GL+X/mVuf/J+1DcSAGn3hoGhiQDAYSM/MDQSAPv8hqfwmggArH35JwQaCYBBW35gaCQA8BnIDwyNBMBqKD8wNBIAWMbyA0MjATBx5QeGRgIgbXbkB4YmAgCLI91flQHQ+z+H4QkA9CP6E1cAJFP6HVsAYO7TrxgD/HkMEGcAYBYzB8DJYw4AvIXMAVBvMQdA2mUOABw2zAHEgWH5xpUEAAqfHtO8ogDCwLBsk6oCCAPDckWoLoAwMCxTt8oAwsDw9uJVtQHguPeeBCsOIAwMb2uEygMAiwaVbZiYAFB+YJinMAIAWAzp9loFYAoA4OxGUUjS+W73AJgEUCILYAEsgAWwABbAAlgAC2ABLIAFsAAWwAJYAAtgASyABbAAFsACWAALYAEsgAWwABbAAlgAC2ABLIAFsAAWwAKUANg2r+WZDSCRBbAAFsAC/GyfXlIYhIIgitYgGHhoiMmDSPyAgqKz2v/qxIkL0JoU9NlB3+6OABEgAkSACBABnAP0FMvwslGsgpeBYjW8FBQbYeZNqbKFmYlSDex0FEp2BwB8V8rkPwwViSJ5gaX5QYlkuf/DZ0i8rWwM///0m7bni5flqh5dxt8BPG/wrQSMUX8AAAAASUVORK5CYII='; + +/** + * Source: https://github.com/tabler/tabler-icons/blob/b54c86433ed5121e2590bf09f0faf746bb5aba66/icons/outline/screenshot.svg + * + * MIT License + * + * Copyright (c) 2020-2024 Paweł Kuna + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * TODO: Replace with another screenshot icon when available + */ +export const screenshotIcon = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAA8KADAAQAAAABAAAA8AAAAAAm0kfIAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgoZXuEHAAAh3klEQVR4Ae2dB7RlVXnHjcBQpFepMzh0HMqAHWNBNGBBjSV2Y6LG2KIus6LR2BJTLdhQ0ayFsFDJYmGwLYwFBKX3GdrAFAZmpFdRQWJ+v5m3J5fHO+/de+7d++w7831r/d49795z9v72/+xvt3PuPY96VFgoEAqEAqFAKBAKhAKhQCgQCgygwB8NsG+JXWeRySawPujbxpBsPTbS/w+wfT/8DtzvbngQajf932gCy7kh/AFKmDr9FtQtaXUn2/8LNVuqB+qmfmI9UbffgP4/BJZNc//fg+W0nqzVZqDUZPvhzJNge7ByHwL6aIXbuuf/a9k+B64GA+EEWAQ1mxXvCXAAzJ/YtrxWvl7rDWgro//7mqz3/7Sdjmnaz2MfDQvgXFgK/v9VuA1qti1wzjqhbgfBE2EPMDgvBAPVMlwGNvIbwE1gOa+Atdp6T3gXBfXkvAiOgseBwbgZeBKsYJtCqsS+Z1BrVvp7wJNny/wN+BwshWTpuPR/V6+W50/gpXAgWKbHQConm8XMhlDdHLkY9MdOcAevNVjvOdsOh6wbL4THg+de7dTNQNXsda0L9rj3gceL5bsX7KFPBgN6JZwJvdabX+/7Y7NtAUqaQXgw2BM9FnaA+WDLmk4KmwPbco44D86B88HW15PaxQnqzfNQfDBwnwdWQod+NZmjFnX7JajZJaD1lmH1O/n/pjzV6DBQt2fCvmBj3tYs453gNMty/gJs+G+G6yBsBgU8MVuCJ+JV8C2wdbQHGAXOgXrTOZX/nw42Fl2ZedswOTJwqNfrX43b+ngczIMup1UG7+Hwfcil049J+zvwEdgawmZQYEc+fx38FBzS5DoxvekaxLbiXdmBZHwiTG5cen2sbdvh9dfBkUJX9iwyzhm8vZovJa+/hW3BHn6YXp7D105zaGzL7hDGYUuvgLm3P0V+pVtYRxvayyF3+XKk/3v8fi84z+zCPkCmOco1VZo2WLeC06/Pw+SGK51LPqrXhpl3Ti5Vb4F348Oj4a3wfHAIXXpI64LYbXA5lDRHHG8A5/njZvZCG4OVelEB53vrjCvLb4J9CuRrFpbVOrI5mPcuE9vb82r5XRzr9Y9/135zdfXZ8Bm4ClLL18VQ0t7EFUh7kxInwsZwNnwcrodU9nF7tWf6NFjBS+jmvHc/+ALcBKX1SnXTgLXR+hEcBRvBOmX2sC+FM6D0SWjKz0bEBsVeJbeZh8PPZaA/qWI0+Vbz+z/D//kwyhEayU1pXon4JNwFtehmEB8J1duwK4620Iqu/QO8FuZALWZQHQAXgwtoOU0dnOeLVqL3Wp3T6P9uRZL7Q4nphw3dr8HzswXUoNsR+KHp2+mrtir94zCpraXgdajxEXgVzIGazOGzC2kbFnDK3sr5m/P9cbftKMA8GKZ+9KuB9cchdIlRUr8+ud9h8FFwYS2NRGpoXHBndObw532wEuyBasN58JUwG3KbvccvwTxr02FQf5wP/gRKzAP3JZ/FUJNu9rxJsxvYfgc4KtHWqiB+OwXyDpdU2Jpe00mwYuwJuW1rMrgFatJgGF+WUpYSvaIjpGH8LHHs7fhoXS8xkiOb/m2YIdKLyMbrnZv3n13RPVNLme6EKpG5K7hri5UqiwFoY1uz2Th/EP6mNicHCeAUEJbB4H0PHOY/Fdvv8M2FGF9zmz39Akhfa8udX870LcNCMLhym4tXniMb2pptJ5x7ATwPemOhU58HCWBPpvOAPwWD92ng5L7ESSabVubw/jwoEVTOG13tdrg17uaaxoVgmXKbq/YXgSvRtZtfTnk37AWDxE62cg3qxK548jp4Jnjd1+CtpjXCl8nmMPBGsHfMbeZxKdyRO6MC6dsrroASjXMK4HT5rUDxWmWhFi7qPQeOBlfqO7dBA9ivAO4NKSBqDl7FtSLqa4mKaG91GbiQNe6mbiV6X3UycC8BR0s1W29dfz2OGgdjZQ4fjgeDYRy4Aj/fBd7e2Ss+/2a1fyF1K+M4aDSVj+fg+2shXftkM7vNIocT4D6Yyqca3zsOXw+ATm2Qk/TnePo2GPburVIF/m8y+mcoPbdywWw3KHHpimxGbseSopUzjbJGnsEUCdrbm583dOw8xec1vuX5tcE5F5yqdWL9DqG3wbungnMAW8PaTWG9qaJ08KqLJ9TG41b/GTO7GX/VzRXhkqMWZfoxfB/u9Z8xsM3w8Ug4oktfZ+qBPYlO1u15nbxvBaVPLFkOZAbv1+C70EUQeU3TrzHa0NmjbALjYCtx8otwBjgFKG32wK7gO5x+PLhIWrt5951+/gD0vzpzOPMx8OTWOA+Z7JPBewzMha5tRxz4BFgpJ/tZ2/+e3w/DttC17YMDXwaHpbXpNJU/V+PnU8DRaXGbqQfeHI8OgcOgEwf7VMR554/h0/AtWAaK3aU5fF8Ep8J14OjFmwFqsntw5jT4DOhnDSvo9v4L4b/gJtgBamhYcGNKswd21OB1c/Usak0LUg6TDYBN4dmwIdRkLnrcCPYcVjpvBDgDFDFdT0xl4K1OTP2WT3AVr3fA28Gh1tawK3jiS5oNnUGxYgL1OgMuA+e9XZvnzJ73+gmu5dWG8A0Tr9vz6gKh+9VizoVfCI78iltTAKfe62A8svftyrweaYB6J5VzS0+mPi8Bb5q4EgwOW+zJlsow+f3S/1vZ9PvbYJBYQeeA2u4JNkYlzAVLA8JgXQBqtxhqst5zpm63wYkTr9aFvUDdZoMNoesLBpC9oOUzwEub+Xp/RCejhOlasqfi1HvgZdCF3U+mF8NXwIrm/wZtWCiQFJjHxpNhZ3CU6GKrC0td2LvI9CS4vYvMp8rzUN48AWwVS3If+Z0CrwB7qM3B+fcsCAsFehWwTmwF9rw7gPXlfXAFlKyz5nUWPBeqsTfiyQrILYRD45THJWx/AA6CjSEsFBhUgS05wLWGKyHVq946lt7L8fpXgzo77P5Nc2DTnQ87DptBH8c7jHceqOBfhJPgfggLBQZVwLp0F1iHDNB3wh4wXT3n45GZQ/kqbB+8OBlytFJTpXk5eb0JXIwICwWGUSCt6ziCc068CKaqczneO568bDCKmStoU9n+vFmqNXGh6vPgfPtBCAsFhlHAwNRctf5P+BncCyVsNpnsWyKjlEdTALuiV6o3PJO8vgERvOmsxOuoFPC6tzeEXDSqBGdIx3hab4Z9RvpxUwB7ja3E9cmbycfVO4UOCwVyKGAPbB0rYS6guSJezJoC2Avkue++cqhzGlxXrLSR0bqogJ3RuXBBgcLvRB5zC+SzJoumAH48e+yyZq98Gy40lBqq5ytFpFy7At4y6lA6t3m5qsTIdU05mgLY3lFym9/k8FbJsFAgpwLWZYMrt9nbF13LaQrg3XBkm8ylVVQXF27KnE8kHwrcjgTLC8jg3WDGTjFrCuDH4cF6kLMX9nrdDZnzIPmwUGBV77sksw7GivE0J3M+D0u+KYDTqrBBlssc0pS6QyZXGSLd8VEgd11LseI3zopZUwBfhQd+BS6X2UBcCl5sDwsFSihgfV4AOQPML+JcU6IwKY+mAF7IDjemnTK8WtDzIGcjkcHtSHKMFfBuLO/6y9lpLCb9K0pq1BTAtlQ5F5ccPv8Kiq7YlRQ28qpOATsNF01z98D+CEExawpgvxm0LKMXd5O23zjKuUiW0f1IegwVMIDtgf22Ui6zl0/rR7ny6Dtdv9vorY4G2Sixd38XeLdXWChQUgHr3DfBQBtlnTat78JzoBrzG0lfg1EX9ATS9A6ssFCgCwVeSKb2xNZrp3LD1O/e419JWmklms3uzeH1UeDdUqmQvQ6n9wZ5vYe03gphoUAXChhg9sKfAKdxg9Tdyfv2xoILV7uDVjSIvVmjyXTYCbkrxXNhWxjGOYctX4fvwK0QFgp0oYALp9Y/v6zjKLPtvfgpFgzeY6Day6IW9G3Q2xNPbo1m+t8FBAtpQxAWCtSggL86cyy4Kj1T/W363OD9C8h9kwhZDGf2vnvB6+F0mKpAvUOK9Lkrct+HN8AcmK7H5+OwUKCYArPIaTYcDB+GqTqoqeq0dXsFfAmOgM2hM+u35XAoLddNvBqIj4HNYM7EtkMKC7cUvIa8ErzudjY4vEg3baT9eCssFOhEAeugPe+yCZZP/P9qXh0t7gRzwP00p3/ucyMsAW9COgeuBYO8M0sO9uOA+xqg2tHg3MFfHzgA9gZbtGXgsMLWTK6CsFCgZgVSvfb778+E34KjzQPB9xxFWo9lIXiPhJdXw0KBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgZEqMMiPx48040gsFFjbFWgbXD5WZcsJtuJ1C/BxpD6W4g7wMaI+fsVnyNwPYaFA7QoYC6kuW5+3Buv5Q3A3WK99SsPv4QaowgYJYAPUoLRwT4CDYP4Ej+NVuxPOBR8/YeFPgF9CWChQqwLWaev27vAUmAc+LuiJYF3XFsH5cCvYOR0DBvRYmYX5M/gB+OAyWyUfWObzVn1mktha/QYspO+fBIdArw3SaPQeF9uhwKgU6K2Ddj4fggvBOm3dtQ5bl1O9ti5b133fB5x9EFJws1mn9RbSHvdzcA04JE4Fm+nVJxr6FLevwkthY9B60179TvwNBcookOqeI8RXwfdgOfQG7Ez1ejH7/wQ+Cr0dVEqbt+uwjXBDB48HW6CZCtb7+eRnqzqUfjlsCmGhQJcK2Hu+Bi6A3jo76LYxcRw45F4fqrO5eHQiOISwcJODctACG8QvhrBQoEsFHA0OG7wpFhxWfx327LJATXk/jQ8GDdKZ9rdB2K0pw3g/FCiggItQM9XTQT53Zfot4JWZoubqW5P5lPIXNn04xPtP5tiXDXF8HBoKDKpA79zU+ueQd5TmSvYr4cBRJjpMWg4H/hGWwiAtUb/7/g/p2kBM14DwcVgoMDIFXHs5HE4BLwH1W1f73c9e2NVpg7mYNQWQQ2fnqrMzebI36T4JXAkMCwVKKLAVmbwAjgK3DcxRmoF7MOw3ykRnSqspgB/gQFfYctksEvZGkHRZKVc+kW4okBRw0ckFp1Sve4fVaZ9hXx1V7jNsIoMc3xTA9pA6k8s2IWHnIRHAuRSOdCcr4GjPOaqXRnPZLiRcdDW6KYDtHXUmlzkfiSF0LnUj3akU2Hyiztl55DKvrngrZjFrCuAdM3vg8MU8HEqHhQIlFNiQTLaBHENn/U9z6h38p5Q1BbA3buS2dCE8dz6RfiigAinAcqmRGgZXo4tZUwAXcyAyCgVCgfYKRAC31y6ODAU6VyACuPNTEA6EAu0ViABur10cGQp0rkAEcOenIBwIBdorEAHcXrs4MhToXIEI4M5PQTgQCrRXIAK4vXZxZCjQuQIRwJ2fgnAgFGivQARwe+3iyFCgcwUigDs/BeFAKNBegQjg9trFkaFA5wpEAHd+CsKBUKC9AhHA7bWLI0OBzhWIAO78FIQDoUB7BSKA22sXR4YCnSsQAdz5KQgHQoH2CkQAt9cujgwFOlcgArjzUxAOhALtFYgAbq9dHBkKdK5ABHDnpyAcCAXaKxAB3F67ODIU6FyBCODOT0E4EAq0VyACuL12cWQo0LkCTQFc4hGJTXl3Lko4EAoMoUDRet2UmU9xy23m4dMZwkKBEgpY1x4qkNFvC+SxJoumAF7AHnet2Wv0GwbvRXD/6JOOFEOBKRW4l3cvht9N+elo3vTB4VeOJqn+UmkK4Ks4/Kb+kmi1130cdQFEALeSLw5qoYDPBb4Uco4ul5L+QihmTQFsD7wigxfpAVPrk7YtYokhTYZiRJJjqID1zVFfzgf3+WCz9ADxIhI1BbCtyKIMHvgENwu5GJZDTjFJPiwUWKOAo74L4eY174x2w57den3raJNtn9prJxyy1xwlDp1f1d6tODIUaK2AI7+vwu0wyjptWifAIVCNzcWTz8CoC3pcNSUMR9Y1BRwBPgPOAeu1K9PD1O/e448irersmXh0LqRC9jqc3hvk1YWxN0BYKNCVAhuQ8fvBNZ5B6u7kfVMs+HoWzAEtPeh79X+Z/643Q/q38bnDjV1gJ2iaM/PRjKZgx8L3IOclqhkdiR3WaQUMuJVgQO4Lj4E2ltZzfsHBjlS9fJTzElUbH9cc80q2fg4PgBeqJ7dGTf+7yiw3wIdge9CKtlKrs4y/ocDDFNiZ/z4By8EFKBdUm+rx5PddiPW9n8FLoHrbBA83hWfBt2FygZr+v5t9T4TnwlYQgYsIYVUo4OhzC9gG3gxeYmqqx5PfX8S+H4UDwYWxzqzfzNMNF2fgqT2whTeotwaHIZuDZkGvgutgKZwPCrMMbOXCQoFaFHBkaAejnQR2Lq8Gr+PuDtbrZHeyYdDK1WC9dsjstNAheWc2SI/ovgao9gww+A3gvWAOuDjg3OJaWAJLJ+AlLBSoVoFUr53eHQrOY2fD3rADOG20Pl8/wWJeDeiwUCAUqESBQTqySlwON0KBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBUCAUCAVCgVAgFAgFQoFQIBQIBUKBKRWI39ydUpZ4MxQYXoG2weWjVB4LO8JO4C/Y+7iVO+BG8CnlPqnBx1DcC2GhwDgoYH22LsvO4PO8fJDZr+Am8LErPqlhIVRhgwSwAboZWLAnwrwJDuDVx1JoPpbiYrgGfGzj8fAD+AOEhQI1KrAhTtkh+SykJ0+87sfrfEjPDvORQZfA7eDzlD4FS2GsbBe8fQdcCLZCBqUPdvK1F99L7/+Q7SMgLBSoUQED1ED9HCyDVKdT/Z1cr/0/BfActqu23t75OXh6MvjA799Cb8Gm23b4vAR+DG8Ge3CtN+3V78TfUKCMAqnuOTy2Q7oIfFiZQ+Xp6nL6zOC+Ba6Dr8MzIFlKO/3f+eumeHA4fBccHqdC9PM6uRVz3vBW2BbCQoEuFXD95p3gVK+futy0jzFxCvwxzILqzEeHpjmshZgclE0Fa3p/AWm8vrpShkPrmgKvo8DXQlM97ef9FAv23KeCa0HV2XPxqJ/CDLLPaaS5f3UlDYfWJQW+RmEHqbP97Pt+0nT1uqg9eprc7H1fMs3nbT86mANf3vbgOC4UaKFA79z0SI53lXnU9goStG5XYT6p/Fjwem4/rc+g+5xFuo+DtEzPZlgokFWBLUndIPsp3AeD1tl+9v846W4MxaypB7aFOgxyLTjNIe0nwiYQFgqUUMAF2SeB13q9R8GAHLXNI8EcvXujn00B7JDDa725zIvnXn+LAM6lcKQ7lQIG7W8mPugdVk+1b5v3duEgbwgpZk0BbCviHVe5zMC1tSo63MhVmEh3LBTYDC/tNHLWOWNmz5JqNAXwgTiRc0XNIYxDaF/DQoESChjA1rmcAbwj6T++RGFSHk0BnOa+OeYJKe+t00a8hgIFFNiAPEp0GN7hVcyaAviOCQ9yzBNS4bwQ7k3kYaFACQVydka9/ntjRzFrCuAFeHBbZi9sHLx7JXrizEJH8muvAk0BXKrEfjHiwVKZRT6hwNqmQFMAe4OF3//NbX7LaW7uTCL9UGBtVaApgB0+5/4lDYfQR4MX1sNCgVCghQJNAXw1aa1okd6gh3h3zFPA+67DQoFQYEAFmgJ4FumUuk/5j8nr+QP6HbuHAqEACjQF8PV8dnMhheaQz4vAe6+T5bx8lfKI11Bg7BVo6mUvoWTLC5TOa3MG66HwXrBBORdy3odN8mGhwNqhQFMP7OWdGwsU0eA1iJ0L+93j90AsaiFCWCjQjwJNPbDHuoh1E+T8UoP59A6XX8z/BrR3aZ0NYaFAKDCNAtNd6/UHu9IN4NMkMfKPdiLFZ8Hh4Ahhf/De7LtAn5qstyFo2ifef6QC64pujvLe/sjij/wdr+CcNPJUGxKc6eT50zcnNxyb4+00J05pn8+G790JS8BRgY3OrbAMbgFvUr8Mfg2Wx/1rte1wzAbKUc2u4De+HG3kNnURnzCgbt7r7v8XwUMT2zXrpmaJ3djeZsJvXvoyp4R7wZv72nu4nVwAPg6sl/2a58Lz4Ij3Brgf/N7y5aD5+ZTnxw+mM+ejZ8F0Q+3pjs/1mT3xpaBYfrdYwU4HRajNPJF+Q2VPUM99wFHFPLBXKGn3kJm6ub7huT8GUiPJZlXmjz54n7xaqdveoHZ+1XWQ4GD3YmaQzRRT0zmzkg8NWs+TndOnYAm0NiuZAWxroHO18kN8ex5o9tDDiLgqkRH90Y/94BNgY1Obfg71ngRa04Lm6k/L/p1Fdl6Z+DzYK9WmWwl/7qbcBvBc0FqdH1vAN8HtoNMO90o4P2ge9+GXrdfP4S3wGNC6COSUp6OWV8OZ4G2pD8Kg5cq9v5VkBXwfXgbJUhnS/yVeU55bkNk7wJGCuvn1vNw61Ji+o0njztHSN+AZ0Mr8nZ+zwQRrLOhknxbi51vB+WZX5hzN4HV4WmujN1k3z/HLocvvaO9I/u+Ea2Cyf+vy/87hT4Gnw8OmDw43ZzLH41bIvWDLmXbu8HNPsK24gbs72CNfAV2YP93yMXAYqE/JNzars+Sbi0M21teBw/0u7AVk+ndgXQtbrYDnx9HcHmAcOkd2EXeV9RPAjr2XwzxwPlerGSjJtp/YsDdeU9j0YYHXo8nDqUeyXt/Se7W89vq2K07ZYF8Fd3XgoCOnIzvIt+Ys0/kxVvcFG9fF8Gvo6zu/tgDOlRwKevnDlnocbDOcdDneeXFJeyqZvQZcMR1H2wqnHb1cXNj5o8jPIfy41K/C8qzKzlhMvfAS3+mnB151JH+Meu1psCGklsH3ajTncjY6Z4Ktlds5bRaJHw7vgiPA/8fRrCD2vjZ8zr1ym+fJEcu74QnwsDke/4f9vwLGnNfDr4UL4fcOj/sxD/Ta60/htH4OqGSf3fHDSrFRAX9sDJ1iOO/1+q6t5biac9CDod/6MUw5/ZnXA2E+uD3OuuF+ETuAXFaN8Po9QUlUr8mdCMuKuDl8JlYIK4YjhhLmYsMDExnZ6I2r2QsbVP3Wj2HKqU42fqm3H2fdhtFhkGNns/PeHjDoCVJku+4vwzgEsT2hi28lAtjgtdey8o+7bUsBvANq0PrRptybcJCNbLp23yaNde2Y3SjwXAvd5gTdw3FfgZ9D7nklWQxlLmSVGkIbwIdAzZfa+hXThaxSQ2gD2MtuEcD9np1HPcrr5a5Itwpgh9N3w6fhePBOmRotDfu9pOQQLbc59LPn0lLeq/8br7/Jd8tSYjhrw7fFeEnUqbfp/KzqKNr0wHrvib0UPgOnwr1Qm6XKdzOOPVTAOYVN15xT3gWyHXkWyXfLkirLyDPpSdAOwFXvsP4USOfnTndvG8DpxF5BGu8Bh9M1ngQblgsgLZCwmc2siK4PrBI2Wy5lEr6DbC6GElMkL/GdD76G9afACnbzZpvWAdybzUr+eQv8Gyzp/aCCbQPYilgigO3lF8DaEMC3UI7LoEQA/4Z8LgdvugnrTwHjzPvFRxLAnmRbhK+Bi1uLoWtLIwRXn0tUQstrD+y0wvWBcbWk2ywKUEo3A9hG9nfjKloHfrums+r8tB1CT+Wzc6bj4auwFByGpQrBZlFznpCGZpewXaJyGMBWxEUwrqZuNkDnwUIoEcTpPC0jv7DpFTCeHB2dC4vddZSrs578+8DbvByy3gZeh90a/Ky0OS//VzgdHiyQueI6DHTV27tkLPc42ndw+j/ASlKiAbaRcNqxO3htc3MIm1oBtbKD/DxcM/Uuw79rr74xeHngFXAOWBESOpG2c77+E/l0YVZCRc5Ztpxpv7EL0cjTG26+BznLtjakPb/3/IyyB07pKpLDSQPVWy9/AWeBvaCtrPOrHGa+qad3+PcNyNZKTVMAexPLvgfsPLFfr28Tb1Xz0uvbL/HqRPC8lTaHhtaNvWG70plXnF86P8bPqXAyONJdZTkCOKVtMDmU/hV4uekGSCfmHrZHPcRMwWten4WfgvOrLszK6BRilwmSb134MlOe+uYK+tnwabgISqzak80jzIbDc+YoZttHfLpuvuH5caHvNDgGloId5CrLGcApj/S6gg0XthbA1bAD2FPpnBVoQ2hrHm+r5ILVsfBN8Lp0F4FjngbAVWBD5XRCfH+YMnL4yM1WXd0cIX0BbOH1vSvdDF5HTdYJb4O1fkquURtJV22eCy+FGrxfBEezBm8X54dsV2ecMvfE/DV8BAw4hwptWcKx/w7Oo5yD12KprE/GoW9D2/LlOu5KfPp7cLhfkyXdjsSpH0Gu8tecrsF7DrwZdoIpLQk15YcF3tyGPDYAeyad3A8OAldy7R0UeH9wZdIV7R1As3CXghXwanDF1JbbXvcBqM3U+SnwTrBcs+FgsJcpaV4iuhwcBamdui0FdVszLGO7FrORfz68Eeyd94T5YJ2p0ayvw8SUo9SF4Pmxfl8ATkE9b45WH2HDZPaIxEbwhhV6VzBYdVhBdgSHoCnIPanOL5fDSrCAN0OtpsaWw0p3EDjct+GynDZUU54Y3h+16Yc63QRWFLVzSlOrJd1svG3YbfjUS93UTx37NYfk+4Ajvty2iAy+BJ7vfuPL/SyP56UXpzdhFSjQ74mswNWqXBilbvtSMhvS3HyvpII1zRdLlrt0XlaasMEVGKVuWw2efasjZrU6quVBEcAthYvDQoEaFIgAruEshA+hQEsFIoBbCheHhQI1KBABXMNZCB9CgZYKRAC3FC4OCwVqUCACuIazED6EAi0ViABuKVwcFgrUoEAEcA1nIXwIBVoqEAHcUrg4LBSoQYEI4BrOQvgQCrRUIAK4pXBx2NgpMMr7qqcqfLrtc/2pPsz1XgRwLmUj3doU8NtMOb/dkxqIot/wigCurZqFP7kU8NdRzof7c2VAun4V0O/yFrMI4GJSR0YdK+APAvhjBv4YRC67gYT9gYliFgFcTOrIqGMFHD77m2mD/BDAoC77oxNFLQK4qNyRWYcK+ONw58HSTD74yzBnwpWZ0o9kQ4FQAAU+CTfCqH+Z41OkOQfCQoFQIJMCrhTPg9Nh1AHsjxYWN38gLiwUWJcUuIXCOlfdE7YbouA2ADYI/pqnv6d9CvjrkUUtArio3JFZJQo4hHZVei5s2+NTCsqetxo3DV5/7fI0+CxcB8V/mjcCGNXD1ikFDDyD159/dWV6IzBw/TG6fleRvRR1O3wX/AnZX4DBa9phoUAoUFiB55FfegKEgeilpvS75Aa32+k9g/9s+EtIDxpgsxtbv5tsI9dQoCoFDF574jvBAPVH4A8Ff5xdWwneBHLZBD4xwbm0+3Zq0eV3Kn9kXpECW+CLc2J74O3AR/1sCd5DbbD6VAuv9frq0DssFAgFKlEgOrJKTkS4EQqsUwr8H4DFXUwZwWxSAAAAAElFTkSuQmCC'; diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts index 96280cf967..d450422aa3 100644 --- a/packages/core/src/js/feedback/integration.ts +++ b/packages/core/src/js/feedback/integration.ts @@ -1,27 +1,102 @@ import { type Integration, getClient } from '@sentry/core'; -import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackWidgetTheme } from './FeedbackWidget.theme'; +import type { FeedbackButtonProps, FeedbackWidgetProps, ScreenshotButtonProps } from './FeedbackWidget.types'; export const MOBILE_FEEDBACK_INTEGRATION_NAME = 'MobileFeedback'; type FeedbackIntegration = Integration & { options: Partial; + buttonOptions: Partial; + screenshotButtonOptions: Partial; + colorScheme?: 'system' | 'light' | 'dark'; + themeLight: Partial; + themeDark: Partial; }; -export const feedbackIntegration = (initOptions: FeedbackWidgetProps = {}): FeedbackIntegration => { +export const feedbackIntegration = ( + initOptions: FeedbackWidgetProps & { + buttonOptions?: FeedbackButtonProps; + screenshotButtonOptions?: ScreenshotButtonProps; + colorScheme?: 'system' | 'light' | 'dark'; + themeLight?: Partial; + themeDark?: Partial; + } = {}, +): FeedbackIntegration => { + const { + buttonOptions, + screenshotButtonOptions, + colorScheme, + themeLight: lightTheme, + themeDark: darkTheme, + ...widgetOptions + } = initOptions; + return { name: MOBILE_FEEDBACK_INTEGRATION_NAME, - options: initOptions, + options: widgetOptions, + buttonOptions: buttonOptions || {}, + screenshotButtonOptions: screenshotButtonOptions || {}, + colorScheme: colorScheme || 'system', + themeLight: lightTheme || {}, + themeDark: darkTheme || {}, }; }; +const _getClientIntegration = (): FeedbackIntegration => { + return getClient()?.getIntegrationByName>(MOBILE_FEEDBACK_INTEGRATION_NAME); +}; + export const getFeedbackOptions = (): Partial => { - const integration = getClient()?.getIntegrationByName>( - MOBILE_FEEDBACK_INTEGRATION_NAME, - ); + const integration = _getClientIntegration(); if (!integration) { return {}; } return integration.options; }; + +export const getFeedbackButtonOptions = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.buttonOptions; +}; + +export const getScreenshotButtonOptions = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.screenshotButtonOptions; +}; + +export const getColorScheme = (): 'system' | 'light' | 'dark' => { + const integration = _getClientIntegration(); + if (!integration) { + return 'system'; + } + + return integration.colorScheme; +}; + +export const getFeedbackLightTheme = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.themeLight; +}; + +export const getFeedbackDarkTheme = (): Partial => { + const integration = _getClientIntegration(); + if (!integration) { + return {}; + } + + return integration.themeDark; +}; diff --git a/packages/core/src/js/feedback/lazy.ts b/packages/core/src/js/feedback/lazy.ts index ba02365c2f..c3d2b2727d 100644 --- a/packages/core/src/js/feedback/lazy.ts +++ b/packages/core/src/js/feedback/lazy.ts @@ -25,3 +25,29 @@ export function lazyLoadAutoInjectFeedbackIntegration(): void { getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_INTEGRATION_NAME }); } } + +export const AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileFeedbackButton'; + +/** + * Lazy loads the auto inject feedback button integration if it is not already loaded. + */ +export function lazyLoadAutoInjectFeedbackButtonIntegration(): void { + const integration = getClient()?.getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME); + if (!integration) { + // Lazy load the integration to track usage + getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME }); + } +} + +export const AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileScreenshotButton'; + +/** + * Lazy loads the auto inject screenshot button integration if it is not already loaded. + */ +export function lazyLoadAutoInjectScreenshotButtonIntegration(): void { + const integration = getClient()?.getIntegrationByName(AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME); + if (!integration) { + // Lazy load the integration to track usage + getClient()?.addIntegration({ name: AUTO_INJECT_SCREENSHOT_BUTTON_INTEGRATION_NAME }); + } +} diff --git a/packages/core/src/js/feedback/utils.ts b/packages/core/src/js/feedback/utils.ts index 9c2826981d..6644bd7468 100644 --- a/packages/core/src/js/feedback/utils.ts +++ b/packages/core/src/js/feedback/utils.ts @@ -18,6 +18,15 @@ export function isModalSupported(): boolean { return !(isFabricEnabled() && major === 0 && minor < 71); } +/** + * The native driver supports color animations since React Native 0.69. + * ref: https://github.com/facebook/react-native/commit/201f355479cafbcece3d9eb40a52bae003da3e5c + */ +export function isNativeDriverSupportedForColorAnimations(): boolean { + const { major, minor } = ReactNativeLibraries.ReactNativeVersion?.version || {}; + return major > 0 || minor >= 69; +} + export const isValidEmail = (email: string): boolean => { const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; return emailRegex.test(email); diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 4508684156..1a163a2b06 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -12,6 +12,8 @@ export type { Thread, User, UserFeedback, + ErrorEvent, + TransactionEvent, } from '@sentry/core'; export { @@ -79,11 +81,16 @@ export { startIdleNavigationSpan, startIdleSpan, getDefaultIdleNavigationSpanOptions, + createTimeToFullDisplay, + createTimeToInitialDisplay, } from './tracing'; export type { TimeToDisplayProps } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; +export { FeedbackButton } from './feedback/FeedbackButton'; export { FeedbackWidget } from './feedback/FeedbackWidget'; -export { showFeedbackWidget } from './feedback/FeedbackWidgetManager'; +export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton } from './feedback/FeedbackWidgetManager'; + +export { getDataFromUri } from './wrapper'; diff --git a/packages/core/src/js/integrations/appRegistry.ts b/packages/core/src/js/integrations/appRegistry.ts new file mode 100644 index 0000000000..2467d73876 --- /dev/null +++ b/packages/core/src/js/integrations/appRegistry.ts @@ -0,0 +1,56 @@ +import type { Client, Integration } from '@sentry/core'; +import { getClient, logger } from '@sentry/core'; + +import { isWeb } from '../utils/environment'; +import { fillTyped } from '../utils/fill'; +import { ReactNativeLibraries } from '../utils/rnlibraries'; + +export const INTEGRATION_NAME = 'AppRegistry'; + +export const appRegistryIntegration = (): Integration & { + onRunApplication: (callback: () => void) => void; +} => { + const callbacks: (() => void)[] = []; + + return { + name: INTEGRATION_NAME, + setupOnce: () => { + if (isWeb()) { + return; + } + + patchAppRegistryRunApplication(callbacks); + }, + onRunApplication: (callback: () => void) => { + if (callbacks.includes(callback)) { + logger.debug('[AppRegistryIntegration] Callback already registered.'); + return; + } + callbacks.push(callback); + }, + }; +}; + +export const patchAppRegistryRunApplication = (callbacks: (() => void)[]): void => { + const { AppRegistry } = ReactNativeLibraries; + if (!AppRegistry) { + return; + } + + fillTyped(AppRegistry, 'runApplication', originalRunApplication => { + return (...args) => { + callbacks.forEach(callback => callback()); + return originalRunApplication(...args); + }; + }); +}; + +export const getAppRegistryIntegration = ( + client: Client | undefined = getClient(), +): ReturnType | undefined => { + if (!client) { + return undefined; + } + + return client.getIntegrationByName(INTEGRATION_NAME); +}; diff --git a/packages/core/src/js/integrations/default.ts b/packages/core/src/js/integrations/default.ts index f41f68c058..c1be11f359 100644 --- a/packages/core/src/js/integrations/default.ts +++ b/packages/core/src/js/integrations/default.ts @@ -3,8 +3,9 @@ import type { Integration } from '@sentry/core'; import type { ReactNativeClientOptions } from '../options'; import { reactNativeTracingIntegration } from '../tracing'; -import { isExpoGo, notWeb } from '../utils/environment'; +import { notWeb } from '../utils/environment'; import { + appRegistryIntegration, appStartIntegration, breadcrumbsIntegration, browserApiErrorsIntegration, @@ -32,6 +33,7 @@ import { sdkInfoIntegration, spotlightIntegration, stallTrackingIntegration, + timeToDisplayIntegration, userInteractionIntegration, viewHierarchyIntegration, } from './exports'; @@ -111,15 +113,17 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ integrations.push(userInteractionIntegration()); } if (hasTracingEnabled && options.enableAutoPerformanceTracing) { + integrations.push(appRegistryIntegration()); integrations.push(reactNativeTracingIntegration()); } + if (hasTracingEnabled) { + integrations.push(timeToDisplayIntegration()); + } if (options.enableCaptureFailedRequests) { integrations.push(httpClientIntegration()); } - if (isExpoGo()) { - integrations.push(expoContextIntegration()); - } + integrations.push(expoContextIntegration()); if (options.spotlight) { const sidecarUrl = typeof options.spotlight === 'string' ? options.spotlight : undefined; diff --git a/packages/core/src/js/integrations/expocontext.ts b/packages/core/src/js/integrations/expocontext.ts index 8e1db1a3ab..b805daf118 100644 --- a/packages/core/src/js/integrations/expocontext.ts +++ b/packages/core/src/js/integrations/expocontext.ts @@ -1,21 +1,122 @@ -import type { DeviceContext, Event, Integration, OsContext } from '@sentry/core'; +import { type DeviceContext, type Event, type Integration, type OsContext, logger } from '@sentry/core'; -import { getExpoDevice } from '../utils/expomodules'; +import type { ReactNativeClient } from '../client'; +import { isExpo, isExpoGo } from '../utils/environment'; +import { getExpoDevice, getExpoUpdates } from '../utils/expomodules'; +import { NATIVE } from '../wrapper'; const INTEGRATION_NAME = 'ExpoContext'; +export const OTA_UPDATES_CONTEXT_KEY = 'ota_updates'; + /** Load device context from expo modules. */ export const expoContextIntegration = (): Integration => { + let _expoUpdatesContextCached: ExpoUpdatesContext | undefined; + + function setup(client: ReactNativeClient): void { + client.on('afterInit', () => { + if (!client.getOptions().enableNative) { + return; + } + + setExpoUpdatesNativeContext(); + }); + } + + function setExpoUpdatesNativeContext(): void { + if (!isExpo() || isExpoGo()) { + return; + } + + const expoUpdates = getExpoUpdatesContextCached(); + + try { + // Ensures native errors and crashes have the same context as JS errors + NATIVE.setContext(OTA_UPDATES_CONTEXT_KEY, expoUpdates); + } catch (error) { + logger.error('Error setting Expo updates context:', error); + } + } + + function processEvent(event: Event): Event { + if (!isExpo()) { + return event; + } + + addExpoGoContext(event); + addExpoUpdatesContext(event); + return event; + } + + function addExpoUpdatesContext(event: Event): void { + event.contexts = event.contexts || {}; + event.contexts[OTA_UPDATES_CONTEXT_KEY] = { + ...getExpoUpdatesContextCached(), + }; + } + + function getExpoUpdatesContextCached(): ExpoUpdatesContext { + if (_expoUpdatesContextCached) { + return _expoUpdatesContextCached; + } + + return (_expoUpdatesContextCached = getExpoUpdatesContext()); + } + return { name: INTEGRATION_NAME, - setupOnce: () => { - // noop - }, + setup, processEvent, }; }; -function processEvent(event: Event): Event { +/** + * @internal Exposed for testing purposes + */ +export function getExpoUpdatesContext(): ExpoUpdatesContext { + const expoUpdates = getExpoUpdates(); + if (!expoUpdates) { + return { + is_enabled: false, + }; + } + + const updatesContext: ExpoUpdatesContext = { + is_enabled: !!expoUpdates.isEnabled, + is_embedded_launch: !!expoUpdates.isEmbeddedLaunch, + is_emergency_launch: !!expoUpdates.isEmergencyLaunch, + is_using_embedded_assets: !!expoUpdates.isUsingEmbeddedAssets, + }; + + if (typeof expoUpdates.updateId === 'string' && expoUpdates.updateId) { + updatesContext.update_id = expoUpdates.updateId.toLowerCase(); + } + if (typeof expoUpdates.channel === 'string' && expoUpdates.channel) { + updatesContext.channel = expoUpdates.channel.toLowerCase(); + } + if (typeof expoUpdates.runtimeVersion === 'string' && expoUpdates.runtimeVersion) { + updatesContext.runtime_version = expoUpdates.runtimeVersion.toLowerCase(); + } + if (typeof expoUpdates.checkAutomatically === 'string' && expoUpdates.checkAutomatically) { + updatesContext.check_automatically = expoUpdates.checkAutomatically.toLowerCase(); + } + if (typeof expoUpdates.emergencyLaunchReason === 'string' && expoUpdates.emergencyLaunchReason) { + updatesContext.emergency_launch_reason = expoUpdates.emergencyLaunchReason; + } + if (typeof expoUpdates.launchDuration === 'number') { + updatesContext.launch_duration = expoUpdates.launchDuration; + } + if (expoUpdates.createdAt instanceof Date) { + updatesContext.created_at = expoUpdates.createdAt.toISOString(); + } + return updatesContext; +} + +function addExpoGoContext(event: Event): void { + if (!isExpoGo()) { + return; + } + const expoDeviceContext = getExpoDeviceContext(); if (expoDeviceContext) { event.contexts = event.contexts || {}; @@ -27,8 +128,6 @@ function processEvent(event: Event): Event { event.contexts = event.contexts || {}; event.contexts.os = { ...expoOsContext, ...event.contexts.os }; } - - return event; } /** @@ -66,3 +165,17 @@ function getExpoOsContext(): OsContext | undefined { name: expoDevice.osName, }; } + +type ExpoUpdatesContext = Partial<{ + is_enabled: boolean; + is_embedded_launch: boolean; + is_emergency_launch: boolean; + is_using_embedded_assets: boolean; + update_id: string; + channel: string; + runtime_version: string; + check_automatically: string; + emergency_launch_reason: string; + launch_duration: number; + created_at: string; +}>; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index f5fabb397e..e87a88c615 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -20,6 +20,8 @@ export { nativeFramesIntegration, createNativeFramesIntegrations } from '../trac export { stallTrackingIntegration } from '../tracing/integrations/stalltracking'; export { userInteractionIntegration } from '../tracing/integrations/userInteraction'; export { createReactNativeRewriteFrames } from './rewriteframes'; +export { appRegistryIntegration } from './appRegistry'; +export { timeToDisplayIntegration } from '../tracing/integrations/timeToDisplayIntegration'; export { breadcrumbsIntegration, @@ -32,4 +34,5 @@ export { inboundFiltersIntegration, linkedErrorsIntegration as browserLinkedErrorsIntegration, rewriteFramesIntegration, + extraErrorDataIntegration, } from '@sentry/react'; diff --git a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts index 4025dc13d5..df56da47cb 100644 --- a/packages/core/src/js/integrations/reactnativeerrorhandlers.ts +++ b/packages/core/src/js/integrations/reactnativeerrorhandlers.ts @@ -1,6 +1,14 @@ import type { EventHint, Integration, SeverityLevel } from '@sentry/core'; -import { addExceptionMechanism, captureException, getClient, getCurrentScope, logger } from '@sentry/core'; - +import { + addExceptionMechanism, + addGlobalUnhandledRejectionInstrumentationHandler, + captureException, + getClient, + getCurrentScope, + logger, +} from '@sentry/core'; + +import { isHermesEnabled, isWeb } from '../utils/environment'; import { createSyntheticError, isErrorLike } from '../utils/error'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; import { checkPromiseAndWarn, polyfillPromise, requireRejectionTracking } from './reactnativeerrorhandlersutils'; @@ -44,49 +52,83 @@ function setup(options: ReactNativeErrorHandlersOptions): void { * Setup unhandled promise rejection tracking */ function setupUnhandledRejectionsTracking(patchGlobalPromise: boolean): void { - if (patchGlobalPromise) { - polyfillPromise(); - } + try { + if ( + isHermesEnabled() && + RN_GLOBAL_OBJ.HermesInternal?.enablePromiseRejectionTracker && + RN_GLOBAL_OBJ?.HermesInternal?.hasPromise?.() + ) { + logger.log('Using Hermes native promise rejection tracking'); + + RN_GLOBAL_OBJ.HermesInternal.enablePromiseRejectionTracker({ + allRejections: true, + onUnhandled: promiseRejectionTrackingOptions.onUnhandled, + onHandled: promiseRejectionTrackingOptions.onHandled, + }); - attachUnhandledRejectionHandler(); - checkPromiseAndWarn(); + logger.log('Unhandled promise rejections will be caught by Sentry.'); + } else if (isWeb()) { + logger.log('Using Browser JS promise rejection tracking for React Native Web'); + + // Use Sentry's built-in global unhandled rejection handler + addGlobalUnhandledRejectionInstrumentationHandler((error: unknown) => { + captureException(error, { + originalException: error, + syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), + mechanism: { handled: false, type: 'onunhandledrejection' }, + }); + }); + } else if (patchGlobalPromise) { + // For JSC and other environments, use the existing approach + polyfillPromise(); + attachUnhandledRejectionHandler(); + checkPromiseAndWarn(); + } else { + // For JSC and other environments, patching was disabled by user configuration + logger.log('Unhandled promise rejections will not be caught by Sentry.'); + } + } catch (e) { + logger.warn( + 'Failed to set up promise rejection tracking. ' + + 'Unhandled promise rejections will not be caught by Sentry.' + + 'See https://docs.sentry.io/platforms/react-native/troubleshooting/ for more details.', + ); + } } -function attachUnhandledRejectionHandler(): void { - const tracking = requireRejectionTracking(); +const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { + onUnhandled: (id, error: unknown, rejection = {}) => { + if (__DEV__) { + logger.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`); + } - const promiseRejectionTrackingOptions: PromiseRejectionTrackingOptions = { - onUnhandled: (id, rejection = {}) => { - // eslint-disable-next-line no-console - console.warn(`Possible Unhandled Promise Rejection (id: ${id}):\n${rejection}`); - }, - onHandled: id => { - // eslint-disable-next-line no-console - console.warn( + // Marking the rejection as handled to avoid breaking crash rate calculations. + // See: https://github.com/getsentry/sentry-react-native/issues/4141 + captureException(error, { + data: { id }, + originalException: error, + syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), + mechanism: { handled: true, type: 'onunhandledrejection' }, + }); + }, + onHandled: id => { + if (__DEV__) { + logger.warn( `Promise Rejection Handled (id: ${id})\n` + 'This means you can ignore any previous messages of the form ' + `"Possible Unhandled Promise Rejection (id: ${id}):"`, ); - }, - }; + } + }, +}; + +function attachUnhandledRejectionHandler(): void { + const tracking = requireRejectionTracking(); tracking.enable({ allRejections: true, - onUnhandled: (id: string, error: unknown) => { - if (__DEV__) { - promiseRejectionTrackingOptions.onUnhandled(id, error); - } - - captureException(error, { - data: { id }, - originalException: error, - syntheticException: isErrorLike(error) ? undefined : createSyntheticError(), - mechanism: { handled: true, type: 'onunhandledrejection' }, - }); - }, - onHandled: (id: string) => { - promiseRejectionTrackingOptions.onHandled(id); - }, + onUnhandled: promiseRejectionTrackingOptions.onUnhandled, + onHandled: promiseRejectionTrackingOptions.onHandled, }); } diff --git a/packages/core/src/js/profiling/integration.ts b/packages/core/src/js/profiling/integration.ts index e2e4134fe6..14c46721c1 100644 --- a/packages/core/src/js/profiling/integration.ts +++ b/packages/core/src/js/profiling/integration.ts @@ -315,9 +315,7 @@ export function addNativeProfileToHermesProfile( return { ...hermes, profile: addNativeThreadCpuProfileToHermes(hermes.profile, native.profile, hermes.transaction.active_thread_id), - debug_meta: { - images: native.debug_meta.images, - }, + ...(native.debug_meta?.images ? { debug_meta: { images: native.debug_meta.images } } : {}), measurements: native.measurements, }; } diff --git a/packages/core/src/js/profiling/nativeTypes.ts b/packages/core/src/js/profiling/nativeTypes.ts index 06a9d3da4c..583fd363ee 100644 --- a/packages/core/src/js/profiling/nativeTypes.ts +++ b/packages/core/src/js/profiling/nativeTypes.ts @@ -39,7 +39,7 @@ export interface NativeProfileEvent { unit: string; } >; - debug_meta: { + debug_meta?: { images: { type: 'macho'; debug_id: string; diff --git a/packages/core/src/js/replay/CustomMask.tsx b/packages/core/src/js/replay/CustomMask.tsx index a43a198be0..4608dfbe04 100644 --- a/packages/core/src/js/replay/CustomMask.tsx +++ b/packages/core/src/js/replay/CustomMask.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import type { HostComponent, ViewProps } from 'react-native'; import { UIManager, View } from 'react-native'; +import { isExpoGo } from '../utils/environment'; + const NativeComponentRegistry: { get>(componentName: string, createViewConfig: () => C): HostComponent; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -35,7 +37,7 @@ const UnmaskFallback = (viewProps: ViewProps): React.ReactElement => { const hasViewManagerConfig = (nativeComponentName: string): boolean => UIManager.hasViewManagerConfig && UIManager.hasViewManagerConfig(nativeComponentName); const Mask = ((): HostComponent | React.ComponentType => { - if (!hasViewManagerConfig(MaskNativeComponentName)) { + if (isExpoGo() || !hasViewManagerConfig(MaskNativeComponentName)) { logger.warn(`[SentrySessionReplay] Can't load ${MaskNativeComponentName}.`); return MaskFallback; } @@ -48,7 +50,7 @@ const Mask = ((): HostComponent | React.ComponentType => { })() const Unmask = ((): HostComponent | React.ComponentType => { - if (!hasViewManagerConfig(UnmaskNativeComponentName)) { + if (isExpoGo() || !hasViewManagerConfig(UnmaskNativeComponentName)) { logger.warn(`[SentrySessionReplay] Can't load ${UnmaskNativeComponentName}.`); return UnmaskFallback; } diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 4f4501b138..79aa1117ec 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -31,14 +31,69 @@ export interface MobileReplayOptions { * @default true */ maskAllVectors?: boolean; + + /** + * Enables the up to 5x faster experimental view renderer used by the Session Replay integration on iOS. + * + * Enabling this flag will reduce the amount of time it takes to render each frame of the session replay on the main thread, therefore reducing + * interruptions and visual lag. + * + * - Experiment: This is an experimental feature and is therefore disabled by default. + * + * @deprecated Use `enableViewRendererV2` instead. + */ + enableExperimentalViewRenderer?: boolean; + + /** + * Enables up to 5x faster new view renderer used by the Session Replay integration on iOS. + * + * Enabling this flag will reduce the amount of time it takes to render each frame of the session replay on the main thread, therefore reducing + * interruptions and visual lag. [Our benchmarks](https://github.com/getsentry/sentry-cocoa/pull/4940) have shown a significant improvement of + * **up to 4-5x faster rendering** (reducing `~160ms` to `~36ms` per frame) on older devices. + * + * - Experiment: In case you are noticing issues with the new view renderer, please report the issue on [GitHub](https://github.com/getsentry/sentry-cocoa). + * Eventually, we will remove this feature flag and use the new view renderer by default. + * + * @default true + */ + enableViewRendererV2?: boolean; + + /** + * Enables up to 5x faster but incomplete view rendering used by the Session Replay integration on iOS. + * + * Enabling this flag will reduce the amount of time it takes to render each frame of the session replay on the main thread, therefore reducing + * interruptions and visual lag. + * + * - Note: This flag can only be used together with `enableExperimentalViewRenderer` with up to 20% faster render times. + * - Experiment: This is an experimental feature and is therefore disabled by default. + * + * @default false + */ + enableFastViewRendering?: boolean; } const defaultOptions: Required = { maskAllText: true, maskAllImages: true, maskAllVectors: true, + enableExperimentalViewRenderer: false, + enableViewRendererV2: true, + enableFastViewRendering: false, }; +function mergeOptions(initOptions: Partial): Required { + const merged = { + ...defaultOptions, + ...initOptions, + }; + + if (initOptions.enableViewRendererV2 === undefined && initOptions.enableExperimentalViewRenderer !== undefined) { + merged.enableViewRendererV2 = initOptions.enableExperimentalViewRenderer; + } + + return merged; +} + type MobileReplayIntegration = Integration & { options: Required; }; @@ -73,7 +128,7 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau return mobileReplayIntegrationNoop(); } - const options = { ...defaultOptions, ...initOptions }; + const options = mergeOptions(initOptions); async function processEvent(event: Event): Promise { const hasException = event.exception && event.exception.values && event.exception.values.length > 0; diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index ceb4c7e263..3bdb27aa22 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -8,7 +8,7 @@ import { import * as React from 'react'; import { ReactNativeClient } from './client'; -import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from './feedback/FeedbackWidgetProvider'; import { getDevServer } from './integrations/debugsymbolicatorutils'; import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; diff --git a/packages/core/src/js/tools/metroconfig.ts b/packages/core/src/js/tools/metroconfig.ts index aa4a2bfbb5..4221172b53 100644 --- a/packages/core/src/js/tools/metroconfig.ts +++ b/packages/core/src/js/tools/metroconfig.ts @@ -228,7 +228,7 @@ export function withSentryResolver(config: MetroConfig, includeWebReplay: boolea if ( (includeWebReplay === false || (includeWebReplay === undefined && (platform === 'android' || platform === 'ios'))) && - (oldMetroModuleName ?? moduleName).includes('@sentry/replay') + !!(oldMetroModuleName ?? moduleName).match(/@sentry(?:-internal)?\/replay/) ) { return { type: 'empty' } as Resolution; } diff --git a/packages/core/src/js/tracing/integrations/appStart.ts b/packages/core/src/js/tracing/integrations/appStart.ts index 0f96557d4c..df0713bc57 100644 --- a/packages/core/src/js/tracing/integrations/appStart.ts +++ b/packages/core/src/js/tracing/integrations/appStart.ts @@ -1,5 +1,5 @@ -/* eslint-disable complexity */ -import type { Client, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core'; +/* eslint-disable complexity, max-lines */ +import type { Client, Event, Integration, Span, SpanJSON, TransactionEvent } from '@sentry/core'; import { getCapturedScopesOnSpan, getClient, @@ -11,13 +11,14 @@ import { timestampInSeconds, } from '@sentry/core'; +import { getAppRegistryIntegration } from '../../integrations/appRegistry'; import { APP_START_COLD as APP_START_COLD_MEASUREMENT, APP_START_WARM as APP_START_WARM_MEASUREMENT, } from '../../measurements'; import type { NativeAppStartResponse } from '../../NativeRNSentry'; import type { ReactNativeClientOptions } from '../../options'; -import { convertSpanToTransaction, setEndTimeValue } from '../../utils/span'; +import { convertSpanToTransaction, isRootSpan, setEndTimeValue } from '../../utils/span'; import { NATIVE } from '../../wrapper'; import { APP_START_COLD as APP_START_COLD_OP, @@ -26,6 +27,7 @@ import { } from '../ops'; import { SPAN_ORIGIN_AUTO_APP_START, SPAN_ORIGIN_MANUAL_APP_START } from '../origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../semanticAttributes'; +import { setMainThreadInfo } from '../span'; import { createChildSpanJSON, createSpanJSON, getBundleStartTimestampMs } from '../utils'; const INTEGRATION_NAME = 'AppStart'; @@ -136,20 +138,40 @@ export const appStartIntegration = ({ let _client: Client | undefined = undefined; let isEnabled = true; let appStartDataFlushed = false; + let afterAllSetupCalled = false; + let firstStartedActiveRootSpanId: string | undefined = undefined; const setup = (client: Client): void => { _client = client; - const clientOptions = client.getOptions() as ReactNativeClientOptions; + const { enableAppStartTracking } = client.getOptions() as ReactNativeClientOptions; - const { enableAppStartTracking } = clientOptions; if (!enableAppStartTracking) { isEnabled = false; logger.warn('[AppStart] App start tracking is disabled.'); } + + client.on('spanStart', recordFirstStartedActiveRootSpanId); }; - const afterAllSetup = (_client: Client): void => { + const afterAllSetup = (client: Client): void => { + if (afterAllSetupCalled) { + return; + } + afterAllSetupCalled = true; + // TODO: automatically set standalone based on the presence of the native layer navigation integration + + getAppRegistryIntegration(client)?.onRunApplication(() => { + if (appStartDataFlushed) { + logger.log('[AppStartIntegration] Resetting app start data flushed flag based on runApplication call.'); + appStartDataFlushed = false; + firstStartedActiveRootSpanId = undefined; + } else { + logger.log( + '[AppStartIntegration] Waiting for initial app start was flush, before updating based on runApplication call.', + ); + } + }); }; const processEvent = async (event: Event): Promise => { @@ -167,6 +189,27 @@ export const appStartIntegration = ({ return event; }; + const recordFirstStartedActiveRootSpanId = (rootSpan: Span): void => { + if (firstStartedActiveRootSpanId) { + return; + } + + if (!isRootSpan(rootSpan)) { + return; + } + + setFirstStartedActiveRootSpanId(rootSpan.spanContext().spanId); + }; + + /** + * For testing purposes only. + * @private + */ + const setFirstStartedActiveRootSpanId = (spanId: string | undefined): void => { + firstStartedActiveRootSpanId = spanId; + logger.debug('[AppStart] First started active root span id recorded.', firstStartedActiveRootSpanId); + }; + async function captureStandaloneAppStart(): Promise { if (!standalone) { logger.debug( @@ -208,7 +251,12 @@ export const appStartIntegration = ({ async function attachAppStartToTransactionEvent(event: TransactionEvent): Promise { if (appStartDataFlushed) { - // App start data is only relevant for the first transaction + // App start data is only relevant for the first transaction of the app run + return; + } + + if (!firstStartedActiveRootSpanId) { + logger.warn('[AppStart] No first started active root span id recorded. Can not attach app start.'); return; } @@ -217,6 +265,13 @@ export const appStartIntegration = ({ return; } + if (firstStartedActiveRootSpanId !== event.contexts.trace.span_id) { + logger.warn( + '[AppStart] First started active root span id does not match the transaction event span id. Can not attached app start.', + ); + return; + } + const appStart = await NATIVE.fetchNativeAppStart(); if (!appStart) { logger.warn('[AppStart] Failed to retrieve the app start metrics from the native layer.'); @@ -242,7 +297,7 @@ export const appStartIntegration = ({ } const isAppStartWithinBounds = - !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp - MAX_APP_START_AGE_MS; + !!event.start_timestamp && appStartTimestampMs >= event.start_timestamp * 1_000 - MAX_APP_START_AGE_MS; if (!__DEV__ && !isAppStartWithinBounds) { logger.warn('[AppStart] App start timestamp is too far in the past to be used for app start span.'); return; @@ -255,6 +310,17 @@ export const appStartIntegration = ({ return; } + if (appStartDurationMs < 0) { + // This can happen when MainActivity on Android is recreated, + // and the app start end timestamp is not updated, for example + // due to missing `Sentry.wrap(RootComponent)` call. + logger.warn( + '[AppStart] Last recorded app start end timestamp is before the app start timestamp.', + 'This is usually caused by missing `Sentry.wrap(RootComponent)` call.', + ); + return; + } + appStartDataFlushed = true; event.contexts.trace.data = event.contexts.trace.data || {}; @@ -332,7 +398,8 @@ export const appStartIntegration = ({ afterAllSetup, processEvent, captureStandaloneAppStart, - }; + setFirstStartedActiveRootSpanId, + } as AppStartIntegration; }; function setSpanDurationAsMeasurementOnTransactionEvent(event: TransactionEvent, label: string, span: SpanJSON): void { @@ -360,19 +427,25 @@ function createJSExecutionStartSpan( return undefined; } + const bundleStartTimestampSeconds = bundleStartTimestampMs / 1000; + if (bundleStartTimestampSeconds < parentSpan.start_timestamp) { + logger.warn('Bundle start timestamp is before the app start span start timestamp. Skipping JS execution span.'); + return undefined; + } + if (!rootComponentCreationTimestampMs) { logger.warn('Missing the root component first constructor call timestamp.'); return createChildSpanJSON(parentSpan, { description: 'JS Bundle Execution Start', - start_timestamp: bundleStartTimestampMs / 1000, - timestamp: bundleStartTimestampMs / 1000, + start_timestamp: bundleStartTimestampSeconds, + timestamp: bundleStartTimestampSeconds, origin: SPAN_ORIGIN_AUTO_APP_START, }); } return createChildSpanJSON(parentSpan, { description: 'JS Bundle Execution Before React Root', - start_timestamp: bundleStartTimestampMs / 1000, + start_timestamp: bundleStartTimestampSeconds, timestamp: rootComponentCreationTimestampMs / 1000, origin: isRootComponentCreationTimestampMsManual ? SPAN_ORIGIN_MANUAL_APP_START : SPAN_ORIGIN_AUTO_APP_START, }); @@ -382,18 +455,22 @@ function createJSExecutionStartSpan( * Adds native spans to the app start span. */ function convertNativeSpansToSpanJSON(parentSpan: SpanJSON, nativeSpans: NativeAppStartResponse['spans']): SpanJSON[] { - return nativeSpans.map(span => { - if (span.description === 'UIKit init') { - return createUIKitSpan(parentSpan, span); - } - - return createChildSpanJSON(parentSpan, { - description: span.description, - start_timestamp: span.start_timestamp_ms / 1000, - timestamp: span.end_timestamp_ms / 1000, - origin: SPAN_ORIGIN_AUTO_APP_START, + return nativeSpans + .filter(span => span.start_timestamp_ms / 1000 >= parentSpan.start_timestamp) + .map(span => { + if (span.description === 'UIKit init') { + return setMainThreadInfo(createUIKitSpan(parentSpan, span)); + } + + return setMainThreadInfo( + createChildSpanJSON(parentSpan, { + description: span.description, + start_timestamp: span.start_timestamp_ms / 1000, + timestamp: span.end_timestamp_ms / 1000, + origin: SPAN_ORIGIN_AUTO_APP_START, + }), + ); }); - }); } /** diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts new file mode 100644 index 0000000000..52cd915634 --- /dev/null +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -0,0 +1,239 @@ +import type { Event, Integration, SpanJSON } from '@sentry/core'; +import { logger } from '@sentry/core'; + +import { NATIVE } from '../../wrapper'; +import { UI_LOAD_FULL_DISPLAY, UI_LOAD_INITIAL_DISPLAY } from '../ops'; +import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin'; +import { getReactNavigationIntegration } from '../reactnavigation'; +import { SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN } from '../semanticAttributes'; +import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span'; +import { getTimeToInitialDisplayFallback } from '../timeToDisplayFallback'; +import { createSpanJSON } from '../utils'; + +export const INTEGRATION_NAME = 'TimeToDisplay'; + +const TIME_TO_DISPLAY_TIMEOUT_MS = 30_000; +const isDeadlineExceeded = (durationMs: number): boolean => durationMs > TIME_TO_DISPLAY_TIMEOUT_MS; + +export const timeToDisplayIntegration = (): Integration => { + let enableTimeToInitialDisplayForPreloadedRoutes = false; + + return { + name: INTEGRATION_NAME, + afterAllSetup(client) { + enableTimeToInitialDisplayForPreloadedRoutes = + getReactNavigationIntegration(client)?.options.enableTimeToInitialDisplayForPreloadedRoutes ?? false; + }, + processEvent: async event => { + if (event.type !== 'transaction') { + // TimeToDisplay data is only relevant for transactions + return event; + } + + const rootSpanId = event.contexts.trace.span_id; + if (!rootSpanId) { + logger.warn(`[${INTEGRATION_NAME}] No root span id found in transaction.`); + return event; + } + + const transactionStartTimestampSeconds = event.start_timestamp; + if (!transactionStartTimestampSeconds) { + // This should never happen + logger.warn(`[${INTEGRATION_NAME}] No transaction start timestamp found in transaction.`); + return event; + } + + event.spans = event.spans || []; + event.measurements = event.measurements || {}; + + const ttidSpan = await addTimeToInitialDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + enableTimeToInitialDisplayForPreloadedRoutes, + }); + const ttfdSpan = await addTimeToFullDisplay({ event, rootSpanId, transactionStartTimestampSeconds, ttidSpan }); + + if (ttidSpan && ttidSpan.start_timestamp && ttidSpan.timestamp) { + event.measurements['time_to_initial_display'] = { + value: (ttidSpan.timestamp - ttidSpan.start_timestamp) * 1000, + unit: 'millisecond', + }; + } + + if (ttfdSpan && ttfdSpan.start_timestamp && ttfdSpan.timestamp) { + const durationMs = (ttfdSpan.timestamp - ttfdSpan.start_timestamp) * 1000; + if (isDeadlineExceeded(durationMs)) { + event.measurements['time_to_full_display'] = event.measurements['time_to_initial_display']; + } else { + event.measurements['time_to_full_display'] = { + value: durationMs, + unit: 'millisecond', + }; + } + } + + const newTransactionEndTimestampSeconds = Math.max( + ttidSpan?.timestamp ?? -1, + ttfdSpan?.timestamp ?? -1, + event.timestamp ?? -1, + ); + if (newTransactionEndTimestampSeconds !== -1) { + event.timestamp = newTransactionEndTimestampSeconds; + } + + return event; + }, + }; +}; + +async function addTimeToInitialDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + enableTimeToInitialDisplayForPreloadedRoutes, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; + enableTimeToInitialDisplayForPreloadedRoutes: boolean; +}): Promise { + const ttidEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-${rootSpanId}`); + + let ttidSpan: SpanJSON | undefined = event.spans?.find(span => span.op === UI_LOAD_INITIAL_DISPLAY); + + if (ttidSpan && (ttidSpan.status === undefined || ttidSpan.status === 'ok') && !ttidEndTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] Ttid span already exists and is ok.`, ttidSpan); + return ttidSpan; + } + + if (!ttidEndTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] No manual ttid end timestamp found for span ${rootSpanId}.`); + return addAutomaticTimeToInitialDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + enableTimeToInitialDisplayForPreloadedRoutes, + }); + } + + if (ttidSpan && ttidSpan.status && ttidSpan.status !== 'ok') { + ttidSpan.status = 'ok'; + ttidSpan.timestamp = ttidEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); + return ttidSpan; + } + + ttidSpan = createSpanJSON({ + op: UI_LOAD_INITIAL_DISPLAY, + description: 'Time To Initial Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttidEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttid span to transaction.`, ttidSpan); + event.spans.push(ttidSpan); + return ttidSpan; +} + +async function addAutomaticTimeToInitialDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + enableTimeToInitialDisplayForPreloadedRoutes, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; + enableTimeToInitialDisplayForPreloadedRoutes: boolean; +}): Promise { + const ttidNativeTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttid-navigation-${rootSpanId}`); + const ttidFallbackTimestampSeconds = await getTimeToInitialDisplayFallback(rootSpanId); + + const hasBeenSeen = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN]; + if (hasBeenSeen && !enableTimeToInitialDisplayForPreloadedRoutes) { + logger.debug( + `[${INTEGRATION_NAME}] Route has been seen and time to initial display is disabled for preloaded routes.`, + ); + return undefined; + } + + const ttidTimestampSeconds = ttidNativeTimestampSeconds ?? ttidFallbackTimestampSeconds; + if (!ttidTimestampSeconds) { + logger.debug(`[${INTEGRATION_NAME}] No automatic ttid end timestamp found for span ${rootSpanId}.`); + return undefined; + } + + const viewNames = event.contexts?.app?.view_names; + const screenName = Array.isArray(viewNames) ? viewNames[0] : viewNames; + + const ttidSpan = createSpanJSON({ + op: UI_LOAD_INITIAL_DISPLAY, + description: screenName ? `${screenName} initial display` : 'Time To Initial Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttidTimestampSeconds, + origin: SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + event.spans = event.spans ?? []; + event.spans.push(ttidSpan); + return ttidSpan; +} + +async function addTimeToFullDisplay({ + event, + rootSpanId, + transactionStartTimestampSeconds, + ttidSpan, +}: { + event: Event; + rootSpanId: string; + transactionStartTimestampSeconds: number; + ttidSpan: SpanJSON | undefined; +}): Promise { + const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + + if (!ttidSpan || !ttfdEndTimestampSeconds) { + return undefined; + } + + let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + + let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; + const ttfdIsBeforeTtid = ttidSpan?.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; + if (ttfdIsBeforeTtid) { + ttfdAdjustedEndTimestampSeconds = ttidSpan.timestamp; + } + + const durationMs = (ttfdAdjustedEndTimestampSeconds - transactionStartTimestampSeconds) * 1000; + + if (ttfdSpan && ttfdSpan.status && ttfdSpan.status !== 'ok') { + ttfdSpan.status = 'ok'; + ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; + logger.debug(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); + return ttfdSpan; + } + + ttfdSpan = createSpanJSON({ + status: isDeadlineExceeded(durationMs) ? 'deadline_exceeded' : 'ok', + op: UI_LOAD_FULL_DISPLAY, + description: 'Time To Full Display', + start_timestamp: transactionStartTimestampSeconds, + timestamp: ttfdAdjustedEndTimestampSeconds, + origin: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + parent_span_id: rootSpanId, + data: { + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, + }, + }); + logger.debug(`[${INTEGRATION_NAME}] Added ttfd span to transaction.`, ttfdSpan); + event.spans.push(ttfdSpan); + return ttfdSpan; +} diff --git a/packages/core/src/js/tracing/ops.ts b/packages/core/src/js/tracing/ops.ts index 0f574d89b9..79c7c239b1 100644 --- a/packages/core/src/js/tracing/ops.ts +++ b/packages/core/src/js/tracing/ops.ts @@ -7,3 +7,6 @@ export const UI_ACTION_TOUCH = 'ui.action.touch'; export const APP_START_COLD = 'app.start.cold'; export const APP_START_WARM = 'app.start.warm'; + +export const UI_LOAD_INITIAL_DISPLAY = 'ui.load.initial_display'; +export const UI_LOAD_FULL_DISPLAY = 'ui.load.full_display'; diff --git a/packages/core/src/js/tracing/reactnativeprofiler.tsx b/packages/core/src/js/tracing/reactnativeprofiler.tsx index 35b5d3073d..ed5a9158e8 100644 --- a/packages/core/src/js/tracing/reactnativeprofiler.tsx +++ b/packages/core/src/js/tracing/reactnativeprofiler.tsx @@ -1,11 +1,15 @@ -import { timestampInSeconds } from '@sentry/core'; +import { logger, timestampInSeconds } from '@sentry/core'; import { getClient, Profiler } from '@sentry/react'; +import { getAppRegistryIntegration } from '../integrations/appRegistry'; import { createIntegration } from '../integrations/factory'; import { _captureAppStart, _setRootComponentCreationTimestampMs } from '../tracing/integrations/appStart'; const ReactNativeProfilerGlobalState = { appStartReported: false, + onRunApplicationHook: () => { + ReactNativeProfilerGlobalState.appStartReported = false; + }, }; /** @@ -44,6 +48,14 @@ export class ReactNativeProfiler extends Profiler { } client.addIntegration && client.addIntegration(createIntegration(this.name)); + + const appRegistryIntegration = getAppRegistryIntegration(client); + if (appRegistryIntegration && typeof appRegistryIntegration.onRunApplication === 'function') { + appRegistryIntegration.onRunApplication(ReactNativeProfilerGlobalState.onRunApplicationHook); + } else { + logger.warn('AppRegistryIntegration.onRunApplication not found or invalid.'); + } + // eslint-disable-next-line @typescript-eslint/no-floating-promises _captureAppStart({ isManual: false }); } diff --git a/packages/core/src/js/tracing/reactnativetracing.ts b/packages/core/src/js/tracing/reactnativetracing.ts index d9ada0ff9d..8874f6769f 100644 --- a/packages/core/src/js/tracing/reactnativetracing.ts +++ b/packages/core/src/js/tracing/reactnativetracing.ts @@ -5,7 +5,7 @@ import { getClient } from '@sentry/core'; import { isWeb } from '../utils/environment'; import { getDevServer } from './../integrations/debugsymbolicatorutils'; -import { addDefaultOpForSpanFrom, defaultIdleOptions } from './span'; +import { addDefaultOpForSpanFrom, addThreadInfoToSpan, defaultIdleOptions } from './span'; export const INTEGRATION_NAME = 'ReactNativeTracing'; @@ -29,7 +29,10 @@ export interface ReactNativeTracingOptions { /** * Flag to disable patching all together for fetch requests. * - * @default true + * Fetch in React Native is a `whatwg-fetch` polyfill which uses XHR under the hood. + * This causes duplicates when both `traceFetch` and `traceXHR` are enabled at the same time. + * + * @default false */ traceFetch: boolean; @@ -70,7 +73,11 @@ function getDefaultTracePropagationTargets(): RegExp[] | undefined { } export const defaultReactNativeTracingOptions: ReactNativeTracingOptions = { - traceFetch: true, + // Fetch in React Native is a `whatwg-fetch` polyfill which uses XHR under the hood. + // This causes duplicates when both `traceFetch` and `traceXHR` are enabled at the same time. + // https://github.com/facebook/react-native/blob/28945c68da056ab2ac01de7e542a845b2bca6096/packages/react-native/Libraries/Network/fetch.js + // (RN Web uses browsers native fetch implementation) + traceFetch: isWeb() ? true : false, traceXHR: true, enableHTTPTimings: true, }; @@ -119,6 +126,7 @@ export const reactNativeTracingIntegration = ( const setup = (client: Client): void => { addDefaultOpForSpanFrom(client); + addThreadInfoToSpan(client); instrumentOutgoingRequests(client, { traceFetch: finalOptions.traceFetch, diff --git a/packages/core/src/js/tracing/reactnavigation.ts b/packages/core/src/js/tracing/reactnavigation.ts index dd33424b9c..58afcf1f4a 100644 --- a/packages/core/src/js/tracing/reactnavigation.ts +++ b/packages/core/src/js/tracing/reactnavigation.ts @@ -2,7 +2,6 @@ import type { Client, Integration, Span } from '@sentry/core'; import { addBreadcrumb, - getActiveSpan, getClient, isPlainObject, logger, @@ -14,25 +13,23 @@ import { timestampInSeconds, } from '@sentry/core'; -import type { NewFrameEvent } from '../utils/sentryeventemitter'; -import type { SentryEventEmitterFallback } from '../utils/sentryeventemitterfallback'; -import { createSentryFallbackEventEmitter } from '../utils/sentryeventemitterfallback'; +import { getAppRegistryIntegration } from '../integrations/appRegistry'; import { isSentrySpan } from '../utils/span'; import { RN_GLOBAL_OBJ } from '../utils/worldwide'; +import type { UnsafeAction } from '../vendor/react-navigation/types'; import { NATIVE } from '../wrapper'; import { ignoreEmptyBackNavigation } from './onSpanEndUtils'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from './origin'; import type { ReactNativeTracingIntegration } from './reactnativetracing'; import { getReactNativeTracingIntegration } from './reactnativetracing'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; +import { SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes'; import { DEFAULT_NAVIGATION_SPAN_NAME, defaultIdleOptions, getDefaultIdleNavigationSpanOptions, startIdleNavigationSpan as startGenericIdleNavigationSpan, } from './span'; -import { manualInitialDisplaySpans, startTimeToInitialDisplaySpan } from './timetodisplay'; -import { setSpanDurationAsMeasurementOnSpan } from './utils'; +import { addTimeToInitialDisplayFallback } from './timeToDisplayFallback'; export const INTEGRATION_NAME = 'ReactNavigation'; const NAVIGATION_HISTORY_MAX_SIZE = 200; @@ -61,6 +58,21 @@ interface ReactNavigationIntegrationOptions { * @default true */ ignoreEmptyBackNavigationTransactions: boolean; + + /** + * Enabled measuring Time to Initial Display for routes that are already loaded in memory. + * (a.k.a., Routes that the navigation integration has already seen.) + * + * @default false + */ + enableTimeToInitialDisplayForPreloadedRoutes: boolean; + + /** + * Whether to use the dispatched action data to populate the transaction metadata. + * + * @default false + */ + useDispatchedActionData: boolean; } /** @@ -75,15 +87,17 @@ export const reactNavigationIntegration = ({ routeChangeTimeoutMs = 1_000, enableTimeToInitialDisplay = false, ignoreEmptyBackNavigationTransactions = true, + enableTimeToInitialDisplayForPreloadedRoutes = false, + useDispatchedActionData = false, }: Partial = {}): Integration & { /** * Pass the ref to the navigation container to register it to the instrumentation * @param navigationContainerRef Ref to a `NavigationContainer` */ registerNavigationContainer: (navigationContainerRef: unknown) => void; + options: ReactNavigationIntegrationOptions; } => { let navigationContainer: NavigationContainer | undefined; - let newScreenFrameEventEmitter: SentryEventEmitterFallback | undefined; let tracing: ReactNativeTracingIntegration | undefined; let idleSpanOptions: Parameters[1] = defaultIdleOptions; @@ -97,8 +111,6 @@ export const reactNavigationIntegration = ({ let recentRouteKeys: string[] = []; if (enableTimeToInitialDisplay) { - newScreenFrameEventEmitter = createSentryFallbackEventEmitter(); - newScreenFrameEventEmitter.initAsync(); NATIVE.initNativeReactNavigationNewFrameTracking().catch((reason: unknown) => { logger.error(`${INTEGRATION_NAME} Failed to initialize native new frame tracking: ${reason}`); }); @@ -118,9 +130,21 @@ export const reactNavigationIntegration = ({ if (initialStateHandled) { // We create an initial state here to ensure a transaction gets created before the first route mounts. + // This assumes that the Sentry.init() call is made before the first route mounts. + // If this is not the case, the first transaction will be nameless 'Route Changed' return undefined; } + getAppRegistryIntegration(client)?.onRunApplication(() => { + if (initialStateHandled) { + // To avoid conflict with the initial transaction we check if it was already handled. + // This ensures runApplication calls after the initial start are correctly traced. + // This is used for example when Activity is (re)started on Android. + logger.log('[ReactNavigationIntegration] Starting new idle navigation span based on runApplication call.'); + startIdleNavigationSpan(); + } + }); + startIdleNavigationSpan(); if (!navigationContainer) { @@ -133,24 +157,28 @@ export const reactNavigationIntegration = ({ initialStateHandled = true; }; - const registerNavigationContainer = (navigationContainerRef: unknown): void => { - /* We prevent duplicate routing instrumentation to be initialized on fast refreshes - - Explanation: If the user triggers a fast refresh on the file that the instrumentation is - initialized in, it will initialize a new instance and will cause undefined behavior. - */ + const registerNavigationContainer = (maybeNewNavigationContainer: unknown): void => { if (RN_GLOBAL_OBJ.__sentry_rn_v5_registered) { - logger.log( - `${INTEGRATION_NAME} Instrumentation already exists, but register has been called again, doing nothing.`, - ); - return undefined; + logger.debug(`${INTEGRATION_NAME} Instrumentation already exists, but registering again...`); + // In the past we have not allowed re-registering the navigation container to avoid unexpected behavior. + // But this doesn't work for Android and re-recreating application main activity. + // Where new navigation container is created and the old one is discarded. We need to re-register to + // trace the new navigation container navigation. } - if (isPlainObject(navigationContainerRef) && 'current' in navigationContainerRef) { - navigationContainer = navigationContainerRef.current as NavigationContainer; + let newNavigationContainer: NavigationContainer | undefined; + if (isPlainObject(maybeNewNavigationContainer) && 'current' in maybeNewNavigationContainer) { + newNavigationContainer = maybeNewNavigationContainer.current as NavigationContainer; } else { - navigationContainer = navigationContainerRef as NavigationContainer; + newNavigationContainer = maybeNewNavigationContainer as NavigationContainer; } + + if (navigationContainer === newNavigationContainer) { + logger.log(`${INTEGRATION_NAME} Navigation container ref is the same as the one already registered.`); + return; + } + navigationContainer = newNavigationContainer as NavigationContainer; + if (!navigationContainer) { logger.warn(`${INTEGRATION_NAME} Received invalid navigation container ref!`); return undefined; @@ -182,7 +210,30 @@ export const reactNavigationIntegration = ({ * It does not name the transaction or populate it with route information. Instead, it waits for the state to fully change * and gets the route information from there, @see updateLatestNavigationSpanWithCurrentRoute */ - const startIdleNavigationSpan = (): void => { + const startIdleNavigationSpan = (unknownEvent?: unknown): void => { + const event = unknownEvent as UnsafeAction | undefined; + if (useDispatchedActionData && event?.data.noop) { + logger.debug(`${INTEGRATION_NAME} Navigation action is a noop, not starting navigation span.`); + return; + } + + const navigationActionType = useDispatchedActionData ? event?.data.action.type : undefined; + if ( + useDispatchedActionData && + [ + // Process common actions + 'PRELOAD', + 'SET_PARAMS', + // Drawer actions + 'OPEN_DRAWER', + 'CLOSE_DRAWER', + 'TOGGLE_DRAWER', + ].includes(navigationActionType) + ) { + logger.debug(`${INTEGRATION_NAME} Navigation action is ${navigationActionType}, not starting navigation span.`); + return; + } + if (latestNavigationSpan) { logger.log(`${INTEGRATION_NAME} A transaction was detected that turned out to be a noop, discarding.`); _discardLatestTransaction(); @@ -196,11 +247,13 @@ export const reactNavigationIntegration = ({ idleSpanOptions, ); latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION); + latestNavigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE, navigationActionType); if (ignoreEmptyBackNavigationTransactions) { ignoreEmptyBackNavigation(getClient(), latestNavigationSpan); } if (enableTimeToInitialDisplay) { + NATIVE.setActiveSpanId(latestNavigationSpan?.spanContext().spanId); navigationProcessingSpan = startInactiveSpan({ op: 'navigation.processing', name: 'Navigation dispatch to navigation cancelled or screen mounted', @@ -240,6 +293,8 @@ export const reactNavigationIntegration = ({ return undefined; } + addTimeToInitialDisplayFallback(latestNavigationSpan.spanContext().spanId, NATIVE.getNewScreenTimeToDisplay()); + if (previousRoute && previousRoute.key === route.key) { logger.debug(`[${INTEGRATION_NAME}] Navigation state changed, but route is the same as previous.`); pushRecentRouteKey(route.key); @@ -251,28 +306,6 @@ export const reactNavigationIntegration = ({ } const routeHasBeenSeen = recentRouteKeys.includes(route.key); - const latestTtidSpan = - !routeHasBeenSeen && - enableTimeToInitialDisplay && - startTimeToInitialDisplaySpan({ - name: `${route.name} initial display`, - isAutoInstrumented: true, - }); - - const navigationSpanWithTtid = latestNavigationSpan; - if (!routeHasBeenSeen && latestTtidSpan) { - newScreenFrameEventEmitter?.onceNewFrame(({ newFrameTimestampInSeconds }: NewFrameEvent) => { - const activeSpan = getActiveSpan(); - if (activeSpan && manualInitialDisplaySpans.has(activeSpan)) { - logger.warn('[ReactNavigationInstrumentation] Detected manual instrumentation for the current active span.'); - return; - } - - latestTtidSpan.setStatus({ code: SPAN_STATUS_OK }); - latestTtidSpan.end(newFrameTimestampInSeconds); - setSpanDurationAsMeasurementOnSpan('time_to_initial_display', latestTtidSpan, navigationSpanWithTtid); - }); - } navigationProcessingSpan?.updateName(`Navigation dispatch to screen ${route.name} mounted`); navigationProcessingSpan?.setStatus({ code: SPAN_STATUS_OK }); @@ -309,7 +342,7 @@ export const reactNavigationIntegration = ({ }, }); - tracing?.setCurrentRoute(route.key); + tracing?.setCurrentRoute(route.name); pushRecentRouteKey(route.key); latestRoute = route; @@ -352,6 +385,13 @@ export const reactNavigationIntegration = ({ name: INTEGRATION_NAME, afterAllSetup, registerNavigationContainer, + options: { + routeChangeTimeoutMs, + enableTimeToInitialDisplay, + ignoreEmptyBackNavigationTransactions, + enableTimeToInitialDisplayForPreloadedRoutes, + useDispatchedActionData, + }, }; }; @@ -363,6 +403,15 @@ export interface NavigationRoute { } interface NavigationContainer { - addListener: (type: string, listener: () => void) => void; + addListener: (type: string, listener: (event?: unknown) => void) => void; getCurrentRoute: () => NavigationRoute; } + +/** + * Returns React Navigation integration of the given client. + */ +export function getReactNavigationIntegration( + client: Client, +): ReturnType | undefined { + return client.getIntegrationByName>(INTEGRATION_NAME); +} diff --git a/packages/core/src/js/tracing/semanticAttributes.ts b/packages/core/src/js/tracing/semanticAttributes.ts index 6a0294ea3a..046d162e77 100644 --- a/packages/core/src/js/tracing/semanticAttributes.ts +++ b/packages/core/src/js/tracing/semanticAttributes.ts @@ -16,3 +16,5 @@ export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_NAME = 'previous_route.name'; export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_KEY = 'previous_route.key'; export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_ID = 'previous_route.component_id'; export const SEMANTIC_ATTRIBUTE_PREVIOUS_ROUTE_COMPONENT_TYPE = 'previous_route.component_type'; +export const SEMANTIC_ATTRIBUTE_TIME_TO_INITIAL_DISPLAY_FALLBACK = 'route.initial_display_fallback'; +export const SEMANTIC_ATTRIBUTE_NAVIGATION_ACTION_TYPE = 'navigation.action_type'; diff --git a/packages/core/src/js/tracing/span.ts b/packages/core/src/js/tracing/span.ts index b44425691c..ac02dca769 100644 --- a/packages/core/src/js/tracing/span.ts +++ b/packages/core/src/js/tracing/span.ts @@ -1,4 +1,4 @@ -import type { Client, Scope, Span, StartSpanOptions } from '@sentry/core'; +import type { Client, Scope, Span, SpanJSON, StartSpanOptions } from '@sentry/core'; import { generatePropagationContext, getActiveSpan, @@ -69,15 +69,15 @@ export const startIdleNavigationSpan = ( activeSpan.end(); } - const finalStartStapOptions = { + const finalStartSpanOptions = { ...getDefaultIdleNavigationSpanOptions(), ...startSpanOption, }; - const idleSpan = startIdleSpan(finalStartStapOptions, { finalTimeout, idleTimeout }); + const idleSpan = startIdleSpan(finalStartSpanOptions, { finalTimeout, idleTimeout }); logger.log( - `[startIdleNavigationSpan] Starting ${finalStartStapOptions.op || 'unknown op'} transaction "${ - finalStartStapOptions.name + `[startIdleNavigationSpan] Starting ${finalStartSpanOptions.op || 'unknown op'} transaction "${ + finalStartSpanOptions.name }" on scope`, ); @@ -154,3 +154,28 @@ export function addDefaultOpForSpanFrom(client: Client): void { } }); } + +export const SPAN_THREAD_NAME = 'thread.name'; +export const SPAN_THREAD_NAME_MAIN = 'main'; +export const SPAN_THREAD_NAME_JAVASCRIPT = 'javascript'; + +/** + * Adds Javascript thread info to spans. + * Ref: https://reactnative.dev/architecture/threading-model + */ +export function addThreadInfoToSpan(client: Client): void { + client.on('spanStart', (span: Span) => { + if (!spanToJSON(span).data?.[SPAN_THREAD_NAME]) { + span.setAttribute(SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT); + } + }); +} + +/** + * Sets the Main thread info to the span. + */ +export function setMainThreadInfo(spanJSON: SpanJSON): SpanJSON { + spanJSON.data = spanJSON.data || {}; + spanJSON.data[SPAN_THREAD_NAME] = SPAN_THREAD_NAME_MAIN; + return spanJSON; +} diff --git a/packages/core/src/js/tracing/timeToDisplayFallback.ts b/packages/core/src/js/tracing/timeToDisplayFallback.ts new file mode 100644 index 0000000000..cd71b21df0 --- /dev/null +++ b/packages/core/src/js/tracing/timeToDisplayFallback.ts @@ -0,0 +1,18 @@ +import { AsyncExpiringMap } from '../utils/AsyncExpiringMap'; + +const TIME_TO_DISPLAY_FALLBACK_TTL_MS = 60_000; + +const spanIdToTimeToInitialDisplayFallback: AsyncExpiringMap = new AsyncExpiringMap({ + ttl: TIME_TO_DISPLAY_FALLBACK_TTL_MS, +}); + +export const addTimeToInitialDisplayFallback = ( + spanId: string, + timestampSeconds: Promise, +): void => { + spanIdToTimeToInitialDisplayFallback.set(spanId, timestampSeconds); +}; + +export const getTimeToInitialDisplayFallback = async (spanId: string): Promise => { + return spanIdToTimeToInitialDisplayFallback.get(spanId); +}; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 12d1198bc4..f33216b875 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -1,12 +1,12 @@ import type { Span,StartSpanOptions } from '@sentry/core'; import { fill, getActiveSpan, getSpanDescendants, logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan } from '@sentry/core'; import * as React from 'react'; +import { useState } from 'react'; import { isTurboModuleEnabled } from '../utils/environment'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; import { getRNSentryOnDrawReporter, nativeComponentExists } from './timetodisplaynative'; -import type {RNSentryOnDrawNextFrameEvent } from './timetodisplaynative.types'; -import { setSpanDurationAsMeasurement } from './utils'; +import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; let nativeComponentMissingLogged = false; @@ -36,10 +36,10 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem const activeSpan = getActiveSpan(); if (activeSpan) { manualInitialDisplaySpans.set(activeSpan, true); - startTimeToInitialDisplaySpan(); } - return {props.children}; + const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + return {props.children}; } /** @@ -50,14 +50,16 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem * */ export function TimeToFullDisplay(props: TimeToDisplayProps): React.ReactElement { - startTimeToFullDisplaySpan(); - return {props.children}; + const activeSpan = getActiveSpan(); + const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + return {props.children}; } function TimeToDisplay(props: { children?: React.ReactNode; initialDisplay?: boolean; fullDisplay?: boolean; + parentSpanId?: string; }): React.ReactElement { const RNSentryOnDrawReporter = getRNSentryOnDrawReporter(); const isNewArchitecture = isTurboModuleEnabled(); @@ -71,14 +73,12 @@ function TimeToDisplay(props: { }, 0); } - const onDraw = (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void => onDrawNextFrame(event); - return ( <> + fullDisplay={props.fullDisplay} + parentSpanId={props.parentSpanId} /> {props.children} ); @@ -88,6 +88,8 @@ function TimeToDisplay(props: { * Starts a new span for the initial display. * * Returns current span if already exists in the currently active span. + * + * @deprecated Use `` component instead. */ export function startTimeToInitialDisplaySpan( options?: Omit & { @@ -132,6 +134,8 @@ export function startTimeToInitialDisplaySpan( * Starts a new span for the full display. * * Returns current span if already exists in the currently active span. + * + * @deprecated Use `` component instead. */ export function startTimeToFullDisplaySpan( options: Omit & { @@ -196,24 +200,26 @@ export function startTimeToFullDisplaySpan( return fullDisplaySpan; } -function onDrawNextFrame(event: { nativeEvent: RNSentryOnDrawNextFrameEvent }): void { - logger.debug(`[TimeToDisplay] onDrawNextFrame: ${JSON.stringify(event.nativeEvent)}`); - if (event.nativeEvent.type === 'fullDisplay') { - return updateFullDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds); - } - if (event.nativeEvent.type === 'initialDisplay') { - return updateInitialDisplaySpan(event.nativeEvent.newFrameTimestampInSeconds); - } -} - -function updateInitialDisplaySpan(frameTimestampSeconds: number): void { - const span = startTimeToInitialDisplaySpan(); +/** + * + */ +export function updateInitialDisplaySpan( + frameTimestampSeconds: number, + { + activeSpan = getActiveSpan(), + span = startTimeToInitialDisplaySpan(), + }: { + activeSpan?: Span; + /** + * Time to initial display span to update. + */ + span?: Span; + } = {}): void { if (!span) { logger.warn(`[TimeToDisplay] No span found or created, possibly performance is disabled.`); return; } - const activeSpan = getActiveSpan(); if (!activeSpan) { logger.warn(`[TimeToDisplay] No active span found to attach ui.load.initial_display to.`); return; @@ -239,7 +245,7 @@ function updateInitialDisplaySpan(frameTimestampSeconds: number): void { updateFullDisplaySpan(frameTimestampSeconds, span); } - setSpanDurationAsMeasurement('time_to_initial_display', span); + setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan); } function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void { @@ -284,3 +290,55 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl setSpanDurationAsMeasurement('time_to_full_display', span); } + +/** + * Creates a new TimeToFullDisplay component which triggers the full display recording every time the component is focused. + */ +export function createTimeToFullDisplay({ + useFocusEffect, +}: { + /** + * `@react-navigation/native` useFocusEffect hook. + */ + useFocusEffect: (callback: () => void) => void +}): React.ComponentType { + return createTimeToDisplay({ useFocusEffect, Component: TimeToFullDisplay }); +} + +/** + * Creates a new TimeToInitialDisplay component which triggers the initial display recording every time the component is focused. + */ +export function createTimeToInitialDisplay({ + useFocusEffect, +}: { + useFocusEffect: (callback: () => void) => void +}): React.ComponentType { + return createTimeToDisplay({ useFocusEffect, Component: TimeToInitialDisplay }); +} + +function createTimeToDisplay({ + useFocusEffect, + Component, +}: { + /** + * `@react-navigation/native` useFocusEffect hook. + */ + useFocusEffect: (callback: () => void) => void; + Component: typeof TimeToFullDisplay | typeof TimeToInitialDisplay; +}): React.ComponentType { + const TimeToDisplayWrapper = (props: TimeToDisplayProps): React.ReactElement => { + const [focused, setFocused] = useState(false); + + useFocusEffect(() => { + setFocused(true); + return () => { + setFocused(false); + }; + }); + + return ; + }; + + TimeToDisplayWrapper.displayName = `TimeToDisplayWrapper`; + return TimeToDisplayWrapper; +} diff --git a/packages/core/src/js/tracing/timetodisplaynative.tsx b/packages/core/src/js/tracing/timetodisplaynative.tsx index 8db6a30316..d549fe8b87 100644 --- a/packages/core/src/js/tracing/timetodisplaynative.tsx +++ b/packages/core/src/js/tracing/timetodisplaynative.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import type { HostComponent } from 'react-native'; import { UIManager, View } from 'react-native'; +import { isExpoGo } from '../utils/environment'; import { ReactNativeLibraries } from '../utils/rnlibraries'; import type { RNSentryOnDrawReporterProps } from './timetodisplaynative.types'; @@ -29,7 +30,7 @@ let RNSentryOnDrawReporter: HostComponent | typeof */ export const getRNSentryOnDrawReporter = (): typeof RNSentryOnDrawReporter => { if (!RNSentryOnDrawReporter) { - RNSentryOnDrawReporter = nativeComponentExists && ReactNativeLibraries.ReactNative?.requireNativeComponent + RNSentryOnDrawReporter = !isExpoGo() && nativeComponentExists && ReactNativeLibraries.ReactNative?.requireNativeComponent ? ReactNativeLibraries.ReactNative.requireNativeComponent(RNSentryOnDrawReporterClass) : RNSentryOnDrawReporterNoop; } diff --git a/packages/core/src/js/tracing/timetodisplaynative.types.ts b/packages/core/src/js/tracing/timetodisplaynative.types.ts index 85fbf5b4a2..ce6c90fe68 100644 --- a/packages/core/src/js/tracing/timetodisplaynative.types.ts +++ b/packages/core/src/js/tracing/timetodisplaynative.types.ts @@ -5,7 +5,7 @@ export interface RNSentryOnDrawNextFrameEvent { export interface RNSentryOnDrawReporterProps { children?: React.ReactNode; - onDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void; initialDisplay?: boolean; fullDisplay?: boolean; + parentSpanId?: string; } diff --git a/packages/core/src/js/utils/expoglobalobject.ts b/packages/core/src/js/utils/expoglobalobject.ts index e1a39c63b0..c36afde03c 100644 --- a/packages/core/src/js/utils/expoglobalobject.ts +++ b/packages/core/src/js/utils/expoglobalobject.ts @@ -44,9 +44,31 @@ export interface ExpoDevice { totalMemory?: number; } +/** + * Interface from the Expo SDK defined here + * (we are describing the native module + * the TS typing is only guideline) + * + * https://github.com/expo/expo/blob/8b7165ad2c6751c741f588c72dac50fb3a814dcc/packages/expo-updates/src/Updates.ts + */ +export interface ExpoUpdates { + isEnabled?: boolean; + updateId?: string | null; + channel?: string | null; + runtimeVersion?: string | null; + checkAutomatically?: string | null; + isEmergencyLaunch?: boolean; + emergencyLaunchReason?: string | null; + launchDuration?: number | null; + isEmbeddedLaunch?: boolean; + isUsingEmbeddedAssets?: boolean; + createdAt?: Date | null; +} + export interface ExpoGlobalObject { modules?: { ExponentConstants?: ExpoConstants; ExpoDevice?: ExpoDevice; + ExpoUpdates?: ExpoUpdates; }; } diff --git a/packages/core/src/js/utils/expomodules.ts b/packages/core/src/js/utils/expomodules.ts index 349c454d9d..9f606a4f76 100644 --- a/packages/core/src/js/utils/expomodules.ts +++ b/packages/core/src/js/utils/expomodules.ts @@ -1,18 +1,23 @@ -import type { ExpoConstants, ExpoDevice } from './expoglobalobject'; +import type { ExpoConstants, ExpoDevice, ExpoUpdates } from './expoglobalobject'; import { RN_GLOBAL_OBJ } from './worldwide'; /** * Returns the Expo Constants module if present */ export function getExpoConstants(): ExpoConstants | undefined { - return ( - (RN_GLOBAL_OBJ.expo && RN_GLOBAL_OBJ.expo.modules && RN_GLOBAL_OBJ.expo.modules.ExponentConstants) || undefined - ); + return RN_GLOBAL_OBJ.expo?.modules?.ExponentConstants ?? undefined; } /** * Returns the Expo Device module if present */ export function getExpoDevice(): ExpoDevice | undefined { - return (RN_GLOBAL_OBJ.expo && RN_GLOBAL_OBJ.expo.modules && RN_GLOBAL_OBJ.expo.modules.ExpoDevice) || undefined; + return RN_GLOBAL_OBJ.expo?.modules?.ExpoDevice ?? undefined; +} + +/** + * Returns the Expo Updates module if present + */ +export function getExpoUpdates(): ExpoUpdates | undefined { + return RN_GLOBAL_OBJ.expo?.modules?.ExpoUpdates ?? undefined; } diff --git a/packages/core/src/js/utils/rnlibraries.ts b/packages/core/src/js/utils/rnlibraries.ts index b5fa610842..b05167c2ef 100644 --- a/packages/core/src/js/utils/rnlibraries.ts +++ b/packages/core/src/js/utils/rnlibraries.ts @@ -1,14 +1,23 @@ /* eslint-disable @typescript-eslint/no-var-requires */ - -import { Platform, TurboModuleRegistry } from 'react-native'; +import { AppRegistry, Platform, TurboModuleRegistry } from 'react-native'; import type * as ReactNative from '../vendor/react-native'; import type { ReactNativeLibrariesInterface } from './rnlibrariesinterface'; -export const ReactNativeLibraries: Required = { +const InternalReactNativeLibrariesInterface: Required = { Devtools: { parseErrorStack: (errorStack: string): Array => { const parseErrorStack = require('react-native/Libraries/Core/Devtools/parseErrorStack'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (parseErrorStack.default && typeof parseErrorStack.default === 'function') { + // Starting with react-native 0.79, the parseErrorStack is a default export + // https://github.com/facebook/react-native/commit/e5818d92a867dbfa5f60d176b847b1f2131cb6da + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return parseErrorStack.default(errorStack); + } + + // react-native 0.78 and below return parseErrorStack(errorStack); }, symbolicateStackTrace: ( @@ -16,10 +25,30 @@ export const ReactNativeLibraries: Required = { extraData?: Record, ): Promise => { const symbolicateStackTrace = require('react-native/Libraries/Core/Devtools/symbolicateStackTrace'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (symbolicateStackTrace.default && typeof symbolicateStackTrace.default === 'function') { + // Starting with react-native 0.79, the symbolicateStackTrace is a default export + // https://github.com/facebook/react-native/commit/e5818d92a867dbfa5f60d176b847b1f2131cb6da + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return symbolicateStackTrace.default(stack, extraData); + } + + // react-native 0.78 and below return symbolicateStackTrace(stack, extraData); }, getDevServer: (): ReactNative.DevServerInfo => { const getDevServer = require('react-native/Libraries/Core/Devtools/getDevServer'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (getDevServer.default && typeof getDevServer.default === 'function') { + // Starting with react-native 0.79, the getDevServer is a default export + // https://github.com/facebook/react-native/commit/e5818d92a867dbfa5f60d176b847b1f2131cb6da + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return getDevServer.default(); + } + + // react-native 0.78 and below return getDevServer(); }, }, @@ -34,6 +63,7 @@ export const ReactNativeLibraries: Required = { version: Platform.constants?.reactNativeVersion, }, TurboModuleRegistry, + AppRegistry, ReactNative: { requireNativeComponent: (viewName: string): ReactNative.HostComponent => { const { requireNativeComponent } = require('react-native'); @@ -41,3 +71,5 @@ export const ReactNativeLibraries: Required = { }, }, }; + +export const ReactNativeLibraries: ReactNativeLibrariesInterface = InternalReactNativeLibrariesInterface; diff --git a/packages/core/src/js/utils/rnlibrariesinterface.ts b/packages/core/src/js/utils/rnlibrariesinterface.ts index 0d485adefc..77e27ca729 100644 --- a/packages/core/src/js/utils/rnlibrariesinterface.ts +++ b/packages/core/src/js/utils/rnlibrariesinterface.ts @@ -25,6 +25,7 @@ export interface ReactNativeLibrariesInterface { Promise?: typeof Promise; ReactNativeVersion?: ReactNative.ReactNativeVersion; TurboModuleRegistry?: ReactNative.TurboModuleRegistry; + AppRegistry?: ReactNative.AppRegistry; ReactNative?: { requireNativeComponent?: (viewName: string) => ReactNative.HostComponent; }; diff --git a/packages/core/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts index 778e58edaf..6dd202e7c2 100644 --- a/packages/core/src/js/utils/worldwide.ts +++ b/packages/core/src/js/utils/worldwide.ts @@ -4,6 +4,11 @@ import type { ErrorUtils } from 'react-native/types'; import type { ReactNativeOptions } from '../options'; import type { ExpoGlobalObject } from './expoglobalobject'; +export interface HermesPromiseRejectionTrackingOptions { + allRejections: boolean; + onUnhandled: (id: string, error: unknown) => void; + onHandled: (id: string) => void; +} /** Internal Global object interface with common and Sentry specific properties */ export interface ReactNativeInternalGlobal extends InternalGlobal { @@ -11,6 +16,8 @@ export interface ReactNativeInternalGlobal extends InternalGlobal { __sentry_rn_v5_registered?: boolean; HermesInternal?: { getRuntimeProperties?: () => Record; + enablePromiseRejectionTracker?: (options: HermesPromiseRejectionTrackingOptions) => void; + hasPromise?: () => boolean; }; Promise: unknown; __turboModuleProxy: unknown; diff --git a/packages/core/src/js/vendor/react-native/index.ts b/packages/core/src/js/vendor/react-native/index.ts index 491a6f1f5d..418ff06bb9 100644 --- a/packages/core/src/js/vendor/react-native/index.ts +++ b/packages/core/src/js/vendor/react-native/index.ts @@ -82,3 +82,7 @@ export type ReactNativeVersion = { prerelease?: string | null | undefined; }; }; + +export type AppRegistry = { + runApplication: (appKey: string, appParameters: any) => void; +}; diff --git a/packages/core/src/js/vendor/react-navigation/types.ts b/packages/core/src/js/vendor/react-navigation/types.ts new file mode 100644 index 0000000000..6319c4b0a5 --- /dev/null +++ b/packages/core/src/js/vendor/react-navigation/types.ts @@ -0,0 +1,50 @@ +// MIT License + +// Copyright (c) 2017 React Navigation Contributors + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// https://github.com/react-navigation/react-navigation/blob/a656331009fcb534e0bef535e6df65ae07a228a4/packages/core/src/types.tsx#L777 + +/** + * Event which fires when an action is dispatched. + * Only intended for debugging purposes, don't use it for app logic. + * This event will be emitted before state changes have been applied. + */ +export type UnsafeAction = { + data: { + /** + * The action object which was dispatched. + */ + action: { + readonly type: string; + readonly payload?: object | undefined; + readonly source?: string | undefined; + readonly target?: string | undefined; + }; + /** + * Whether the action was a no-op, i.e. resulted any state changes. + */ + noop: boolean; + /** + * Stack trace of the action, this will only be available during development. + */ + stack: string | undefined; + }; +}; diff --git a/packages/core/src/js/version.ts b/packages/core/src/js/version.ts index daf16ff05e..82d109f96f 100644 --- a/packages/core/src/js/version.ts +++ b/packages/core/src/js/version.ts @@ -1,3 +1,3 @@ export const SDK_PACKAGE_NAME = 'npm:@sentry/react-native'; export const SDK_NAME = 'sentry.javascript.react-native'; -export const SDK_VERSION = '6.8.0'; +export const SDK_VERSION = '6.15.1'; diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 9b37a9b87b..fdd04e8ac8 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -122,6 +122,11 @@ interface SentryNativeWrapper { getNewScreenTimeToDisplay(): Promise; getDataFromUri(uri: string): Promise; + popTimeToDisplayFor(key: string): Promise; + + setActiveSpanId(spanId: string): void; + + encodeToBase64(data: Uint8Array): Promise; } const EOL = utf8ToBytes('\n'); @@ -692,7 +697,7 @@ export const NATIVE: SentryNativeWrapper = { return null; } - const result = RNSentry.crashedLastRun(); + const result = await RNSentry.crashedLastRun(); return typeof result === 'boolean' ? result : null; }, @@ -717,6 +722,47 @@ export const NATIVE: SentryNativeWrapper = { } }, + popTimeToDisplayFor(key: string): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + try { + return RNSentry.popTimeToDisplayFor(key); + } catch (error) { + logger.error('Error:', error); + return null; + } + }, + + setActiveSpanId(spanId): void { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return undefined; + } + + try { + RNSentry.setActiveSpanId(spanId); + } catch (error) { + logger.error('Error:', error); + return undefined; + } + }, + + async encodeToBase64(data: Uint8Array): Promise { + if (!this.enableNative || !this._isModuleLoaded(RNSentry)) { + return Promise.resolve(null); + } + + try { + const byteArray = Array.from(data); + const base64 = await RNSentry.encodeToBase64(byteArray); + return base64 || null; + } catch (error) { + logger.error('Error:', error); + return Promise.resolve(null); + } + }, + /** * Gets the event from envelopeItem and applies the level filter to the selected event. * @param data An envelope item containing the event. @@ -804,3 +850,12 @@ export const NATIVE: SentryNativeWrapper = { nativeIsReady: false, platform: Platform.OS, }; + +/** + * Fethces the data from the given uri in Uint8Array format. + * @param uri string + * @returns Uint8Array | null + */ +export async function getDataFromUri(uri: string): Promise { + return NATIVE.getDataFromUri(uri); +} diff --git a/packages/core/test/clientAfterInit.test.ts b/packages/core/test/clientAfterInit.test.ts new file mode 100644 index 0000000000..227d4d09bb --- /dev/null +++ b/packages/core/test/clientAfterInit.test.ts @@ -0,0 +1,78 @@ +import { ReactNativeClient } from '../src/js'; +import type { ReactNativeClientOptions } from '../src/js/options'; +import { NATIVE } from './mockWrapper'; + +jest.useFakeTimers({ advanceTimers: true }); + +jest.mock('../src/js/wrapper', () => jest.requireActual('./mockWrapper')); + +describe('ReactNativeClient emits `afterInit` event', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('emits `afterInit` event when native is enabled', async () => { + const client = setupReactNativeClient({ + enableNative: true, + }); + + const emitSpy = jest.spyOn(client, 'emit'); + client.init(); + + await jest.runOnlyPendingTimersAsync(); + + expect(emitSpy).toHaveBeenCalledWith('afterInit'); + }); + + test('emits `afterInit` event when native is disabled', async () => { + const client = setupReactNativeClient({ + enableNative: false, + }); + + const emitSpy = jest.spyOn(client, 'emit'); + client.init(); + + await jest.runOnlyPendingTimersAsync(); + expect(emitSpy).toHaveBeenCalledWith('afterInit'); + }); + + test('emits `afterInit` event when native init is rejected', async () => { + NATIVE.initNativeSdk = jest.fn().mockRejectedValue(new Error('Test Native Init Rejected')); + + const client = setupReactNativeClient({ + enableNative: false, + }); + + const emitSpy = jest.spyOn(client, 'emit'); + client.init(); + + await jest.runOnlyPendingTimersAsync(); + expect(emitSpy).toHaveBeenCalledWith('afterInit'); + }); +}); + +function setupReactNativeClient(options: Partial = {}): ReactNativeClient { + return new ReactNativeClient({ + ...DEFAULT_OPTIONS, + ...options, + }); +} + +const EXAMPLE_DSN = 'https://6890c2f6677340daa4804f8194804ea2@o19635.ingest.sentry.io/148053'; + +const DEFAULT_OPTIONS: ReactNativeClientOptions = { + dsn: EXAMPLE_DSN, + enableNative: true, + enableNativeCrashHandling: true, + enableNativeNagger: true, + autoInitializeNativeSdk: true, + enableAutoPerformanceTracing: true, + enableWatchdogTerminationTracking: true, + patchGlobalPromise: true, + integrations: [], + transport: () => ({ + send: jest.fn(), + flush: jest.fn(), + }), + stackParser: jest.fn().mockReturnValue([]), +}; diff --git a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts index 0ed9a95551..be48ecea14 100644 --- a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts +++ b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts @@ -138,13 +138,13 @@ describe('withSentryAndroidGradlePlugin', () => { expect(modifiedGradle.modResults.contents).toContain('apply plugin: "io.sentry.android.gradle"'); expect(modifiedGradle.modResults.contents).toContain(` sentry { - autoUploadProguardMapping = true + autoUploadProguardMapping = shouldSentryAutoUpload() includeProguardMapping = true dexguardEnabled = false - uploadNativeSymbols = true - autoUploadNativeSymbols = true + uploadNativeSymbols = shouldSentryAutoUpload() + autoUploadNativeSymbols = shouldSentryAutoUpload() includeNativeSources = false - includeSourceContext = true + includeSourceContext = shouldSentryAutoUpload() tracingInstrumentation { enabled = false } diff --git a/packages/core/test/feedback/FeedbackButton.test.tsx b/packages/core/test/feedback/FeedbackButton.test.tsx new file mode 100644 index 0000000000..579bccc7ca --- /dev/null +++ b/packages/core/test/feedback/FeedbackButton.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; + +import { FeedbackButton } from '../../src/js/feedback/FeedbackButton'; +import type { FeedbackButtonProps, FeedbackButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; +import { showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; + +jest.mock('../../src/js/feedback/FeedbackWidgetManager', () => ({ + ...jest.requireActual('../../src/js/feedback/FeedbackWidgetManager'), + showFeedbackWidget: jest.fn(), +})); + +const customTextProps: FeedbackButtonProps = { + triggerLabel: 'Give Feedback', +}; + +export const customStyles: FeedbackButtonStyles = { + triggerButton: { + backgroundColor: '#ffffff', + }, + triggerText: { + color: '#ff0000', + }, +}; + +describe('FeedbackButton', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('matches the snapshot with default configuration', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles', () => { + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('shows the feedback widget when pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(customTextProps.triggerLabel)); + + await waitFor(() => { + expect(showFeedbackWidget).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx index ee317d2650..21e10bff1e 100644 --- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -1,17 +1,19 @@ import { getClient, logger, setCurrentClient } from '@sentry/core'; import { render } from '@testing-library/react-native'; import * as React from 'react'; -import { Text } from 'react-native'; +import { Appearance, Text } from 'react-native'; import { defaultConfiguration } from '../../src/js/feedback/defaults'; -import { FeedbackWidgetProvider, resetFeedbackWidgetManager, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; +import { hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider'; import { feedbackIntegration } from '../../src/js/feedback/integration'; -import { AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; +import { AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME,AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; import { isModalSupported } from '../../src/js/feedback/utils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; jest.mock('../../src/js/feedback/utils', () => ({ isModalSupported: jest.fn(), + isNativeDriverSupportedForColorAnimations: jest.fn().mockReturnValue(true), })); const consoleWarnSpy = jest.spyOn(console, 'warn'); @@ -114,7 +116,7 @@ describe('FeedbackWidgetManager', () => { showFeedbackWidget(); - expect(consoleWarnSpy).toHaveBeenLastCalledWith('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.'); + expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackWidget requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackWidget()'.`); }); it('showFeedbackWidget does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { @@ -130,7 +132,7 @@ describe('FeedbackWidgetManager', () => { expect(consoleWarnSpy).not.toHaveBeenCalled(); }); - + it('showFeedbackWidget adds the feedbackIntegration to the client', () => { mockedIsModalSupported.mockReturnValue(true); @@ -139,3 +141,363 @@ describe('FeedbackWidgetManager', () => { expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_INTEGRATION_NAME)).toBeDefined(); }); }); + +describe('FeedbackButtonManager', () => { + let listener: (preferences: Appearance.AppearancePreferences) => void; + + afterEach(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + consoleWarnSpy.mockReset(); + resetFeedbackButtonManager(); + + jest.spyOn(Appearance, 'addChangeListener').mockImplementation((cb) => { + listener = cb; + return { remove: jest.fn() }; + }); + }); + + it('showFeedbackButton displays the button when FeedbackWidgetProvider is used', () => { + const { getByText } = render( + + App Components + + ); + + showFeedbackButton(); + + expect(getByText('Report a Bug')).toBeTruthy(); + }); + + it('hideFeedbackButton hides the button', () => { + const { queryByText } = render( + + App Components + + ); + + showFeedbackButton(); + hideFeedbackButton(); + + expect(queryByText('Report a Bug')).toBeNull(); + }); + + it('showFeedbackButton does not throw an error when FeedbackWidgetProvider is not used', () => { + expect(() => { + showFeedbackButton(); + }).not.toThrow(); + }); + + it('showFeedbackButton warns about missing feedback provider', () => { + showFeedbackButton(); + + expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackButton requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackButton()'.`); + }); + + it('showFeedbackButton does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { + render( + + App Components + + ); + + showFeedbackButton(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('showFeedbackButton adds the feedbackIntegration to the client', () => { + showFeedbackButton(); + + expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME)).toBeDefined(); + }); + + it('the Feedback Widget matches the snapshot with default configuration and system light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with default configuration and system dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with default configuration and dynamically changed theme', () => { + const component = ( + + App Components + + ); + + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render(component); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackWidget(); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + listener({ colorScheme: 'dark' }); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with custom light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'light', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with custom dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'dark', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with system light custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Widget matches the snapshot with system dark custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackWidget(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with default configuration and system light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with default configuration and system dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with default configuration and dynamically changed theme', () => { + const component = ( + + App Components + + ); + + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render(component); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackButton(); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + listener({ colorScheme: 'dark' }); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with custom light theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'light', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with custom dark theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'dark', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with system light custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeLight: { + foreground: '#ff0000', + background: '#00ff00', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('the Feedback Button matches the snapshot with system dark custom theme', () => { + mockedIsModalSupported.mockReturnValue(true); + const { toJSON } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + colorScheme: 'system', + themeDark: { + foreground: '#00ff00', + background: '#ff0000', + }, + }); + getClient()?.addIntegration(integration); + + jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark'); + + showFeedbackButton(); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/packages/core/test/feedback/ScreenshotButton.test.tsx b/packages/core/test/feedback/ScreenshotButton.test.tsx new file mode 100644 index 0000000000..2419860d1c --- /dev/null +++ b/packages/core/test/feedback/ScreenshotButton.test.tsx @@ -0,0 +1,221 @@ +import { getClient, setCurrentClient } from '@sentry/core'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; +import { Alert, Text } from 'react-native'; + +import { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget'; +import type { ScreenshotButtonProps, ScreenshotButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; +import { resetFeedbackButtonManager, resetFeedbackWidgetManager, resetScreenshotButtonManager, showFeedbackButton } from '../../src/js/feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider } from '../../src/js/feedback/FeedbackWidgetProvider'; +import { feedbackIntegration } from '../../src/js/feedback/integration'; +import { getCapturedScreenshot, ScreenshotButton } from '../../src/js/feedback/ScreenshotButton'; +import type { Screenshot } from '../../src/js/wrapper'; +import { NATIVE } from '../../src/js/wrapper'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; + +jest.mock('../../src/js/wrapper', () => ({ + NATIVE: { + captureScreenshot: jest.fn(), + encodeToBase64: jest.fn(), + }, +})); + +jest.spyOn(Alert, 'alert'); + +const mockScreenshot: Screenshot = { + filename: 'test-screenshot.png', + contentType: 'image/png', + data: new Uint8Array([1, 2, 3]), +}; + +const mockBase64Image = 'mockBase64ImageString'; + +const mockCaptureScreenshot = NATIVE.captureScreenshot as jest.Mock; +const mockEncodeToBase64 = NATIVE.encodeToBase64 as jest.Mock; + +describe('ScreenshotButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + FeedbackWidget.reset(); + getCapturedScreenshot(); // cleans up stored screenshot if any + resetFeedbackWidgetManager(); + resetFeedbackButtonManager(); + resetScreenshotButtonManager(); + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + }); + + it('matches the snapshot with default configuration', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts', () => { + const defaultProps: ScreenshotButtonProps = { + triggerLabel: 'Take Screenshot', + }; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles', () => { + const customStyles: ScreenshotButtonStyles = { + triggerButton: { + backgroundColor: '#ffffff', + }, + triggerText: { + color: '#ff0000', + }, + }; + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('the take screenshot button is visible in the feedback widget when enabled', async () => { + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + + const takeScreenshotButton = getByText('Take a screenshot'); + expect(takeScreenshotButton).toBeTruthy(); + }); + + + it('the capture screenshot button is shown when tapping the Take a screenshot button in the feedback widget', async () => { + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + + const captureButton = getByText('Take Screenshot'); + expect(captureButton).toBeTruthy(); + }); + + it('a screenshot is captured when tapping the Take Screenshot button', async () => { + mockCaptureScreenshot.mockResolvedValue([mockScreenshot]); + mockEncodeToBase64.mockResolvedValue(mockBase64Image); + + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + fireEvent.press(getByText('Take Screenshot')); + + await waitFor(() => { + expect(mockCaptureScreenshot).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockEncodeToBase64).toHaveBeenCalled(); + }); + }); + + it('the feedback widget ui is updated when a screenshot is captured', async () => { + mockCaptureScreenshot.mockResolvedValue([mockScreenshot]); + mockEncodeToBase64.mockResolvedValue(mockBase64Image); + + const { getByText, queryByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + fireEvent.press(getByText('Take Screenshot')); + + await waitFor(() => { + expect(mockCaptureScreenshot).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockEncodeToBase64).toHaveBeenCalled(); + }); + + await waitFor(() => { + const captureButton = queryByText('Take Screenshot'); + expect(captureButton).toBeNull(); + }); + + await waitFor(() => { + const takeScreenshotButtonAfterCapture = queryByText('Take a screenshot'); + expect(takeScreenshotButtonAfterCapture).toBeNull(); + }); + + await waitFor(() => { + const removeScreenshotButtonAfterCapture = queryByText('Remove screenshot'); + expect(removeScreenshotButtonAfterCapture).toBeTruthy(); + }); + }); + + it('when the capture fails an error message is shown', async () => { + mockCaptureScreenshot.mockResolvedValue([]); + + const { getByText } = render( + + App Components + + ); + + const integration = feedbackIntegration({ + enableTakeScreenshot: true, + }); + getClient()?.addIntegration(integration); + + showFeedbackButton(); + + fireEvent.press(getByText('Report a Bug')); + fireEvent.press(getByText('Take a screenshot')); + fireEvent.press(getByText('Take Screenshot')); + + await waitFor(() => { + expect(mockCaptureScreenshot).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith('Error', 'Error capturing screenshot. Please try again.'); + }); + }); +}); diff --git a/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap new file mode 100644 index 0000000000..3115f5ccd9 --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackButton matches the snapshot with custom styles 1`] = ` + + + + Report a Bug + + +`; + +exports[`FeedbackButton matches the snapshot with custom texts 1`] = ` + + + + Give Feedback + + +`; + +exports[`FeedbackButton matches the snapshot with default configuration 1`] = ` + + + + Report a Bug + + +`; diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap index 9f71d72ceb..a3841f1597 100644 --- a/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap +++ b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap @@ -2,6 +2,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = ` Report a Bug @@ -53,6 +55,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = ` style={ { "height": 40, + "tintColor": "rgba(54, 45, 89, 1)", "width": 40, } } @@ -80,6 +83,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = ` "height": 50, } } + testID="sentry-feedback-name-input" value="Test User" /> Send Bug Report @@ -234,6 +241,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles 1`] = ` exports[`FeedbackWidget matches the snapshot with custom styles and screenshot button 1`] = ` Report a Bug @@ -285,6 +294,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b style={ { "height": 40, + "tintColor": "rgba(54, 45, 89, 1)", "width": 40, } } @@ -312,6 +322,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b "height": 50, } } + testID="sentry-feedback-name-input" value="Test User" /> Send Bug Report @@ -523,6 +537,7 @@ exports[`FeedbackWidget matches the snapshot with custom styles and screenshot b exports[`FeedbackWidget matches the snapshot with custom texts 1`] = ` Feedback Form @@ -580,6 +596,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = ` style={ { "height": 40, + "tintColor": "rgba(54, 45, 89, 1)", "width": 40, } } @@ -612,6 +629,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = ` "paddingHorizontal": 10, } } + testID="sentry-feedback-name-input" value="Test User" /> Submit Button Label @@ -786,6 +807,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts 1`] = ` exports[`FeedbackWidget matches the snapshot with custom texts and screenshot button 1`] = ` Feedback Form @@ -843,6 +866,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu style={ { "height": 40, + "tintColor": "rgba(54, 45, 89, 1)", "width": 40, } } @@ -875,6 +899,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu "paddingHorizontal": 10, } } + testID="sentry-feedback-name-input" value="Test User" /> Submit Button Label @@ -1112,6 +1140,7 @@ exports[`FeedbackWidget matches the snapshot with custom texts and screenshot bu exports[`FeedbackWidget matches the snapshot with default configuration 1`] = ` Report a Bug @@ -1169,6 +1199,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = ` style={ { "height": 40, + "tintColor": "rgba(54, 45, 89, 1)", "width": 40, } } @@ -1201,6 +1232,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = ` "paddingHorizontal": 10, } } + testID="sentry-feedback-name-input" value="Test User" /> Send Bug Report @@ -1375,6 +1410,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration 1`] = ` exports[`FeedbackWidget matches the snapshot with default configuration and screenshot button 1`] = ` Report a Bug @@ -1432,6 +1469,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration and scre style={ { "height": 40, + "tintColor": "rgba(54, 45, 89, 1)", "width": 40, } } @@ -1464,6 +1502,7 @@ exports[`FeedbackWidget matches the snapshot with default configuration and scre "paddingHorizontal": 10, } } + testID="sentry-feedback-name-input" value="Test User" /> Send Bug Report diff --git a/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap new file mode 100644 index 0000000000..521573f3ad --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/FeedbackWidgetManager.test.tsx.snap @@ -0,0 +1,3102 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with custom dark theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with custom light theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and dynamically changed theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and system dark theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with default configuration and system light theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with system dark custom theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Button matches the snapshot with system light custom theme 1`] = ` +[ + + App Components + , + + + + Report a Bug + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with custom dark theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with custom light theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and dynamically changed theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and system dark theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with default configuration and system light theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with system dark custom theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; + +exports[`FeedbackButtonManager the Feedback Widget matches the snapshot with system light custom theme 1`] = ` +[ + + App Components + , + + + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + + , +] +`; diff --git a/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap new file mode 100644 index 0000000000..2cd6455502 --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/ScreenshotButton.test.tsx.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ScreenshotButton matches the snapshot with custom styles 1`] = ` + + + + Take Screenshot + + +`; + +exports[`ScreenshotButton matches the snapshot with custom texts 1`] = ` + + + + Take Screenshot + + +`; + +exports[`ScreenshotButton matches the snapshot with default configuration 1`] = ` + + + + Take Screenshot + + +`; diff --git a/packages/core/test/integrations/appRegistry.test.ts b/packages/core/test/integrations/appRegistry.test.ts new file mode 100644 index 0000000000..535616c378 --- /dev/null +++ b/packages/core/test/integrations/appRegistry.test.ts @@ -0,0 +1,80 @@ +import { getOriginalFunction } from '@sentry/core'; + +import { appRegistryIntegration } from '../../src/js/integrations/appRegistry'; +import * as Environment from '../../src/js/utils/environment'; +import { ReactNativeLibraries } from '../../src/js/utils/rnlibraries'; + +const originalAppRegistry = ReactNativeLibraries.AppRegistry; +const originalRunApplication = ReactNativeLibraries.AppRegistry.runApplication; + +describe('AppRegistry Integration', () => { + let mockedRunApplication: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockedRunApplication = jest.spyOn(ReactNativeLibraries.AppRegistry, 'runApplication').mockImplementation(jest.fn()); + }); + + afterEach(() => { + ReactNativeLibraries.AppRegistry = originalAppRegistry; + ReactNativeLibraries.AppRegistry.runApplication = originalRunApplication; + }); + + it('does not patch app registry on init before setup', async () => { + appRegistryIntegration(); + + expect(getOriginalFunction(ReactNativeLibraries.AppRegistry.runApplication)).toBeUndefined(); + expect(ReactNativeLibraries.AppRegistry.runApplication).toBe(mockedRunApplication); + }); + + it('patches app registry on init after setup', async () => { + appRegistryIntegration().setupOnce(); + + expect(getOriginalFunction(ReactNativeLibraries.AppRegistry.runApplication)).toBeDefined(); + expect(ReactNativeLibraries.AppRegistry.runApplication).not.toBe(mockedRunApplication); + }); + + it('executes callbacks when runApplication is called', async () => { + const firstMockedCallback = jest.fn(); + const secondMockedCallback = jest.fn(); + const integration = appRegistryIntegration(); + + integration.setupOnce(); + integration.onRunApplication(firstMockedCallback); + integration.onRunApplication(secondMockedCallback); + + ReactNativeLibraries.AppRegistry.runApplication('test-app', {}); + + expect(firstMockedCallback).toHaveBeenCalled(); + expect(secondMockedCallback).toHaveBeenCalled(); + }); + + it('registers and executes callback only once', async () => { + const mockedCallback = jest.fn(); + const integration = appRegistryIntegration(); + + integration.setupOnce(); + integration.onRunApplication(mockedCallback); + integration.onRunApplication(mockedCallback); + + ReactNativeLibraries.AppRegistry.runApplication('test-app', {}); + + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + + it('does not patch app registry on web', async () => { + jest.spyOn(Environment, 'isWeb').mockReturnValue(true); + const integration = appRegistryIntegration(); + + integration.setupOnce(); + + expect(ReactNativeLibraries.AppRegistry.runApplication).toBe(mockedRunApplication); + }); + + it('does not crash if AppRegistry is not available', async () => { + ReactNativeLibraries.AppRegistry = undefined; + const integration = appRegistryIntegration(); + + integration.setupOnce(); + }); +}); diff --git a/packages/core/test/integrations/expocontext.test.ts b/packages/core/test/integrations/expocontext.test.ts index 91059a4bc2..205fc35b57 100644 --- a/packages/core/test/integrations/expocontext.test.ts +++ b/packages/core/test/integrations/expocontext.test.ts @@ -1,102 +1,370 @@ -import type { Client, Event } from '@sentry/core'; +import { type Client, type Event, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; -import { expoContextIntegration } from '../../src/js/integrations/expocontext'; +import { + expoContextIntegration, + getExpoUpdatesContext, + OTA_UPDATES_CONTEXT_KEY, +} from '../../src/js/integrations/expocontext'; +import * as environment from '../../src/js/utils/environment'; +import type { ExpoUpdates } from '../../src/js/utils/expoglobalobject'; import { getExpoDevice } from '../../src/js/utils/expomodules'; +import * as expoModules from '../../src/js/utils/expomodules'; +import { setupTestClient } from '../mocks/client'; +import { NATIVE } from '../mockWrapper'; +jest.mock('../../src/js/wrapper', () => jest.requireActual('../mockWrapper')); jest.mock('../../src/js/utils/expomodules'); describe('Expo Context Integration', () => { - it('does not add device context because expo device module is not available', async () => { - (getExpoDevice as jest.Mock).mockReturnValue(undefined); - const actualEvent = await executeIntegrationFor({}); + afterEach(() => { + jest.clearAllMocks(); - expect(actualEvent.contexts?.device).toBeUndefined(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); }); - it('does not add os context because expo device module is not available', async () => { - (getExpoDevice as jest.Mock).mockReturnValue(undefined); - const actualEvent = await executeIntegrationFor({}); + describe('Set Native Context after init()', () => { + beforeEach(() => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({ + updateId: '123', + channel: 'default', + runtimeVersion: '1.0.0', + checkAutomatically: 'always', + emergencyLaunchReason: 'some reason', + launchDuration: 1000, + createdAt: new Date('2021-01-01T00:00:00.000Z'), + }); + }); - expect(actualEvent.contexts?.os).toBeUndefined(); - }); + it('calls setContext when native enabled', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] }); - it('adds expo device context', async () => { - (getExpoDevice as jest.Mock).mockReturnValue({ - deviceName: 'test device name', - isDevice: true, - modelName: 'test model name', - manufacturer: 'test manufacturer', - totalMemory: 1000, + expect(NATIVE.setContext).toHaveBeenCalledWith( + OTA_UPDATES_CONTEXT_KEY, + expect.objectContaining({ + update_id: '123', + channel: 'default', + runtime_version: '1.0.0', + }), + ); }); - const actualEvent = await executeIntegrationFor({}); - expect(actualEvent.contexts?.device).toStrictEqual({ - name: 'test device name', - simulator: false, - model: 'test model name', - manufacturer: 'test manufacturer', - memory_size: 1000, + it('does not call setContext when native disabled', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + setupTestClient({ enableNative: false, integrations: [expoContextIntegration()] }); + + expect(NATIVE.setContext).not.toHaveBeenCalled(); + }); + + it('does not call setContext when not expo', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(false); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + + setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] }); + + expect(NATIVE.setContext).not.toHaveBeenCalled(); + }); + + it('does not call setContext when expo go', () => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); + + setupTestClient({ enableNative: true, integrations: [expoContextIntegration()] }); + + expect(NATIVE.setContext).not.toHaveBeenCalled(); }); }); - it('adds expo os context', async () => { - (getExpoDevice as jest.Mock).mockReturnValue({ - osName: 'test os name', - osBuildId: 'test os build id', - osVersion: 'test os version', + describe('Non Expo App', () => { + beforeEach(() => { + jest.spyOn(environment, 'isExpo').mockReturnValue(false); }); - const actualEvent = await executeIntegrationFor({}); - expect(actualEvent.contexts?.os).toStrictEqual({ - name: 'test os name', - build: 'test os build id', - version: 'test os version', + it('does not add expo updates context', () => { + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toBeUndefined(); }); }); - it('merge existing event device context with expo', async () => { - (getExpoDevice as jest.Mock).mockReturnValue({ - deviceName: 'test device name', - simulator: true, - modelName: 'test model name', - manufacturer: 'test manufacturer', - totalMemory: 1000, - }); - const actualEvent = await executeIntegrationFor({ - contexts: { - device: { - name: 'existing device name', - }, - }, + describe('In Expo App', () => { + beforeEach(() => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(false); + }); + + it('only calls getExpoUpdates once', () => { + const getExpoUpdatesMock = jest.spyOn(expoModules, 'getExpoUpdates'); + + const integration = expoContextIntegration(); + integration.processEvent!({}, {}, {} as Client); + integration.processEvent!({}, {}, {} as Client); + + expect(getExpoUpdatesMock).toHaveBeenCalledTimes(1); + }); + + it('added context does not share the same reference', async () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({}); + + const integration = expoContextIntegration(); + const event1 = await integration.processEvent!({}, {}, {} as Client); + const event2 = await integration.processEvent!({}, {}, {} as Client); + + expect(event1.contexts![OTA_UPDATES_CONTEXT_KEY]).not.toBe(event2.contexts![OTA_UPDATES_CONTEXT_KEY]); + }); + + it('adds isEnabled false if ExpoUpdates module is missing', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue(undefined); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({ + is_enabled: false, + }); + }); + + it('adds all bool constants if ExpoUpdate module is empty', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({}); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({ + is_enabled: false, + is_embedded_launch: false, + is_emergency_launch: false, + is_using_embedded_assets: false, + }); + }); + + it('adds all non bool constants', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({ + updateId: '123', + channel: 'default', + runtimeVersion: '1.0.0', + checkAutomatically: 'always', + emergencyLaunchReason: 'some reason', + launchDuration: 1000, + createdAt: new Date('2021-01-01T00:00:00.000Z'), + }); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toEqual({ + is_enabled: false, + is_embedded_launch: false, + is_emergency_launch: false, + is_using_embedded_assets: false, + update_id: '123', + channel: 'default', + runtime_version: '1.0.0', + check_automatically: 'always', + emergency_launch_reason: 'some reason', + launch_duration: 1000, + created_at: '2021-01-01T00:00:00.000Z', + }); }); - expect(actualEvent.contexts?.device).toStrictEqual({ - name: 'existing device name', - simulator: true, - model: 'test model name', - manufacturer: 'test manufacturer', - memory_size: 1000, + it('avoids adding values of unexpected types', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({ + updateId: {}, + channel: {}, + runtimeVersion: {}, + checkAutomatically: {}, + emergencyLaunchReason: {}, + launchDuration: {}, + createdAt: {}, + } as unknown as ExpoUpdates); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({ + is_enabled: false, + is_embedded_launch: false, + is_emergency_launch: false, + is_using_embedded_assets: false, + }); }); }); - it('merge existing event os context with expo', async () => { - (getExpoDevice as jest.Mock).mockReturnValue({ - osName: 'test os name', - osBuildId: 'test os build id', - osVersion: 'test os version', + describe('In Expo Go', () => { + beforeEach(() => { + jest.spyOn(environment, 'isExpo').mockReturnValue(true); + jest.spyOn(environment, 'isExpoGo').mockReturnValue(true); + }); + + it('does add expo updates context', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({ + isEnabled: true, + isEmbeddedLaunch: false, + updateId: '123', + channel: 'default', + runtimeVersion: '1.0.0', + }); + + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.[OTA_UPDATES_CONTEXT_KEY]).toStrictEqual({ + is_enabled: true, + is_embedded_launch: false, + is_emergency_launch: false, + is_using_embedded_assets: false, + update_id: '123', + channel: 'default', + runtime_version: '1.0.0', + }); + }); + + it('does not add device context because expo device module is not available', () => { + (getExpoDevice as jest.Mock).mockReturnValue(undefined); + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.device).toBeUndefined(); + }); + + it('does not add os context because expo device module is not available', () => { + (getExpoDevice as jest.Mock).mockReturnValue(undefined); + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.os).toBeUndefined(); + }); + + it('adds expo device context', () => { + (getExpoDevice as jest.Mock).mockReturnValue({ + deviceName: 'test device name', + isDevice: true, + modelName: 'test model name', + manufacturer: 'test manufacturer', + totalMemory: 1000, + }); + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.device).toStrictEqual({ + name: 'test device name', + simulator: false, + model: 'test model name', + manufacturer: 'test manufacturer', + memory_size: 1000, + }); }); - const actualEvent = await executeIntegrationFor({ - contexts: { - os: { - name: 'existing os name', + + it('adds expo os context', () => { + (getExpoDevice as jest.Mock).mockReturnValue({ + osName: 'test os name', + osBuildId: 'test os build id', + osVersion: 'test os version', + }); + const actualEvent = executeIntegrationFor({}); + + expect(actualEvent.contexts?.os).toStrictEqual({ + name: 'test os name', + build: 'test os build id', + version: 'test os version', + }); + }); + + it('merge existing event device context with expo', () => { + (getExpoDevice as jest.Mock).mockReturnValue({ + deviceName: 'test device name', + simulator: true, + modelName: 'test model name', + manufacturer: 'test manufacturer', + totalMemory: 1000, + }); + const actualEvent = executeIntegrationFor({ + contexts: { + device: { + name: 'existing device name', + }, + }, + }); + + expect(actualEvent.contexts?.device).toStrictEqual({ + name: 'existing device name', + simulator: true, + model: 'test model name', + manufacturer: 'test manufacturer', + memory_size: 1000, + }); + }); + + it('merge existing event os context with expo', () => { + (getExpoDevice as jest.Mock).mockReturnValue({ + osName: 'test os name', + osBuildId: 'test os build id', + osVersion: 'test os version', + }); + const actualEvent = executeIntegrationFor({ + contexts: { + os: { + name: 'existing os name', + }, }, - }, + }); + + expect(actualEvent.contexts?.os).toStrictEqual({ + name: 'existing os name', + build: 'test os build id', + version: 'test os version', + }); + }); + }); + + describe('getExpoUpdatesContext', () => { + it('does not return empty values', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({ + isEnabled: false, + isEmbeddedLaunch: false, + isEmergencyLaunch: false, + isUsingEmbeddedAssets: false, + updateId: '', + channel: '', + runtimeVersion: '', + checkAutomatically: '', + emergencyLaunchReason: '', + launchDuration: 0, + createdAt: new Date('2021-01-01T00:00:00.000Z'), + }); + + const expoUpdates = getExpoUpdatesContext(); + + expect(expoUpdates).toStrictEqual({ + is_enabled: false, + is_embedded_launch: false, + is_emergency_launch: false, + is_using_embedded_assets: false, + launch_duration: 0, + created_at: '2021-01-01T00:00:00.000Z', + }); }); - expect(actualEvent.contexts?.os).toStrictEqual({ - name: 'existing os name', - build: 'test os build id', - version: 'test os version', + it('lowercases all string values', () => { + jest.spyOn(expoModules, 'getExpoUpdates').mockReturnValue({ + updateId: 'UPPERCASE-123', + channel: 'UPPERCASE-123', + runtimeVersion: 'UPPERCASE-123', + checkAutomatically: 'UPPERCASE-123', + emergencyLaunchReason: 'This is a description of the reason.', + createdAt: new Date('2021-01-01T00:00:00.000Z'), + }); + + const expoUpdates = getExpoUpdatesContext(); + + expect(expoUpdates).toEqual( + expect.objectContaining({ + update_id: 'uppercase-123', + channel: 'uppercase-123', + runtime_version: 'uppercase-123', + check_automatically: 'uppercase-123', + emergency_launch_reason: 'This is a description of the reason.', // Description should be kept as is + created_at: '2021-01-01T00:00:00.000Z', // Date should keep ISO string format + }), + ); }); }); diff --git a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts index aff3b6210f..076008fc61 100644 --- a/packages/core/test/integrations/reactnativeerrorhandlers.test.ts +++ b/packages/core/test/integrations/reactnativeerrorhandlers.test.ts @@ -1,16 +1,53 @@ jest.mock('../../src/js/integrations/reactnativeerrorhandlersutils'); +jest.mock('../../src/js/utils/environment'); -import type { ExtendedError, Mechanism, SeverityLevel } from '@sentry/core'; -import { setCurrentClient } from '@sentry/core'; +import type { SeverityLevel } from '@sentry/core'; +import { addGlobalUnhandledRejectionInstrumentationHandler, captureException, setCurrentClient } from '@sentry/core'; import { reactNativeErrorHandlersIntegration } from '../../src/js/integrations/reactnativeerrorhandlers'; -import { requireRejectionTracking } from '../../src/js/integrations/reactnativeerrorhandlersutils'; +import { + checkPromiseAndWarn, + polyfillPromise, + requireRejectionTracking, +} from '../../src/js/integrations/reactnativeerrorhandlersutils'; +import { isHermesEnabled, isWeb } from '../../src/js/utils/environment'; +import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +let errorHandlerCallback: ((error: Error, isFatal?: boolean) => Promise) | null = null; + +jest.mock('../../src/js/utils/worldwide', () => { + const actual = jest.requireActual('../../src/js/utils/worldwide'); + return { + ...actual, + RN_GLOBAL_OBJ: { + ...actual.RN_GLOBAL_OBJ, + ErrorUtils: { + setGlobalHandler: jest.fn(callback => { + errorHandlerCallback = callback; + }), + getGlobalHandler: jest.fn(() => jest.fn()), + reportError: jest.fn(), + }, + }, + }; +}); + +jest.mock('@sentry/core', () => { + const actual = jest.requireActual('@sentry/core'); + return { + ...actual, + captureException: jest.fn(), + addGlobalUnhandledRejectionInstrumentationHandler: jest.fn(), + }; +}); + describe('ReactNativeErrorHandlers', () => { let client: TestClient; let mockDisable: jest.Mock; let mockEnable: jest.Mock; + let originalHermesInternal: any; + let mockEnablePromiseRejectionTracker: jest.Mock; beforeEach(() => { mockDisable = jest.fn(); @@ -19,35 +56,48 @@ describe('ReactNativeErrorHandlers', () => { disable: mockDisable, enable: mockEnable, }); - ErrorUtils.getGlobalHandler = () => jest.fn(); + (polyfillPromise as jest.Mock).mockImplementation(() => {}); + (checkPromiseAndWarn as jest.Mock).mockImplementation(() => {}); + + errorHandlerCallback = null; + + (isWeb as jest.Mock).mockReturnValue(false); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + + originalHermesInternal = RN_GLOBAL_OBJ.HermesInternal; client = new TestClient(getDefaultTestClientOptions()); setCurrentClient(client); client.init(); + + mockEnablePromiseRejectionTracker = jest.fn(); + RN_GLOBAL_OBJ.HermesInternal = { + enablePromiseRejectionTracker: mockEnablePromiseRejectionTracker, + hasPromise: jest.fn(() => true), + }; + + jest.clearAllMocks(); }); afterEach(() => { jest.clearAllMocks(); + RN_GLOBAL_OBJ.HermesInternal = originalHermesInternal; }); describe('onError', () => { - let errorHandlerCallback: (error: Error, isFatal: boolean) => Promise; - - beforeEach(() => { - errorHandlerCallback = () => Promise.resolve(); - - ErrorUtils.setGlobalHandler = jest.fn(_callback => { - errorHandlerCallback = _callback as typeof errorHandlerCallback; - }); - + test('Sets up the global error handler', () => { const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); - expect(ErrorUtils.setGlobalHandler).toHaveBeenCalledWith(errorHandlerCallback); + expect(RN_GLOBAL_OBJ.ErrorUtils.setGlobalHandler).toHaveBeenCalled(); }); test('Sets handled:false on a fatal error', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(errorHandlerCallback).not.toBeNull(); + await errorHandlerCallback(new Error('Test Error'), true); await client.flush(); @@ -59,6 +109,11 @@ describe('ReactNativeErrorHandlers', () => { }); test('Does not set handled:false on a non-fatal error', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(errorHandlerCallback).not.toBeNull(); + await errorHandlerCallback(new Error('Test Error'), false); await client.flush(); @@ -70,6 +125,11 @@ describe('ReactNativeErrorHandlers', () => { }); test('Includes original exception in hint', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(errorHandlerCallback).not.toBeNull(); + await errorHandlerCallback(new Error('Test Error'), false); await client.flush(); @@ -80,17 +140,14 @@ describe('ReactNativeErrorHandlers', () => { }); describe('onUnhandledRejection', () => { - test('unhandled rejected promise is captured with synthetical error', async () => { + test('unhandled rejected promise is captured with JSC approach', async () => { + (isWeb as jest.Mock).mockReturnValue(false); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); - const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; - actualTrackingOptions?.onUnhandled?.(1, 'Test Error'); - - await client.flush(); - const actualSyntheticError = client.hint?.syntheticException; - - expect(mockDisable).not.toHaveBeenCalled(); + expect(polyfillPromise).toHaveBeenCalled(); expect(mockEnable).toHaveBeenCalledWith( expect.objectContaining({ allRejections: true, @@ -98,53 +155,180 @@ describe('ReactNativeErrorHandlers', () => { onHandled: expect.any(Function), }), ); - expect(mockEnable).toHaveBeenCalledTimes(1); - expect((actualSyntheticError as ExtendedError).framesToPop).toBe(3); - }); - - test('error like unhandled rejected promise is captured without synthetical error', async () => { - const integration = reactNativeErrorHandlersIntegration(); - integration.setupOnce(); - const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; - actualTrackingOptions?.onUnhandled?.(1, new Error('Test Error')); + const [options] = mockEnable.mock.calls[0]; + const onUnhandledHandler = options.onUnhandled; - await client.flush(); - const actualSyntheticError = client.hint?.syntheticException; + onUnhandledHandler('test-id', 'Test Error'); - expect(mockDisable).not.toHaveBeenCalled(); - expect(mockEnable).toHaveBeenCalledWith( + expect(captureException).toHaveBeenCalledWith( + 'Test Error', expect.objectContaining({ - allRejections: true, - onUnhandled: expect.any(Function), - onHandled: expect.any(Function), + data: { id: 'test-id' }, + originalException: 'Test Error', + mechanism: { + handled: true, + type: 'onunhandledrejection', + }, }), ); - expect(mockEnable).toHaveBeenCalledTimes(1); - expect(actualSyntheticError).toBeUndefined(); }); - test('unhandled rejected sets error mechanism', async () => { + test('error like unhandled rejected promise is captured without synthetical error', async () => { + (isWeb as jest.Mock).mockReturnValue(false); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + const integration = reactNativeErrorHandlersIntegration(); integration.setupOnce(); - const [actualTrackingOptions] = mockEnable.mock.calls[0] || []; - actualTrackingOptions?.onUnhandled?.(1, 'Test Error'); + const [options] = mockEnable.mock.calls[0]; + const onUnhandledHandler = options.onUnhandled; - await client.flush(); - const actualSyntheticError = client.hint?.syntheticException; - const errorMechanism = client.event?.exception?.values[0]?.mechanism; - expect(mockDisable).not.toHaveBeenCalled(); - expect(mockEnable).toHaveBeenCalledWith( + const error = new Error('Test Error'); + onUnhandledHandler('test-id', error); + + expect(captureException).toHaveBeenCalledWith( + error, expect.objectContaining({ - allRejections: true, - onUnhandled: expect.any(Function), - onHandled: expect.any(Function), + data: { id: 'test-id' }, + originalException: error, + syntheticException: undefined, + mechanism: { + handled: true, + type: 'onunhandledrejection', + }, }), ); - expect(mockEnable).toHaveBeenCalledTimes(1); - expect((actualSyntheticError as ExtendedError).framesToPop).toBe(3); - expect(errorMechanism).toEqual({ handled: true, type: 'onunhandledrejection' } as Mechanism); + }); + + describe('Hermes engine', () => { + beforeEach(() => { + (isHermesEnabled as jest.Mock).mockReturnValue(true); + }); + + test('uses native Hermes promise rejection tracking', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledTimes(1); + expect(mockEnablePromiseRejectionTracker).toHaveBeenCalledWith( + expect.objectContaining({ + allRejections: true, + onUnhandled: expect.any(Function), + onHandled: expect.any(Function), + }), + ); + + expect(polyfillPromise).not.toHaveBeenCalled(); + }); + + test('captures unhandled rejection with Hermes tracker', async () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + const [options] = mockEnablePromiseRejectionTracker.mock.calls[0]; + const onUnhandledHandler = options.onUnhandled; + + const testError = new Error('Hermes Test Error'); + onUnhandledHandler('hermes-test-error', testError); + + expect(captureException).toHaveBeenCalledWith( + testError, + expect.objectContaining({ + data: { id: 'hermes-test-error' }, + originalException: testError, + mechanism: { + handled: true, + type: 'onunhandledrejection', + }, + }), + ); + }); + }); + + describe('React Native Web', () => { + beforeEach(() => { + (isWeb as jest.Mock).mockReturnValue(true); + (isHermesEnabled as jest.Mock).mockReturnValue(false); + }); + + test('uses addGlobalUnhandledRejectionInstrumentationHandler for React Native Web', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledTimes(1); + expect(addGlobalUnhandledRejectionInstrumentationHandler).toHaveBeenCalledWith(expect.any(Function)); + + // Verify JSC fallback was not used + expect(polyfillPromise).not.toHaveBeenCalled(); + expect(requireRejectionTracking).not.toHaveBeenCalled(); + }); + + test('captures unhandled rejection with the callback', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0]; + + const mockError = new Error('Web Test Error'); + callback(mockError); + + expect(captureException).toHaveBeenCalledWith( + mockError, + expect.objectContaining({ + originalException: mockError, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }), + ); + }); + + test('handles non-error rejection with synthetic error', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + const [callback] = (addGlobalUnhandledRejectionInstrumentationHandler as jest.Mock).mock.calls[0]; + + const nonErrorObject = { message: 'Custom rejection object' }; + callback(nonErrorObject); + + expect(captureException).toHaveBeenCalledWith( + nonErrorObject, + expect.objectContaining({ + originalException: nonErrorObject, + syntheticException: expect.anything(), + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }), + ); + }); + }); + + describe('JSC and other environments', () => { + beforeEach(() => { + (isHermesEnabled as jest.Mock).mockReturnValue(false); + (isWeb as jest.Mock).mockReturnValue(false); + }); + + test('uses existing polyfill for JSC environments', () => { + const integration = reactNativeErrorHandlersIntegration(); + integration.setupOnce(); + + expect(polyfillPromise).toHaveBeenCalledTimes(1); + expect(requireRejectionTracking).toHaveBeenCalledTimes(1); + }); + + test('respects patchGlobalPromise option', () => { + const integration = reactNativeErrorHandlersIntegration({ patchGlobalPromise: false }); + integration.setupOnce(); + + expect(polyfillPromise).not.toHaveBeenCalled(); + expect(requireRejectionTracking).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts index fe6a611394..c26c384e26 100644 --- a/packages/core/test/mockWrapper.ts +++ b/packages/core/test/mockWrapper.ts @@ -58,8 +58,11 @@ const NATIVE: MockInterface = { getCurrentReplayId: jest.fn(), crashedLastRun: jest.fn(), - getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42), + getNewScreenTimeToDisplay: jest.fn(), getDataFromUri: jest.fn(), + popTimeToDisplayFor: jest.fn(), + setActiveSpanId: jest.fn(), + encodeToBase64: jest.fn(), }; NATIVE.isNativeAvailable.mockReturnValue(true); @@ -84,6 +87,8 @@ NATIVE.initNativeReactNavigationNewFrameTracking.mockReturnValue(Promise.resolve NATIVE.captureReplay.mockResolvedValue(null); NATIVE.getCurrentReplayId.mockReturnValue(null); NATIVE.crashedLastRun.mockResolvedValue(false); +NATIVE.popTimeToDisplayFor.mockResolvedValue(null); +NATIVE.getNewScreenTimeToDisplay.mockResolvedValue(null); export const getRNSentryModule = jest.fn(); diff --git a/packages/core/test/mocks/appRegistryIntegrationMock.ts b/packages/core/test/mocks/appRegistryIntegrationMock.ts new file mode 100644 index 0000000000..12fc0dd273 --- /dev/null +++ b/packages/core/test/mocks/appRegistryIntegrationMock.ts @@ -0,0 +1,14 @@ +import * as AppRegistry from '../../src/js/integrations/appRegistry'; + +export const mockAppRegistryIntegration = () => { + const mockedOnRunApplication = jest.fn(); + const mockedGetAppRegistryIntegration = jest.spyOn(AppRegistry, 'getAppRegistryIntegration').mockReturnValue({ + onRunApplication: mockedOnRunApplication, + name: 'appRegistryIntegrationMocked', + }); + + return { + mockedOnRunApplication, + mockedGetAppRegistryIntegration, + }; +}; diff --git a/packages/core/test/mocks/client.ts b/packages/core/test/mocks/client.ts index 8a0df75ebc..1a18e94365 100644 --- a/packages/core/test/mocks/client.ts +++ b/packages/core/test/mocks/client.ts @@ -114,5 +114,9 @@ export function setupTestClient(options: Partial = {}): TestC const client = new TestClient(finalOptions); setCurrentClient(client); client.init(); + + // @ts-expect-error Only available on ReactNativeClient + client.emit('afterInit'); + return client; } diff --git a/packages/core/test/profiling/fixtures.ts b/packages/core/test/profiling/fixtures.ts index 7a4e777598..f406a613f3 100644 --- a/packages/core/test/profiling/fixtures.ts +++ b/packages/core/test/profiling/fixtures.ts @@ -80,6 +80,15 @@ export function createMockMinimalValidHermesProfileEvent(): HermesProfileEvent { }; } +/** + * Create a mock native (iOS/Apple) profile. + */ +export function createMockMinimalValidAppleProfileWithoutDebugMeta(): NativeProfileEvent { + const profile = createMockMinimalValidAppleProfile(); + delete profile.debug_meta; + return profile; +} + /** * Create a mock native (iOS/Apple) profile. */ diff --git a/packages/core/test/profiling/integration.test.ts b/packages/core/test/profiling/integration.test.ts index 83da5cc53d..43a7d3cc75 100644 --- a/packages/core/test/profiling/integration.test.ts +++ b/packages/core/test/profiling/integration.test.ts @@ -17,6 +17,7 @@ import { envelopeItemPayload, envelopeItems } from '../testutils'; import { createMockMinimalValidAndroidProfile, createMockMinimalValidAppleProfile, + createMockMinimalValidAppleProfileWithoutDebugMeta, createMockMinimalValidHermesProfile, } from './fixtures'; @@ -155,6 +156,41 @@ describe('profiling integration', () => { }); describe('with native profiling', () => { + test('should create a new mixed profile and add it to the transaction envelope with missing debug_meta', () => { + mockWrapper.NATIVE.stopProfiling.mockReturnValue({ + hermesProfile: createMockMinimalValidHermesProfile(), + nativeProfile: createMockMinimalValidAppleProfileWithoutDebugMeta(), + }); + + const transaction = Sentry.startSpan({ name: 'test-name' }, span => span); + + jest.runAllTimers(); + + const envelope: Envelope | undefined = mock.transportSendMock.mock.lastCall?.[0]; + expectEnvelopeToContainProfile(envelope, 'test-name', spanToJSON(transaction).trace_id); + // Expect merged profile + expect(getProfileFromEnvelope(envelope)).toEqual( + expect.objectContaining(>{ + profile: expect.objectContaining(>{ + frames: [ + { + function: '[root]', + in_app: false, + }, + { + instruction_addr: '0x0000000000000003', + platform: 'cocoa', + }, + { + instruction_addr: '0x0000000000000004', + platform: 'cocoa', + }, + ], + }), + }), + ); + }); + test('should create a new mixed profile and add it to the transaction envelope', () => { mockWrapper.NATIVE.stopProfiling.mockReturnValue({ hermesProfile: createMockMinimalValidHermesProfile(), @@ -262,8 +298,9 @@ describe('profiling integration', () => { const transaction1 = Sentry.startSpanManual({ name: 'test-name-1' }, span => span); const transaction2 = Sentry.startSpanManual({ name: 'test-name-2' }, span => span); transaction1.end(); - transaction2.end(); + jest.runOnlyPendingTimers(); + transaction2.end(); jest.runAllTimers(); expectEnvelopeToContainProfile( diff --git a/packages/core/test/replay/CustomMask.test.ts b/packages/core/test/replay/CustomMask.test.ts index 7ae7978665..af8d677a62 100644 --- a/packages/core/test/replay/CustomMask.test.ts +++ b/packages/core/test/replay/CustomMask.test.ts @@ -2,9 +2,23 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; describe('CustomMask', () => { beforeEach(() => { + jest.mock('../../src/js/utils/environment', () => ({ + isExpoGo: () => false, + })); jest.resetModules(); }); + it('returns a fallback when isExpoGo is true', () => { + jest.mock('../../src/js/utils/environment', () => ({ + isExpoGo: () => true, + })); + + const { Mask, Unmask, MaskFallback, UnmaskFallback } = require('../../src/js/replay/CustomMask'); + + expect(Mask).toBe(MaskFallback); + expect(Unmask).toBe(UnmaskFallback); + }); + it('returns a fallback when native view manager is missing', () => { jest.mock('react-native', () => ({ UIManager: {}, diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index 0e64264899..3affd059cc 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -780,6 +780,37 @@ describe('Tests the SDK functionality', () => { }); }); + describe('app registry integration', () => { + test('no integration when tracing disabled', () => { + init({}); + + expectNotIntegration('AppRegistry'); + }); + test('integration added when tracing enabled', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectIntegration('AppRegistry'); + }); + }); + + describe('time to display integration', () => { + it('no integration when tracing disabled', () => { + init({}); + + expectNotIntegration('TimeToDisplay'); + }); + + it('integration added when tracing enabled', () => { + init({ + tracesSampleRate: 0.5, + }); + + expectIntegration('TimeToDisplay'); + }); + }); + it('adds spotlight integration with spotlight bool', () => { init({ spotlight: true, @@ -904,6 +935,7 @@ describe('Tests the SDK functionality', () => { expectIntegration('EventOrigin'); expectIntegration('SdkInfo'); expectIntegration('ReactNativeInfo'); + expectIntegration('ExpoContext'); }); it('adds web platform specific default integrations', () => { diff --git a/packages/core/test/testutils.ts b/packages/core/test/testutils.ts index 76bdd990a6..bf0aa9f3d7 100644 --- a/packages/core/test/testutils.ts +++ b/packages/core/test/testutils.ts @@ -62,6 +62,10 @@ export const createMockTransport = (): MockInterface => { }; }; +export const nowInSeconds = (): number => { + return Date.now() / 1000; +}; + export const secondAgoTimestampMs = (): number => { return new Date(Date.now() - 1000).getTime(); }; diff --git a/packages/core/test/tools/metroconfig.test.ts b/packages/core/test/tools/metroconfig.test.ts index 7fc19888f1..2329509754 100644 --- a/packages/core/test/tools/metroconfig.test.ts +++ b/packages/core/test/tools/metroconfig.test.ts @@ -169,72 +169,74 @@ describe('metroconfig', () => { })); }); - test('keep Web Replay when platform is web and includeWebReplay is true', () => { - const modifiedConfig = withSentryResolver(config, true); - resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'web'); + describe.each([['@sentry/replay'], ['@sentry-internal/replay']])('with %s', replayPackage => { + test('keep Web Replay when platform is web and includeWebReplay is true', () => { + const modifiedConfig = withSentryResolver(config, true); + resolveRequest(modifiedConfig, contextMock, replayPackage, 'web'); - ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'web'); - }); + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, 'web'); + }); - test('removes Web Replay when platform is web and includeWebReplay is false', () => { - const modifiedConfig = withSentryResolver(config, false); - const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'web'); + test('removes Web Replay when platform is web and includeWebReplay is false', () => { + const modifiedConfig = withSentryResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'web'); - expect(result).toEqual({ type: 'empty' }); - expect(originalResolverMock).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); - test('keep Web Replay when platform is android and includeWebReplay is true', () => { - const modifiedConfig = withSentryResolver(config, true); - resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android'); + test('keep Web Replay when platform is android and includeWebReplay is true', () => { + const modifiedConfig = withSentryResolver(config, true); + resolveRequest(modifiedConfig, contextMock, replayPackage, 'android'); - ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'android'); - }); + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, 'android'); + }); - test('removes Web Replay when platform is android and includeWebReplay is false', () => { - const modifiedConfig = withSentryResolver(config, false); - const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android'); + test('removes Web Replay when platform is android and includeWebReplay is false', () => { + const modifiedConfig = withSentryResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'android'); - expect(result).toEqual({ type: 'empty' }); - expect(originalResolverMock).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); - test('removes Web Replay when platform is android and includeWebReplay is undefined', () => { - const modifiedConfig = withSentryResolver(config, undefined); - const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'android'); + test('removes Web Replay when platform is android and includeWebReplay is undefined', () => { + const modifiedConfig = withSentryResolver(config, undefined); + const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'android'); - expect(result).toEqual({ type: 'empty' }); - expect(originalResolverMock).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); - test('keep Web Replay when platform is undefined and includeWebReplay is null', () => { - const modifiedConfig = withSentryResolver(config, undefined); - resolveRequest(modifiedConfig, contextMock, '@sentry/replay', null); + test('keep Web Replay when platform is undefined and includeWebReplay is null', () => { + const modifiedConfig = withSentryResolver(config, undefined); + resolveRequest(modifiedConfig, contextMock, replayPackage, null); - ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', null); - }); + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, null); + }); - test('keep Web Replay when platform is ios and includeWebReplay is true', () => { - const modifiedConfig = withSentryResolver(config, true); - resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios'); + test('keep Web Replay when platform is ios and includeWebReplay is true', () => { + const modifiedConfig = withSentryResolver(config, true); + resolveRequest(modifiedConfig, contextMock, replayPackage, 'ios'); - ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, '@sentry/replay', 'ios'); - }); + ExpectToBeCalledWithMetroParameters(originalResolverMock, contextMock, replayPackage, 'ios'); + }); - test('removes Web Replay when platform is ios and includeWebReplay is false', () => { - const modifiedConfig = withSentryResolver(config, false); - const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios'); + test('removes Web Replay when platform is ios and includeWebReplay is false', () => { + const modifiedConfig = withSentryResolver(config, false); + const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'ios'); - expect(result).toEqual({ type: 'empty' }); - expect(originalResolverMock).not.toHaveBeenCalled(); - }); + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); - test('removes Web Replay when platform is ios and includeWebReplay is undefined', () => { - const modifiedConfig = withSentryResolver(config, undefined); - const result = resolveRequest(modifiedConfig, contextMock, '@sentry/replay', 'ios'); + test('removes Web Replay when platform is ios and includeWebReplay is undefined', () => { + const modifiedConfig = withSentryResolver(config, undefined); + const result = resolveRequest(modifiedConfig, contextMock, replayPackage, 'ios'); - expect(result).toEqual({ type: 'empty' }); - expect(originalResolverMock).not.toHaveBeenCalled(); + expect(result).toEqual({ type: 'empty' }); + expect(originalResolverMock).not.toHaveBeenCalled(); + }); }); test('calls originalResolver when moduleName is not @sentry/replay', () => { diff --git a/packages/core/test/tracing/integrations/appStart.test.ts b/packages/core/test/tracing/integrations/appStart.test.ts index 4337e3e2b3..b31c35db66 100644 --- a/packages/core/test/tracing/integrations/appStart.test.ts +++ b/packages/core/test/tracing/integrations/appStart.test.ts @@ -1,4 +1,4 @@ -import type { ErrorEvent, Event, SpanJSON, TransactionEvent } from '@sentry/core'; +import type { ErrorEvent, Event, Integration, SpanJSON, TransactionEvent } from '@sentry/core'; import { getCurrentScope, getGlobalScope, @@ -27,12 +27,18 @@ import { setRootComponentCreationTimestampMs, } from '../../../src/js/tracing/integrations/appStart'; import { SPAN_ORIGIN_AUTO_APP_START, SPAN_ORIGIN_MANUAL_APP_START } from '../../../src/js/tracing/origin'; +import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_MAIN } from '../../../src/js/tracing/span'; import { getTimeOriginMilliseconds } from '../../../src/js/tracing/utils'; import { RN_GLOBAL_OBJ } from '../../../src/js/utils/worldwide'; import { NATIVE } from '../../../src/js/wrapper'; +import { mockAppRegistryIntegration } from '../../mocks/appRegistryIntegrationMock'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { mockFunction } from '../../testutils'; +type AppStartIntegrationTest = ReturnType & { + setFirstStartedActiveRootSpanId: (spanId: string | undefined) => void; +}; + let dateNowSpy: jest.SpyInstance; jest.mock('../../../src/js/wrapper', () => { @@ -122,11 +128,15 @@ describe('App Start Integration', () => { it('Does add App Start Span older than threshold in development builds', async () => { set__DEV__(true); - const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooOldAppStart(); + const [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds] = mockTooOldAppStart(); const actualEvent = await captureStandAloneAppStart(); expect(actualEvent).toEqual( - expectEventWithStandaloneWarmAppStart(actualEvent, { timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithStandaloneWarmAppStart(actualEvent, { + timeOriginMilliseconds, + appStartTimeMilliseconds, + appStartDurationMilliseconds, + }), ); }); @@ -252,6 +262,7 @@ describe('App Start Integration', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_MAIN, }, }), ); @@ -426,21 +437,27 @@ describe('App Start Integration', () => { it('Does not add App Start Span older than threshold', async () => { set__DEV__(false); - mockTooOldAppStart(); + const [timeOriginMilliseconds] = mockTooOldAppStart(); - const actualEvent = await processEvent(getMinimalTransactionEvent()); - expect(actualEvent).toStrictEqual(getMinimalTransactionEvent()); + const actualEvent = await processEvent( + getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }), + ); + expect(actualEvent).toStrictEqual(getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds })); }); it('Does add App Start Span older than threshold in development builds', async () => { set__DEV__(true); - const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockTooOldAppStart(); + const [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds] = mockTooOldAppStart(); const actualEvent = await processEvent( getMinimalTransactionEvent({ startTimestampSeconds: timeOriginMilliseconds }), ); expect(actualEvent).toEqual( - expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), + expectEventWithAttachedWarmAppStart({ + timeOriginMilliseconds, + appStartTimeMilliseconds, + appStartDurationMilliseconds, + }), ); }); @@ -612,6 +629,7 @@ describe('App Start Integration', () => { data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: appStartRootSpan!.op, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_AUTO_APP_START, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_MAIN, }, }), ); @@ -683,13 +701,63 @@ describe('App Start Integration', () => { ); }); + it('run application before initial app start is flushed is ignored, app start is attached only once', async () => { + const { mockedOnRunApplication } = mockAppRegistryIntegration(); + mockAppStart({ cold: true }); + + const event = getMinimalTransactionEvent(); + const integration = setupIntegration(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(event.contexts?.trace?.span_id); + + const registerAppStartCallback = mockedOnRunApplication.mock.calls[0][0]; + registerAppStartCallback(); + + const actualFirstEvent = await processEventWithIntegration(integration, event); + const actualSecondEvent = await processEventWithIntegration(integration, getMinimalTransactionEvent()); + + expect(actualFirstEvent.measurements[APP_START_COLD_MEASUREMENT]).toBeDefined(); + expect(actualSecondEvent.measurements).toBeUndefined(); + }); + + it('run application after initial app start is flushed allows attaching app start to the next root span', async () => { + const { mockedOnRunApplication } = mockAppRegistryIntegration(); + mockAppStart({ cold: true }); + + const firstEvent = getMinimalTransactionEvent(); + const integration = setupIntegration(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); + + const actualFirstEvent = await processEventWithIntegration(integration, firstEvent); + + const registerAppStartCallback = mockedOnRunApplication.mock.calls[0][0]; + registerAppStartCallback(); + + mockAppStart({ cold: false }); + const secondEvent = getMinimalTransactionEvent(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(secondEvent.contexts?.trace?.span_id); + const actualSecondEvent = await processEventWithIntegration(integration, secondEvent); + + expect(actualFirstEvent.measurements[APP_START_COLD_MEASUREMENT]).toBeDefined(); + expect(actualSecondEvent.measurements[APP_START_WARM_MEASUREMENT]).toBeDefined(); + }); + + it('Does not add app start span if app start end timestamp is before app start timestamp', async () => { + mockAppStart({ cold: true, appStartEndTimestampMs: Date.now() - 1000 }); + + const actualEvent = await processEvent(getMinimalTransactionEvent()); + expect(actualEvent.measurements).toBeUndefined(); + }); + it('Does not add app start span twice', async () => { const [timeOriginMilliseconds, appStartTimeMilliseconds] = mockAppStart({ cold: true }); const integration = appStartIntegration(); const client = new TestClient(getDefaultTestClientOptions()); - const actualEvent = await integration.processEvent(getMinimalTransactionEvent(), {}, client); + const firstEvent = getMinimalTransactionEvent(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(firstEvent.contexts?.trace?.span_id); + + const actualEvent = await integration.processEvent(firstEvent, {}, client); expect(actualEvent).toEqual( expectEventWithAttachedColdAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds }), ); @@ -720,11 +788,24 @@ describe('App Start Integration', () => { }); }); -function processEvent(event: Event): PromiseLike | Event | null { +function setupIntegration() { + const client = new TestClient(getDefaultTestClientOptions()); const integration = appStartIntegration(); + integration.afterAllSetup(client); + + return integration; +} + +function processEventWithIntegration(integration: Integration, event: Event) { return integration.processEvent(event, {}, new TestClient(getDefaultTestClientOptions())); } +function processEvent(event: Event): PromiseLike | Event | null { + const integration = setupIntegration(); + (integration as AppStartIntegrationTest).setFirstStartedActiveRootSpanId(event.contexts?.trace?.span_id); + return processEventWithIntegration(integration, event); +} + async function captureStandAloneAppStart(): Promise | Event | null> { getCurrentScope().clear(); getIsolationScope().clear(); @@ -830,9 +911,11 @@ function expectEventWithAttachedColdAppStart({ function expectEventWithAttachedWarmAppStart({ timeOriginMilliseconds, appStartTimeMilliseconds, + appStartDurationMilliseconds, }: { timeOriginMilliseconds: number; appStartTimeMilliseconds: number; + appStartDurationMilliseconds?: number; }) { return expect.objectContaining({ type: 'transaction', @@ -849,7 +932,7 @@ function expectEventWithAttachedWarmAppStart({ }), measurements: expect.objectContaining({ [APP_START_WARM_MEASUREMENT]: { - value: timeOriginMilliseconds - appStartTimeMilliseconds, + value: appStartDurationMilliseconds || timeOriginMilliseconds - appStartTimeMilliseconds, unit: 'millisecond', }, }), @@ -935,9 +1018,11 @@ function expectEventWithStandaloneWarmAppStart( { timeOriginMilliseconds, appStartTimeMilliseconds, + appStartDurationMilliseconds, }: { timeOriginMilliseconds: number; appStartTimeMilliseconds: number; + appStartDurationMilliseconds?: number; }, ) { return expect.objectContaining({ @@ -955,7 +1040,7 @@ function expectEventWithStandaloneWarmAppStart( }), measurements: expect.objectContaining({ [APP_START_WARM_MEASUREMENT]: { - value: timeOriginMilliseconds - appStartTimeMilliseconds, + value: appStartDurationMilliseconds || timeOriginMilliseconds - appStartTimeMilliseconds, unit: 'millisecond', }, }), @@ -984,12 +1069,14 @@ function mockAppStart({ has_fetched = false, enableNativeSpans = false, customNativeSpans = [], + appStartEndTimestampMs = undefined, }: { cold?: boolean; has_fetched?: boolean; enableNativeSpans?: boolean; customNativeSpans?: NativeAppStartResponse['spans']; -}) { + appStartEndTimestampMs?: number; +} = {}) { const timeOriginMilliseconds = Date.now(); const appStartTimeMilliseconds = timeOriginMilliseconds - 100; const mockAppStartResponse: NativeAppStartResponse = { @@ -1008,7 +1095,7 @@ function mockAppStart({ : [], }; - _setAppStartEndTimestampMs(timeOriginMilliseconds); + _setAppStartEndTimestampMs(appStartEndTimestampMs || timeOriginMilliseconds); mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); @@ -1034,7 +1121,10 @@ function mockTooLongAppStart() { function mockTooOldAppStart() { const timeOriginMilliseconds = Date.now(); + // Ensures app start is old (more than 1 minute before transaction start) const appStartTimeMilliseconds = timeOriginMilliseconds - 65000; + const appStartEndTimestampMilliseconds = appStartTimeMilliseconds + 5000; + const appStartDurationMilliseconds = appStartEndTimestampMilliseconds - appStartTimeMilliseconds; const mockAppStartResponse: NativeAppStartResponse = { type: 'warm', app_start_timestamp_ms: appStartTimeMilliseconds, @@ -1043,13 +1133,14 @@ function mockTooOldAppStart() { }; // App start finish timestamp - _setAppStartEndTimestampMs(timeOriginMilliseconds); + // App start length is 5 seconds + _setAppStartEndTimestampMs(appStartEndTimestampMilliseconds); mockFunction(getTimeOriginMilliseconds).mockReturnValue(timeOriginMilliseconds - 64000); mockFunction(NATIVE.fetchNativeAppStart).mockResolvedValue(mockAppStartResponse); // Transaction start timestamp mockFunction(timestampInSeconds).mockReturnValue(timeOriginMilliseconds / 1000 + 65); - return [timeOriginMilliseconds, appStartTimeMilliseconds]; + return [timeOriginMilliseconds, appStartTimeMilliseconds, appStartDurationMilliseconds]; } /** diff --git a/packages/core/test/tracing/mockedtimetodisplaynative.tsx b/packages/core/test/tracing/mockedtimetodisplaynative.tsx index 14c78fc5e0..6fbc773b53 100644 --- a/packages/core/test/tracing/mockedtimetodisplaynative.tsx +++ b/packages/core/test/tracing/mockedtimetodisplaynative.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { View } from 'react-native'; -import type { RNSentryOnDrawNextFrameEvent, RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; +import type { RNSentryOnDrawReporterProps } from '../../src/js/tracing/timetodisplaynative.types'; +import { NATIVE } from '../mockWrapper'; export let nativeComponentExists = true; @@ -9,18 +10,44 @@ export function setMockedNativeComponentExists(value: boolean): void { nativeComponentExists = value; } -export let mockedOnDrawNextFrame: (event: { nativeEvent: RNSentryOnDrawNextFrameEvent }) => void; +/** + * { + * [spanId]: timestampInSeconds, + * } + */ +export function mockRecordedTimeToDisplay({ + ttidNavigation = {}, + ttid = {}, + ttfd = {}, +}: { + 'ttidNavigation'?: Record, + ttid?: Record, + ttfd?: Record, +}): void { + NATIVE.popTimeToDisplayFor.mockImplementation((key: string) => { + if (key.startsWith('ttid-navigation-')) { + return Promise.resolve(ttidNavigation[key.substring(16)]); + } else if (key.startsWith('ttid-')) { + return Promise.resolve(ttid[key.substring(5)]); + } else if (key.startsWith('ttfd-')) { + return Promise.resolve(ttfd[key.substring(5)]); + } + return Promise.resolve(undefined); + }); +} + +let mockedProps: RNSentryOnDrawReporterProps[] = []; -export function emitNativeInitialDisplayEvent(frameTimestampMs?: number): void { - mockedOnDrawNextFrame({ nativeEvent: { type: 'initialDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } }); +export function getMockedOnDrawReportedProps(): RNSentryOnDrawReporterProps[] { + return mockedProps; } -export function emitNativeFullDisplayEvent(frameTimestampMs?: number): void { - mockedOnDrawNextFrame({ nativeEvent: { type: 'fullDisplay', newFrameTimestampInSeconds: (frameTimestampMs || Date.now()) / 1_000 } }); +export function clearMockedOnDrawReportedProps(): void { + mockedProps = []; } function RNSentryOnDrawReporterMock(props: RNSentryOnDrawReporterProps): React.ReactElement { - mockedOnDrawNextFrame = props.onDrawNextFrame; + mockedProps.push(props); return ; } diff --git a/packages/core/test/tracing/reactnativenavigation.test.ts b/packages/core/test/tracing/reactnativenavigation.test.ts index 09acbaa25f..ebc264d91f 100644 --- a/packages/core/test/tracing/reactnativenavigation.test.ts +++ b/packages/core/test/tracing/reactnativenavigation.test.ts @@ -32,6 +32,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../../src/js/tracing/semanticAttributes'; +import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; interface MockEventsRegistry extends EventsRegistry { @@ -87,6 +88,7 @@ describe('React Native Navigation Instrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -131,6 +133,7 @@ describe('React Native Navigation Instrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -206,6 +209,7 @@ describe('React Native Navigation Instrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -294,6 +298,7 @@ describe('React Native Navigation Instrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -342,6 +347,7 @@ describe('React Native Navigation Instrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), diff --git a/packages/core/test/tracing/reactnavigation.test.ts b/packages/core/test/tracing/reactnavigation.test.ts index 5c8b805e9d..54a203000f 100644 --- a/packages/core/test/tracing/reactnavigation.test.ts +++ b/packages/core/test/tracing/reactnavigation.test.ts @@ -2,7 +2,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { SentrySpan } from '@sentry/core'; import type { Event, Measurements, StartSpanOptions } from '@sentry/core'; -import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient } from '@sentry/core'; +import { + getActiveSpan, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + spanToJSON, +} from '@sentry/core'; import { nativeFramesIntegration, reactNativeTracingIntegration } from '../../src/js'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION } from '../../src/js/tracing/origin'; @@ -20,8 +27,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '../../src/js/tracing/semanticAttributes'; -import { DEFAULT_NAVIGATION_SPAN_NAME } from '../../src/js/tracing/span'; +import { DEFAULT_NAVIGATION_SPAN_NAME, SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; +import { mockAppRegistryIntegration } from '../mocks/appRegistryIntegrationMock'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { NATIVE } from '../mockWrapper'; import { getDevServer } from './../../src/js/integrations/debugsymbolicatorutils'; @@ -54,6 +62,7 @@ describe('ReactNavigationInstrumentation', () => { let mockNavigation: ReturnType; beforeEach(() => { + jest.clearAllMocks(); RN_GLOBAL_OBJ.__sentry_rn_v5_registered = false; getCurrentScope().clear(); @@ -83,6 +92,7 @@ describe('ReactNavigationInstrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -192,6 +202,7 @@ describe('ReactNavigationInstrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -229,6 +240,7 @@ describe('ReactNavigationInstrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -268,6 +280,7 @@ describe('ReactNavigationInstrumentation', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: 'idleTimeout', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, }), }), @@ -324,6 +337,7 @@ describe('ReactNavigationInstrumentation', () => { expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true); + expect(mockNavigationContainer.addListener).toHaveBeenCalledTimes(2); expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function)); expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function)); }); @@ -335,23 +349,71 @@ describe('ReactNavigationInstrumentation', () => { expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true); + expect(mockNavigationContainer.addListener).toHaveBeenCalledTimes(2); expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function)); expect(mockNavigationContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function)); }); - test('does not register navigation container if there is an existing one', () => { - RN_GLOBAL_OBJ.__sentry_rn_v5_registered = true; - + test('does not register navigation container if the existing one is already registered', () => { const instrumentation = reactNavigationIntegration(); + const mockNavigationContainer = new MockNavigationContainer(); instrumentation.registerNavigationContainer({ current: mockNavigationContainer, }); + // Clear mocks after the first registration + jest.clearAllMocks(); + + instrumentation.registerNavigationContainer({ + current: mockNavigationContainer, + }); + expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true); expect(mockNavigationContainer.addListener).not.toHaveBeenCalled(); - expect(mockNavigationContainer.addListener).not.toHaveBeenCalled(); + }); + + test('does register navigation container if received a new reference', () => { + const instrumentation = reactNavigationIntegration(); + const refHolder: { current: MockNavigationContainer | undefined } = { current: undefined }; + const firstContainer = new MockNavigationContainer(); + const secondContainer = new MockNavigationContainer(); + + refHolder.current = firstContainer; + instrumentation.registerNavigationContainer(refHolder); + + refHolder.current = secondContainer; + instrumentation.registerNavigationContainer(refHolder); + + expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true); + + expect(firstContainer.addListener).toHaveBeenCalledTimes(2); + expect(firstContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function)); + expect(firstContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function)); + + expect(secondContainer.addListener).toHaveBeenCalledTimes(2); + expect(secondContainer.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function)); + expect(secondContainer.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function)); + }); + + test('does not register navigation container if received a new holder with the same reference', () => { + const instrumentation = reactNavigationIntegration(); + const container = new MockNavigationContainer(); + + instrumentation.registerNavigationContainer({ + current: container, + }); + + instrumentation.registerNavigationContainer({ + current: container, + }); + + expect(RN_GLOBAL_OBJ.__sentry_rn_v5_registered).toBe(true); + + expect(container.addListener).toHaveBeenCalledTimes(2); + expect(container.addListener).toHaveBeenNthCalledWith(1, '__unsafe_action__', expect.any(Function)); + expect(container.addListener).toHaveBeenNthCalledWith(2, 'state', expect.any(Function)); }); test('works if routing instrumentation setup is after navigation registration', async () => { @@ -367,6 +429,66 @@ describe('ReactNavigationInstrumentation', () => { expect(mockTransaction['_sampled']).not.toBe(false); }); + + test('after all setup registers for runApplication calls', async () => { + const { mockedGetAppRegistryIntegration, mockedOnRunApplication } = mockAppRegistryIntegration(); + + const instrumentation = reactNavigationIntegration(); + + const mockNavigationContainer = new MockNavigationContainer(); + instrumentation.registerNavigationContainer(mockNavigationContainer); + + instrumentation.afterAllSetup(client); + await jest.runOnlyPendingTimersAsync(); + + expect(getActiveSpan()).toBeUndefined(); + expect(mockedGetAppRegistryIntegration).toHaveBeenCalledOnce(); + expect(mockedOnRunApplication).toHaveBeenCalledOnce(); + + const runApplicationCallback = mockedOnRunApplication.mock.calls[0][0]; + runApplicationCallback(); + + const span = getActiveSpan(); + expect(span).toBeDefined(); + expect(spanToJSON(span)).toEqual( + expect.objectContaining({ + description: DEFAULT_NAVIGATION_SPAN_NAME, + op: 'navigation', + }), + ); + }); + + test('runApplication calls are ignored when the initial state is not handled', async () => { + // This avoid starting a new navigation span when the application run was called before + // before the first navigation container is registered. + + const { mockedGetAppRegistryIntegration, mockedOnRunApplication } = mockAppRegistryIntegration(); + + reactNavigationIntegration().afterAllSetup(client); + await jest.runOnlyPendingTimersAsync(); // Flushes the initial navigation span + + expect(getActiveSpan()).toBeUndefined(); + expect(mockedGetAppRegistryIntegration).toHaveBeenCalledOnce(); + expect(mockedOnRunApplication).toHaveBeenCalledOnce(); + + const runApplicationCallback = mockedOnRunApplication.mock.calls[0][0]; + runApplicationCallback(); + + const span = getActiveSpan(); + expect(span).toBeUndefined(); + }); + + test('handles graceful missing app registry integration', async () => { + // This avoid starting a new navigation span when the application run was called before + // before the first navigation container is registered. + + const { mockedGetAppRegistryIntegration, mockedOnRunApplication } = mockAppRegistryIntegration(); + mockedOnRunApplication.mockReturnValue(undefined); + + reactNavigationIntegration().afterAllSetup(client); + + expect(mockedGetAppRegistryIntegration).toHaveBeenCalledOnce(); + }); }); describe('options', () => { @@ -457,13 +579,170 @@ describe('ReactNavigationInstrumentation', () => { }); }); + [true, false].forEach(useDispatchedActionData => { + describe(`test actions which should not create a navigation span when useDispatchedActionData is ${useDispatchedActionData}`, () => { + beforeEach(async () => { + setupTestClient({ useDispatchedActionData }); + await jest.runOnlyPendingTimers(); // Flushes the initial navigation span + client.event = undefined; + }); + + test(`noop does ${useDispatchedActionData ? 'not' : ''}create a navigation span`, async () => { + mockNavigation.emitWithStateChange({ + data: { + action: { + type: 'UNKNOWN', + }, + noop: true, + stack: undefined, + }, + }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event === undefined).toBe(useDispatchedActionData); + }); + + test.each(['PRELOAD', 'SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'])( + `%s does ${useDispatchedActionData ? 'not' : ''}create a navigation span`, + async actionType => { + mockNavigation.emitWithStateChange({ + data: { + action: { + type: actionType, + }, + noop: false, + stack: undefined, + }, + }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expect(client.event === undefined).toBe(useDispatchedActionData); + }, + ); + }); + }); + + test('noop does not remove the previous navigation span from scope', async () => { + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); // Flushes the initial navigation span + + mockNavigation.emitNavigationWithoutStateChange(); + const activeSpan = getActiveSpan(); + + mockNavigation.emitWithoutStateChange({ + data: { + action: { + type: 'UNKNOWN', + }, + noop: true, + stack: undefined, + }, + }); + + expect(getActiveSpan()).toBe(activeSpan); + }); + + test.each(['PRELOAD', 'SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'])( + '%s does not remove the previous navigation span from scope', + async actionType => { + setupTestClient({ useDispatchedActionData: true }); + await jest.runOnlyPendingTimers(); // Flushes the initial navigation span + + mockNavigation.emitNavigationWithoutStateChange(); + const activeSpan = getActiveSpan(); + + mockNavigation.emitWithoutStateChange({ + data: { + action: { + type: actionType, + }, + noop: false, + stack: undefined, + }, + }); + + expect(getActiveSpan()).toBe(activeSpan); + }, + ); + + describe('setCurrentRoute', () => { + let mockSetCurrentRoute: jest.Mock; + + beforeEach(() => { + mockSetCurrentRoute = jest.fn(); + const rnTracingIntegration = reactNativeTracingIntegration(); + rnTracingIntegration.setCurrentRoute = mockSetCurrentRoute; + + const rNavigation = reactNavigationIntegration({ + routeChangeTimeoutMs: 200, + }); + mockNavigation = createMockNavigationAndAttachTo(rNavigation); + + const options = getDefaultTestClientOptions({ + enableNativeFramesTracking: false, + enableStallTracking: false, + tracesSampleRate: 1.0, + integrations: [rNavigation, rnTracingIntegration], + enableAppStartTracking: false, + }); + + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + jest.runOnlyPendingTimers(); + }); + + test('setCurrentRoute is called with route name after navigation', async () => { + expect(mockSetCurrentRoute).toHaveBeenCalledWith('Initial Screen'); + + mockSetCurrentRoute.mockClear(); + mockNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); + + expect(mockSetCurrentRoute).toHaveBeenCalledWith('New Screen'); + + mockSetCurrentRoute.mockClear(); + mockNavigation.navigateToSecondScreen(); + jest.runOnlyPendingTimers(); + + expect(mockSetCurrentRoute).toHaveBeenCalledWith('Second Screen'); + + mockSetCurrentRoute.mockClear(); + mockNavigation.navigateToInitialScreen(); + jest.runOnlyPendingTimers(); + + expect(mockSetCurrentRoute).toHaveBeenCalledWith('Initial Screen'); + }); + + test('setCurrentRoute is not called when navigation is cancelled', async () => { + mockSetCurrentRoute.mockClear(); + mockNavigation.emitCancelledNavigation(); + jest.runOnlyPendingTimers(); + + expect(mockSetCurrentRoute).not.toHaveBeenCalled(); + }); + + test('setCurrentRoute is not called when navigation finishes', async () => { + mockSetCurrentRoute.mockClear(); + mockNavigation.finishAppStartNavigation(); + jest.runOnlyPendingTimers(); + + expect(mockSetCurrentRoute).not.toHaveBeenCalled(); + }); + }); + function setupTestClient( setupOptions: { beforeSpanStart?: (options: StartSpanOptions) => StartSpanOptions; + useDispatchedActionData?: boolean; } = {}, ) { const rNavigation = reactNavigationIntegration({ routeChangeTimeoutMs: 200, + useDispatchedActionData: setupOptions.useDispatchedActionData, }); mockNavigation = createMockNavigationAndAttachTo(rNavigation); diff --git a/packages/core/test/tracing/reactnavigation.ttid.test.tsx b/packages/core/test/tracing/reactnavigation.ttid.test.tsx index a0245cff12..68d0f3bb91 100644 --- a/packages/core/test/tracing/reactnavigation.ttid.test.tsx +++ b/packages/core/test/tracing/reactnavigation.ttid.test.tsx @@ -1,5 +1,5 @@ import type { Scope, Span, SpanJSON, TransactionEvent, Transport } from '@sentry/core'; -import { timestampInSeconds } from '@sentry/core'; +import { getActiveSpan, spanToJSON, timestampInSeconds } from '@sentry/core'; import * as TestRenderer from '@testing-library/react-native' import * as React from "react"; @@ -16,13 +16,12 @@ import { startSpanManual } from '../../src/js'; import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing'; import { _setAppStartEndTimestampMs } from '../../src/js/tracing/integrations/appStart'; import { SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION, SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; +import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { isHermesEnabled, notWeb } from '../../src/js/utils/environment'; -import { createSentryFallbackEventEmitter } from '../../src/js/utils/sentryeventemitterfallback'; import { RN_GLOBAL_OBJ } from '../../src/js/utils/worldwide'; import { MOCK_DSN } from '../mockDsn'; -import { secondInFutureTimestampMs } from '../testutils'; -import type { MockedSentryEventEmitterFallback } from '../utils/mockedSentryeventemitterfallback'; -import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; +import { nowInSeconds, secondInFutureTimestampMs } from '../testutils'; +import { mockRecordedTimeToDisplay } from './mockedtimetodisplaynative'; import { createMockNavigationAndAttachTo } from './reactnavigationutils'; const SCOPE_SPAN_FIELD = '_sentrySpan'; @@ -32,7 +31,6 @@ type ScopeWithMaybeSpan = Scope & { }; describe('React Navigation - TTID', () => { - let mockedEventEmitter: MockedSentryEventEmitterFallback; let transportSendMock: jest.Mock, Parameters>; let mockedNavigation: ReturnType; const mockedAppStartTimeSeconds: number = timestampInSeconds(); @@ -51,9 +49,6 @@ describe('React Navigation - TTID', () => { }); _setAppStartEndTimestampMs(mockedAppStartTimeSeconds * 1000); - mockedEventEmitter = mockedSentryEventEmitter.createMockedSentryFallbackEventEmitter(); - (createSentryFallbackEventEmitter as jest.Mock).mockReturnValue(mockedEventEmitter); - const sut = createTestedInstrumentation({ enableTimeToInitialDisplay: true }); transportSendMock = initSentry(sut).transportSendMock; @@ -68,7 +63,7 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -80,6 +75,7 @@ describe('React Navigation - TTID', () => { data: { 'sentry.op': 'ui.load.initial_display', 'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'New Screen initial display', op: 'ui.load.initial_display', @@ -97,8 +93,8 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); + mockAutomaticTimeToDisplay(); (Sentry.getCurrentScope() as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD] = undefined; - mockedEventEmitter.emitNewFrameEvent(); jest.runOnlyPendingTimers(); // Flush transaction const transaction = getLastTransaction(transportSendMock); @@ -110,6 +106,7 @@ describe('React Navigation - TTID', () => { data: { 'sentry.op': 'ui.load.initial_display', 'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'New Screen initial display', op: 'ui.load.initial_display', @@ -133,8 +130,8 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); + mockAutomaticTimeToDisplay(); (Sentry.getCurrentScope() as ScopeWithMaybeSpan)[SCOPE_SPAN_FIELD] = startSpanManual({ name: 'test' }, s => s); - mockedEventEmitter.emitNewFrameEvent(); jest.runOnlyPendingTimers(); // Flush transaction const transaction = getLastTransaction(transportSendMock); @@ -146,6 +143,7 @@ describe('React Navigation - TTID', () => { data: { 'sentry.op': 'ui.load.initial_display', 'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'New Screen initial display', op: 'ui.load.initial_display', @@ -169,7 +167,7 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -190,7 +188,7 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -203,6 +201,7 @@ describe('React Navigation - TTID', () => { 'sentry.op': 'navigation.processing', 'sentry.origin': SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION, 'sentry.source': 'custom', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Navigation dispatch to screen New Screen mounted', op: 'navigation.processing', @@ -218,7 +217,7 @@ describe('React Navigation - TTID', () => { test('should add processing navigation span for application start up', () => { mockedNavigation.finishAppStartNavigation(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -231,6 +230,7 @@ describe('React Navigation - TTID', () => { 'sentry.op': 'navigation.processing', 'sentry.origin': SPAN_ORIGIN_AUTO_NAVIGATION_REACT_NAVIGATION, 'sentry.source': 'custom', + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Navigation dispatch to screen Initial Screen mounted', op: 'navigation.processing', @@ -246,7 +246,7 @@ describe('React Navigation - TTID', () => { test('should add ttid span for application start up', () => { mockedNavigation.finishAppStartNavigation(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -261,6 +261,7 @@ describe('React Navigation - TTID', () => { data: { 'sentry.op': 'ui.load.initial_display', 'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Initial Screen initial display', op: 'ui.load.initial_display', @@ -276,10 +277,16 @@ describe('React Navigation - TTID', () => { test('should add ttfd span for application start up', () => { mockedNavigation.finishAppStartNavigation(); - mockedEventEmitter.emitNewFrameEvent(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttidNavigation: { + [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(), + }, + }); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -295,6 +302,7 @@ describe('React Navigation - TTID', () => { data: { 'sentry.op': 'ui.load.full_display', 'sentry.origin': SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', @@ -310,7 +318,7 @@ describe('React Navigation - TTID', () => { test('should add ttid measurement for application start up', () => { mockedNavigation.finishAppStartNavigation(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -334,7 +342,7 @@ describe('React Navigation - TTID', () => { test('ttid span duration and measurement should equal for application start up', () => { mockedNavigation.finishAppStartNavigation(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush ttid transaction const transaction = getLastTransaction(transportSendMock); @@ -343,12 +351,46 @@ describe('React Navigation - TTID', () => { expect(getSpanDurationMs(transaction, 'ui.load.initial_display')).toEqual(transaction.measurements?.time_to_initial_display?.value); }); + test('ttfd span duration and measurement should equal ttid from ttfd is called earlier than ttid', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction + + mockedNavigation.navigateToNewScreen(); + TestRenderer.render(); + mockRecordedTimeToDisplay({ + ttidNavigation: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(), + }, + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds() - 1, + }, + }); + + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + const ttfdSpanDuration = getSpanDurationMs(transaction, 'ui.load.full_display'); + const ttidSpanDuration = getSpanDurationMs(transaction, 'ui.load.initial_display'); + expect(ttfdSpanDuration).toBeDefined(); + expect(ttidSpanDuration).toBeDefined(); + expect(ttfdSpanDuration).toEqual(ttidSpanDuration); + + expect(transaction.measurements?.time_to_full_display?.value).toBeDefined(); + expect(transaction.measurements?.time_to_initial_display?.value).toBeDefined(); + expect(transaction.measurements?.time_to_full_display?.value).toEqual(transaction.measurements?.time_to_initial_display?.value); + }); + test('ttfd span duration and measurement should equal for application start up', () => { mockedNavigation.finishAppStartNavigation(); - mockedEventEmitter.emitNewFrameEvent(); TestRenderer.render(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttidNavigation: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(), + }, + ttfd: { + [spanToJSON(getActiveSpan()!).span_id!]: timestampInSeconds(), + }, + }); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -359,6 +401,7 @@ describe('React Navigation - TTID', () => { }); test('idle transaction should cancel the ttid span if new frame not received', () => { + jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); jest.runOnlyPendingTimers(); // Flush ttid transaction @@ -366,18 +409,9 @@ describe('React Navigation - TTID', () => { expect(transaction).toEqual( expect.objectContaining({ type: 'transaction', - spans: expect.arrayContaining([ + spans: expect.not.arrayContaining([ expect.objectContaining>({ - data: { - 'sentry.op': 'ui.load.initial_display', - 'sentry.origin': SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, - }, - description: 'New Screen initial display', op: 'ui.load.initial_display', - origin: SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, - status: 'cancelled', - start_timestamp: transaction.start_timestamp, - timestamp: expect.any(Number), }), ]), }), @@ -388,11 +422,11 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush transaction mockedNavigation.navigateToInitialScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush transaction const transaction = getLastTransaction(transportSendMock); @@ -408,11 +442,11 @@ describe('React Navigation - TTID', () => { jest.runOnlyPendingTimers(); // Flush app start transaction mockedNavigation.navigateToNewScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); jest.runOnlyPendingTimers(); // Flush transaction mockedNavigation.navigateToInitialScreen(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); const artificialSpan = Sentry.startInactiveSpan({ name: 'Artificial span to ensure back navigation transaction is not empty', }); @@ -447,9 +481,13 @@ describe('React Navigation - TTID', () => { mockedNavigation.navigateToNewScreen(); const timeToDisplayComponent = TestRenderer.render(); - mockedEventEmitter.emitNewFrameEvent(); + mockAutomaticTimeToDisplay(); timeToDisplayComponent.update(); - emitNativeInitialDisplayEvent(manualInitialDisplayEndTimestampMs); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(getActiveSpan()!).span_id!]: manualInitialDisplayEndTimestampMs / 1_000, + }, + }); jest.runOnlyPendingTimers(); // Flush transaction @@ -473,40 +511,6 @@ describe('React Navigation - TTID', () => { }), ); }); - - test('auto initial display api overwrites manual api if manual not initialized on time', () => { - const autoInitialDisplayEndTimestampMs = timestampInSeconds(); - - jest.runOnlyPendingTimers(); // Flush app start transaction - mockedNavigation.navigateToNewScreen(); - mockedEventEmitter.emitNewFrameEvent(autoInitialDisplayEndTimestampMs); - - // Initialized too late auto instrumentation finished before manual - TestRenderer.render(); - emitNativeInitialDisplayEvent(secondInFutureTimestampMs()); - - jest.runOnlyPendingTimers(); // Flush transaction - - const transaction = getLastTransaction(transportSendMock); - expect(transaction).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'New Screen', - spans: expect.arrayContaining([ - expect.objectContaining>({ - op: 'ui.load.initial_display', - timestamp: autoInitialDisplayEndTimestampMs, - }), - ]), - measurements: expect.objectContaining['measurements']>({ - time_to_initial_display: { - value: expect.any(Number), - unit: 'millisecond', - }, - }), - }), - ); - }); }); describe('ttid disabled', () => { @@ -579,6 +583,55 @@ describe('React Navigation - TTID', () => { }); }); + describe('ttid for preloaded/seen routes', () => { + beforeEach(() => { + jest.useFakeTimers(); + (notWeb as jest.Mock).mockReturnValue(true); + (isHermesEnabled as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should add ttid span and measurement for already seen route', () => { + const sut = createTestedInstrumentation({ + enableTimeToInitialDisplay: true, + ignoreEmptyBackNavigationTransactions: false, + enableTimeToInitialDisplayForPreloadedRoutes: true, + }); + transportSendMock = initSentry(sut).transportSendMock; + + mockedNavigation = createMockNavigationAndAttachTo(sut); + + jest.runOnlyPendingTimers(); // Flush app start transaction + mockedNavigation.navigateToNewScreen(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + mockedNavigation.navigateToInitialScreen(); + mockAutomaticTimeToDisplay(); + jest.runOnlyPendingTimers(); // Flush navigation transaction + + const transaction = getLastTransaction(transportSendMock); + expect(transaction).toEqual( + expect.objectContaining({ + type: 'transaction', + spans: expect.arrayContaining([ + expect.objectContaining>({ + op: 'ui.load.initial_display', + description: 'Initial Screen initial display', + }), + ]), + measurements: expect.objectContaining['measurements']>({ + time_to_initial_display: { + value: expect.any(Number), + unit: 'millisecond', + }, + }), + }), + ); + }); + }); + function getSpanDurationMs(transaction: TransactionEvent, op: string): number | undefined { const ttidSpan = transaction.spans?.find(span => span.op === op); if (!ttidSpan) { @@ -593,10 +646,13 @@ describe('React Navigation - TTID', () => { return (spanJSON.timestamp - spanJSON.start_timestamp) * 1000; } - function createTestedInstrumentation(options?: { enableTimeToInitialDisplay?: boolean }) { + function createTestedInstrumentation(options?: { + enableTimeToInitialDisplay?: boolean + enableTimeToInitialDisplayForPreloadedRoutes?: boolean + ignoreEmptyBackNavigationTransactions?: boolean + }) { const sut = Sentry.reactNavigationIntegration({ ...options, - ignoreEmptyBackNavigationTransactions: true, // default true }); return sut; } @@ -607,6 +663,14 @@ describe('React Navigation - TTID', () => { } }); +function mockAutomaticTimeToDisplay(): void { + mockRecordedTimeToDisplay({ + ttidNavigation: { + [spanToJSON(getActiveSpan()!).span_id!]: nowInSeconds(), + }, + }); +} + function initSentry(sut: ReturnType): { transportSendMock: jest.Mock, Parameters>; } { @@ -619,6 +683,7 @@ function initSentry(sut: ReturnType): integrations: [ sut, Sentry.reactNativeTracingIntegration(), + Sentry.timeToDisplayIntegration(), ], transport: () => ({ send: transportSendMock.mockResolvedValue({}), diff --git a/packages/core/test/tracing/reactnavigationutils.ts b/packages/core/test/tracing/reactnavigationutils.ts index 3aba609d13..bbc4576859 100644 --- a/packages/core/test/tracing/reactnavigationutils.ts +++ b/packages/core/test/tracing/reactnavigationutils.ts @@ -1,17 +1,24 @@ import type { NavigationRoute, reactNavigationIntegration } from '../../src/js/tracing/reactnavigation'; +import type { UnsafeAction } from '../../src/js/vendor/react-navigation/types'; + +const navigationAction: UnsafeAction = { + data: { + action: { + type: 'NAVIGATE', + }, + noop: false, + stack: undefined, + }, +}; export function createMockNavigationAndAttachTo(sut: ReturnType) { const mockedNavigationContained = mockNavigationContainer(); const mockedNavigation = { emitCancelledNavigation: () => { - mockedNavigationContained.listeners['__unsafe_action__']({ - // this object is not used by the instrumentation - }); + mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); }, navigateToNewScreen: () => { - mockedNavigationContained.listeners['__unsafe_action__']({ - // this object is not used by the instrumentation - }); + mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); mockedNavigationContained.currentRoute = { key: 'new_screen', name: 'New Screen', @@ -21,9 +28,7 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { - mockedNavigationContained.listeners['__unsafe_action__']({ - // this object is not used by the instrumentation - }); + mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); mockedNavigationContained.currentRoute = { key: 'second_screen', name: 'Second Screen', @@ -33,9 +38,7 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { - mockedNavigationContained.listeners['__unsafe_action__']({ - // this object is not used by the instrumentation - }); + mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); mockedNavigationContained.currentRoute = { key: 'initial_screen', name: 'Initial Screen', @@ -53,6 +56,18 @@ export function createMockNavigationAndAttachTo(sut: ReturnType { + mockedNavigationContained.listeners['__unsafe_action__'](navigationAction); + }, + emitWithoutStateChange: (action: UnsafeAction) => { + mockedNavigationContained.listeners['__unsafe_action__'](action); + }, + emitWithStateChange: (action: UnsafeAction) => { + mockedNavigationContained.listeners['__unsafe_action__'](action); + mockedNavigationContained.listeners['state']({ + // this object is not used by the instrumentation + }); + }, }; sut.registerNavigationContainer(mockRef(mockedNavigationContained)); diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index bfdbd3baa5..43413abcbc 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -1,11 +1,15 @@ -import { getActiveSpan, getCurrentScope, getGlobalScope, getIsolationScope, getSpanDescendants, logger , setCurrentClient, spanToJSON, startSpanManual} from '@sentry/core'; +import { getCurrentScope, getGlobalScope, getIsolationScope, logger , setCurrentClient, spanToJSON, startSpanManual } from '@sentry/core'; jest.spyOn(logger, 'warn'); +import * as mockWrapper from '../mockWrapper'; +jest.mock('../../src/js/wrapper', () => mockWrapper); + import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); import { isTurboModuleEnabled } from '../../src/js/utils/environment'; jest.mock('../../src/js/utils/environment', () => ({ + isWeb: jest.fn().mockReturnValue(false), isTurboModuleEnabled: jest.fn().mockReturnValue(false), })); @@ -13,12 +17,15 @@ import type { Event, Measurements, Span, SpanJSON} from '@sentry/core'; import * as React from "react"; import * as TestRenderer from 'react-test-renderer'; +import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timeToDisplayIntegration'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../src/js/tracing/semanticAttributes'; +import { SPAN_THREAD_NAME , SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -import { secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; -import { emitNativeFullDisplayEvent, emitNativeInitialDisplayEvent } from './mockedtimetodisplaynative'; +import { nowInSeconds, secondAgoTimestampMs, secondInFutureTimestampMs } from '../testutils'; + +const { mockRecordedTimeToDisplay, getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative; jest.useFakeTimers({advanceTimers: true}); @@ -26,6 +33,7 @@ describe('TimeToDisplay', () => { let client: TestClient; beforeEach(() => { + clearMockedOnDrawReportedProps(); getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -33,7 +41,13 @@ describe('TimeToDisplay', () => { const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0, }); - client = new TestClient(options); + client = new TestClient({ + ...options, + integrations: [ + ...options.integrations, + timeToDisplayIntegration(), + ], + }); setCurrentClient(client); client.init(); }); @@ -43,20 +57,21 @@ describe('TimeToDisplay', () => { }); test('creates manual initial display', async () => { - const [testSpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { - const testSpan = startTimeToInitialDisplaySpan(); + startTimeToInitialDisplaySpan(); TestRenderer.create(); - - emitNativeInitialDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - - return [testSpan, activeSpan]; }, ); @@ -64,83 +79,33 @@ describe('TimeToDisplay', () => { await client.flush(); expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); + expectFinishedInitialDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates manual full display', async () => { - const [testSpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { startTimeToInitialDisplaySpan(); - const testSpan = startTimeToFullDisplaySpan(); + startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - emitNativeFullDisplayEvent(); - - activeSpan?.end(); - return [testSpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); - }); - - test('creates initial display span on first component render', async () => { - const [testSpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const renderer = TestRenderer.create(); - const testSpan = getInitialDisplaySpan(activeSpan); - - renderer.update(); - emitNativeInitialDisplayEvent(); - activeSpan?.end(); - return [testSpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); - }); - - test('creates full display span on first component render', async () => { - const [testSpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - TestRenderer.create(); - emitNativeInitialDisplayEvent(); - - const renderer = TestRenderer.create(); - const testSpan = getFullDisplaySpan(getActiveSpan()); - - renderer.update(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [testSpan, activeSpan]; }, ); @@ -148,12 +113,13 @@ describe('TimeToDisplay', () => { await client.flush(); expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(testSpan, activeSpan); - expect(spanToJSON(testSpan!).start_timestamp).toEqual(spanToJSON(activeSpan!).start_timestamp); + expectFinishedFullDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); + expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('does not create full display when initial display is missing', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -162,10 +128,13 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeFullDisplayEvent(); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -175,11 +144,13 @@ describe('TimeToDisplay', () => { expectNoInitialDisplayMeasurementOnSpan(client.event!); expectNoFullDisplayMeasurementOnSpan(client.event!); - expectNoTimeToDisplaySpans(activeSpan); + expectNoTimeToDisplaySpans(client.event!); + + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates initial display for active span without initial display span', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -187,10 +158,13 @@ describe('TimeToDisplay', () => { (activeSpan: Span | undefined) => { TestRenderer.create(); - emitNativeInitialDisplayEvent(); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -198,11 +172,12 @@ describe('TimeToDisplay', () => { await client.flush(); expectInitialDisplayMeasurementOnSpan(client.event!); - expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('creates full display for active span without full display span', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -212,13 +187,18 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - emitNativeFullDisplayEvent(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); @@ -226,11 +206,13 @@ describe('TimeToDisplay', () => { await client.flush(); expectFullDisplayMeasurementOnSpan(client.event!); - expectFinishedFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); + expectFinishedFullDisplaySpan(client.event!); + expect(getMockedOnDrawReportedProps()[0]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); + expect(getMockedOnDrawReportedProps()[1]!.parentSpanId).toEqual(client.event!.contexts!.trace!.span_id); }); test('cancels full display spans longer than 30s', async () => { - const [activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), @@ -240,23 +222,26 @@ describe('TimeToDisplay', () => { startTimeToFullDisplaySpan(); TestRenderer.create(); - emitNativeInitialDisplayEvent(); - TestRenderer.create(); - // native event is not emitted - jest.advanceTimersByTime(40_000); + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds(), + }, + ttfd: { + [spanToJSON(activeSpan!).span_id!]: nowInSeconds() + 40, + }, + }); activeSpan?.end(); - return [activeSpan]; }, ); await jest.runOnlyPendingTimersAsync(); await client.flush(); - expectFinishedInitialDisplaySpan(getInitialDisplaySpan(activeSpan), activeSpan); - expectDeadlineExceededFullDisplaySpan(getFullDisplaySpan(activeSpan), activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expectDeadlineExceededFullDisplaySpan(client.event!); expectInitialDisplayMeasurementOnSpan(client.event!); expectFullDisplayMeasurementOnSpan(client.event!); @@ -267,120 +252,46 @@ describe('TimeToDisplay', () => { test('full display which ended before initial display is extended to initial display end', async () => { const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); - - const timeToDisplayComponent = TestRenderer.create(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - - timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 10); - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - - activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFullDisplayMeasurementOnSpan(client.event!); - - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - }); - - test('full display which ended before but processed after initial display is extended to initial display end', async () => { - const fullDisplayEndTimestampMs = secondInFutureTimestampMs(); - const initialDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); + startTimeToInitialDisplaySpan(); + startTimeToFullDisplaySpan(); const timeToDisplayComponent = TestRenderer.create(<>); - timeToDisplayComponent.update(<>); - - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - - activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; - }, - ); - - await jest.runOnlyPendingTimersAsync(); - await client.flush(); - - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); - - expectInitialDisplayMeasurementOnSpan(client.event!); - expectFullDisplayMeasurementOnSpan(client.event!); - - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - }); - - test('consequent renders do not update display end', async () => { - const initialDisplayEndTimestampMs = secondInFutureTimestampMs(); - const fullDisplayEndTimestampMs = secondInFutureTimestampMs() + 500; - const [initialDisplaySpan, fullDisplaySpan, activeSpan] = startSpanManual( - { - name: 'Root Manual Span', - startTime: secondAgoTimestampMs(), - }, - (activeSpan: Span | undefined) => { - const initialDisplaySpan = startTimeToInitialDisplaySpan(); - const fullDisplaySpan = startTimeToFullDisplaySpan(); - - const timeToDisplayComponent = TestRenderer.create(<>); - emitNativeInitialDisplayEvent(initialDisplayEndTimestampMs); - - timeToDisplayComponent.update(<>); - emitNativeInitialDisplayEvent(fullDisplayEndTimestampMs + 10); timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs); - timeToDisplayComponent.update(<>); - emitNativeFullDisplayEvent(fullDisplayEndTimestampMs + 20); + mockRecordedTimeToDisplay({ + ttfd: { + [spanToJSON(activeSpan!).span_id!]: fullDisplayEndTimestampMs / 1_000, + }, + ttid: { + [spanToJSON(activeSpan!).span_id!]: initialDisplayEndTimestampMs / 1_000, + }, + }); activeSpan?.end(); - return [initialDisplaySpan, fullDisplaySpan, activeSpan]; }, ); await jest.runOnlyPendingTimersAsync(); await client.flush(); - expectFinishedInitialDisplaySpan(initialDisplaySpan, activeSpan); - expectFinishedFullDisplaySpan(fullDisplaySpan, activeSpan); + expectFinishedInitialDisplaySpan(client.event!); + expectFinishedFullDisplaySpan(client.event!); expectInitialDisplayMeasurementOnSpan(client.event!); expectFullDisplayMeasurementOnSpan(client.event!); - expect(spanToJSON(initialDisplaySpan!).timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); - expect(spanToJSON(fullDisplaySpan!).timestamp).toEqual(fullDisplayEndTimestampMs / 1_000); + expect(getInitialDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); + expect(getFullDisplaySpanJSON(client.event!.spans!)!.timestamp).toEqual(initialDisplayEndTimestampMs / 1_000); }); test('should not log a warning if native component exists and not in new architecture', async () => { - (isTurboModuleEnabled as jest.Mock).mockReturnValue(false); TestRenderer.create(); @@ -390,7 +301,6 @@ describe('TimeToDisplay', () => { }); test('should log a warning if in new architecture', async () => { - (isTurboModuleEnabled as jest.Mock).mockReturnValue(true); TestRenderer.create(); await jest.runOnlyPendingTimersAsync(); // Flush setTimeout. @@ -400,62 +310,65 @@ describe('TimeToDisplay', () => { }); }); -function getInitialDisplaySpan(span?: Span) { - return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.initial_display'); +function getInitialDisplaySpanJSON(spans: SpanJSON[]) { + return spans.find(s => s.op === 'ui.load.initial_display'); } -function getFullDisplaySpan(span?: Span) { - return getSpanDescendants(span!)?.find(s => spanToJSON(s).op === 'ui.load.full_display'); +function getFullDisplaySpanJSON(spans: SpanJSON[]) { + return spans.find(s => s.op === 'ui.load.full_display'); } -function expectFinishedInitialDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectFinishedInitialDisplaySpan(event: Event) { + expect(getInitialDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.initial_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Initial Display', op: 'ui.load.initial_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'ok', timestamp: expect.any(Number), })); } -function expectFinishedFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectFinishedFullDisplaySpan(event: Event) { + expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'ok', timestamp: expect.any(Number), })); } -function expectDeadlineExceededFullDisplaySpan(actualSpan?: Span, expectedParentSpan?: Span) { - expect(spanToJSON(actualSpan!)).toEqual(expect.objectContaining>({ +function expectDeadlineExceededFullDisplaySpan(event: Event) { + expect(getFullDisplaySpanJSON(event.spans!)).toEqual(expect.objectContaining>({ data: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: "ui.load.full_display", [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY, + [SPAN_THREAD_NAME]: SPAN_THREAD_NAME_JAVASCRIPT, }, description: 'Time To Full Display', op: 'ui.load.full_display', - parent_span_id: expectedParentSpan ? spanToJSON(expectedParentSpan).span_id : undefined, - start_timestamp: expect.any(Number), + parent_span_id: event.contexts.trace.span_id, + start_timestamp: event.start_timestamp, status: 'deadline_exceeded', timestamp: expect.any(Number), })); } -function expectNoTimeToDisplaySpans(span?: Span) { - expect(getSpanDescendants(span!).map(spanToJSON)).toEqual(expect.not.arrayContaining([ +function expectNoTimeToDisplaySpans(event: Event) { + expect(event.spans).toEqual(expect.not.arrayContaining([ expect.objectContaining>({ op: 'ui.load.initial_display' }), expect.objectContaining>({ op: 'ui.load.full_display' }), ])); diff --git a/packages/core/test/tracing/timetodisplaynative.test.tsx b/packages/core/test/tracing/timetodisplaynative.test.tsx new file mode 100644 index 0000000000..ec4dd5d75d --- /dev/null +++ b/packages/core/test/tracing/timetodisplaynative.test.tsx @@ -0,0 +1,15 @@ +import { getRNSentryOnDrawReporter } from '../../src/js/tracing/timetodisplaynative'; +import * as Environment from '../../src/js/utils/environment'; + +describe('timetodisplaynative', () => { + beforeEach(() => { + jest.spyOn(Environment, 'isExpoGo').mockReturnValue(false); + }); + + test('getRNSentryOnDrawReporter returns Noop in Expo Go', () => { + jest.spyOn(Environment, 'isExpoGo').mockReturnValue(true); + const drawReported = getRNSentryOnDrawReporter(); + + expect(drawReported.name).toBe('RNSentryOnDrawReporterNoop'); + }); +}); diff --git a/packages/core/test/wrap.test.tsx b/packages/core/test/wrap.test.tsx index 6272de9cf3..f949b36d38 100644 --- a/packages/core/test/wrap.test.tsx +++ b/packages/core/test/wrap.test.tsx @@ -1,6 +1,11 @@ +import { logger, setCurrentClient } from '@sentry/core'; +import { render } from '@testing-library/react-native'; import * as React from 'react'; +import { Text } from 'react-native'; +import * as AppRegistry from '../src/js/integrations/appRegistry'; import { wrap } from '../src/js/sdk'; +import { getDefaultTestClientOptions, TestClient } from './mocks/client'; describe('Sentry.wrap', () => { it('should not enforce any keys on the wrapped component', () => { @@ -9,4 +14,23 @@ describe('Sentry.wrap', () => { expect(typeof ActualWrapped.defaultProps).toBe(typeof Mock.defaultProps); }); + + it('should wrap the component and init with a warning when getAppRegistryIntegration returns undefined', () => { + logger.warn = jest.fn(); + const getAppRegistryIntegration = jest.spyOn(AppRegistry, 'getAppRegistryIntegration').mockReturnValueOnce(undefined); + const Mock: React.FC = () => Test; + const client = new TestClient( + getDefaultTestClientOptions(), + ); + setCurrentClient(client); + + client.init(); + const ActualWrapped = wrap(Mock); + + const { getByText } = render(); + + expect(getAppRegistryIntegration).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith('AppRegistryIntegration.onRunApplication not found or invalid.'); + expect(getByText('Test')).toBeTruthy(); + }); }); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index 9a260f1a18..f0cc9be167 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -14,6 +14,7 @@ jest.mock('react-native', () => { addBreadcrumb: jest.fn(), captureEnvelope: jest.fn(), clearBreadcrumbs: jest.fn(), + crashedLastRun: jest.fn(), crash: jest.fn(), fetchNativeDeviceContexts: jest.fn(() => Promise.resolve({ @@ -784,4 +785,27 @@ describe('Tests Native Wrapper', () => { expect(RNSentry.setContext).toHaveBeenCalledOnce(); }); }); + + describe('crashedLastRun', () => { + test('return true when promise resolves true ', async () => { + (RNSentry.crashedLastRun as jest.Mock).mockResolvedValue(true); + + const result = await NATIVE.crashedLastRun(); + expect(result).toBeTrue(); + }); + + test('return true when promise resolves false ', async () => { + (RNSentry.crashedLastRun as jest.Mock).mockResolvedValue(false); + + const result = await NATIVE.crashedLastRun(); + expect(result).toBeFalse(); + }); + + test('return null when promise does not resolve ', async () => { + (RNSentry.crashedLastRun as jest.Mock).mockResolvedValue(undefined); + + const result = await NATIVE.crashedLastRun(); + expect(result).toBeNull(); + }); + }); }); diff --git a/performance-tests/TestAppPlain/package.json b/performance-tests/TestAppPlain/package.json index ca67f73bec..219608b595 100644 --- a/performance-tests/TestAppPlain/package.json +++ b/performance-tests/TestAppPlain/package.json @@ -1,6 +1,6 @@ { "name": "TestAppPlain", - "version": "6.8.0", + "version": "6.15.1", "private": true, "scripts": { "android": "react-native run-android", diff --git a/performance-tests/TestAppSentry/package.json b/performance-tests/TestAppSentry/package.json index c4a798aa5a..804b758063 100644 --- a/performance-tests/TestAppSentry/package.json +++ b/performance-tests/TestAppSentry/package.json @@ -1,6 +1,6 @@ { "name": "TestAppSentry", - "version": "6.8.0", + "version": "6.15.1", "private": true, "scripts": { "android": "react-native run-android", @@ -8,7 +8,7 @@ "start": "react-native start" }, "dependencies": { - "@sentry/react-native": "6.8.0", + "@sentry/react-native": "6.15.1", "react": "18.1.0", "react-native": "0.70.15" }, diff --git a/performance-tests/metrics-ios.yml b/performance-tests/metrics-ios.yml index d0235886d7..6210780f8f 100644 --- a/performance-tests/metrics-ios.yml +++ b/performance-tests/metrics-ios.yml @@ -11,4 +11,4 @@ startupTimeTest: binarySizeTest: diffMin: 600 KiB - diffMax: 1200 KiB + diffMax: 1300 KiB diff --git a/samples/expo/app.json b/samples/expo/app.json index 1f1c89980d..5226bd9df5 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -3,8 +3,9 @@ "name": "sentry-react-native-expo-sample", "slug": "sentry-react-native-expo-sample", "jsEngine": "hermes", + "newArchEnabled": true, "scheme": "sentry-expo-sample", - "version": "6.8.0", + "version": "6.15.1", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -19,7 +20,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "io.sentry.expo.sample", - "buildNumber": "38" + "buildNumber": "49" }, "android": { "adaptiveIcon": { @@ -27,7 +28,7 @@ "backgroundColor": "#ffffff" }, "package": "io.sentry.expo.sample", - "versionCode": 38 + "versionCode": 49 }, "web": { "bundler": "metro", @@ -65,10 +66,28 @@ { "asyncRoutes": { "web": true, + "ios": false, "default": "development" } } - ] - ] + ], + "expo-web-browser" + ], + "extra": { + "router": { + "origin": false, + "asyncRoutes": { + "web": true, + "default": "development" + } + }, + "eas": {} + }, + "runtimeVersion": { + "policy": "appVersion" + }, + "updates": { + "url": "https://u.expo.dev/00000000-0000-0000-0000-000000000000" + } } } \ No newline at end of file diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index 947b22e471..fe6a630adb 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -2,19 +2,33 @@ import { Button, StyleSheet } from 'react-native'; import Constants from 'expo-constants'; import * as Sentry from '@sentry/react-native'; import { reloadAppAsync } from 'expo'; +import * as DevClient from 'expo-dev-client'; import { Text, View } from '@/components/Themed'; import { setScopeProperties } from '@/utils/setScopeProperties'; import React from 'react'; import * as WebBrowser from 'expo-web-browser'; +import { useUpdates } from 'expo-updates'; +import { isWeb } from '../../utils/isWeb'; -const isRunningInExpoGo = Constants.appOwnership === 'expo' +const isRunningInExpoGo = Constants.appOwnership === 'expo'; export default function TabOneScreen() { + const { currentlyRunning } = useUpdates(); return ( Welcome to Sentry Expo Sample App! + Update ID: {currentlyRunning.updateId} + Channel: {currentlyRunning.channel} + Runtime Version: {currentlyRunning.runtimeVersion} +