diff --git a/CHANGELOG.md b/CHANGELOG.md index ef04ec37c7..c4a0dfb748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- Add RNSentrySDK APIs support to @sentry/react-native/expo plugin ([#4633](https://github.com/getsentry/sentry-react-native/pull/4633)) - User Feedback Widget Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435)) To collect user feedback from inside your application call `Sentry.showFeedbackWidget()`. diff --git a/packages/core/plugin/src/logger.ts b/packages/core/plugin/src/logger.ts new file mode 100644 index 0000000000..c72df9eaed --- /dev/null +++ b/packages/core/plugin/src/logger.ts @@ -0,0 +1,41 @@ +const warningMap = new Map(); + +/** + * Log a warning message only once per run. + * This is used to avoid spamming the console with the same message. + */ +export function warnOnce(message: string): void { + if (!warningMap.has(message)) { + warningMap.set(message, true); + // eslint-disable-next-line no-console + console.warn(yellow(prefix(message))); + } +} + +/** + * Prefix message with `› [value]`. + * + * Example: + * ``` + * › [@sentry/react-native/expo] This is a warning message + * ``` + */ +export function prefix(value: string): string { + return `› ${bold('[@sentry/react-native/expo]')} ${value}`; +} + +/** + * The same as `chalk.yellow` + * This code is part of the SDK, we don't want to introduce a dependency on `chalk` just for this. + */ +export function yellow(message: string): string { + return `\x1b[33m${message}\x1b[0m`; +} + +/** + * The same as `chalk.bold` + * This code is part of the SDK, we don't want to introduce a dependency on `chalk` just for this. + */ +export function bold(message: string): string { + return `\x1b[1m${message}\x1b[22m`; +} diff --git a/packages/core/plugin/src/utils.ts b/packages/core/plugin/src/utils.ts index c587426b4f..9f4d154e12 100644 --- a/packages/core/plugin/src/utils.ts +++ b/packages/core/plugin/src/utils.ts @@ -8,42 +8,3 @@ export function writeSentryPropertiesTo(filepath: string, sentryProperties: stri fs.writeFileSync(path.resolve(filepath, 'sentry.properties'), sentryProperties); } - -const sdkPackage: { - name: string; - version: string; - // eslint-disable-next-line @typescript-eslint/no-var-requires -} = require('../../package.json'); - -const SDK_PACKAGE_NAME = `${sdkPackage.name}/expo`; - -const warningMap = new Map(); -export function warnOnce(message: string): void { - if (!warningMap.has(message)) { - warningMap.set(message, true); - // eslint-disable-next-line no-console - console.warn(yellow(`${logPrefix()} ${message}`)); - } -} - -export function logPrefix(): string { - return `› ${bold('[@sentry/react-native/expo]')}`; -} - -/** - * The same as `chalk.yellow` - * This code is part of the SDK, we don't want to introduce a dependency on `chalk` just for this. - */ -export function yellow(message: string): string { - return `\x1b[33m${message}\x1b[0m`; -} - -/** - * The same as `chalk.bold` - * This code is part of the SDK, we don't want to introduce a dependency on `chalk` just for this. - */ -export function bold(message: string): string { - return `\x1b[1m${message}\x1b[22m`; -} - -export { sdkPackage, SDK_PACKAGE_NAME }; diff --git a/packages/core/plugin/src/version.ts b/packages/core/plugin/src/version.ts new file mode 100644 index 0000000000..92d091ff71 --- /dev/null +++ b/packages/core/plugin/src/version.ts @@ -0,0 +1,8 @@ +const packageJson: { + name: string; + version: string; + // eslint-disable-next-line @typescript-eslint/no-var-requires +} = require('../../package.json'); + +export const PLUGIN_NAME = `${packageJson.name}/expo`; +export const PLUGIN_VERSION = packageJson.version; diff --git a/packages/core/plugin/src/withSentry.ts b/packages/core/plugin/src/withSentry.ts index 70d4c8932b..68068c9b23 100644 --- a/packages/core/plugin/src/withSentry.ts +++ b/packages/core/plugin/src/withSentry.ts @@ -1,7 +1,8 @@ import type { ConfigPlugin } from 'expo/config-plugins'; import { createRunOncePlugin } from 'expo/config-plugins'; -import { bold, sdkPackage, warnOnce } from './utils'; +import { bold, warnOnce } from './logger'; +import { PLUGIN_NAME, PLUGIN_VERSION } from './version'; import { withSentryAndroid } from './withSentryAndroid'; import type { SentryAndroidGradlePluginOptions } from './withSentryAndroidGradlePlugin'; import { withSentryAndroidGradlePlugin } from './withSentryAndroidGradlePlugin'; @@ -12,6 +13,7 @@ interface PluginProps { project?: string; authToken?: string; url?: string; + useNativeInit?: boolean; experimental_android?: SentryAndroidGradlePluginOptions; } @@ -26,7 +28,7 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { let cfg = config; if (sentryProperties !== null) { try { - cfg = withSentryAndroid(cfg, sentryProperties); + cfg = withSentryAndroid(cfg, { sentryProperties, useNativeInit: props?.useNativeInit }); } catch (e) { warnOnce(`There was a problem with configuring your native Android project: ${e}`); } @@ -39,7 +41,7 @@ const withSentryPlugin: ConfigPlugin = (config, props) => { } } try { - cfg = withSentryIOS(cfg, sentryProperties); + cfg = withSentryIOS(cfg, { sentryProperties, useNativeInit: props?.useNativeInit }); } catch (e) { warnOnce(`There was a problem with configuring your native iOS project: ${e}`); } @@ -79,6 +81,6 @@ ${authToken ? `${existingAuthTokenMessage}\nauth.token=${authToken}` : missingAu } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -const withSentry = createRunOncePlugin(withSentryPlugin, sdkPackage.name, sdkPackage.version); +const withSentry = createRunOncePlugin(withSentryPlugin, PLUGIN_NAME, PLUGIN_VERSION); export { withSentry }; diff --git a/packages/core/plugin/src/withSentryAndroid.ts b/packages/core/plugin/src/withSentryAndroid.ts index 9beaa23883..629986eefd 100644 --- a/packages/core/plugin/src/withSentryAndroid.ts +++ b/packages/core/plugin/src/withSentryAndroid.ts @@ -1,11 +1,16 @@ +import type { ExpoConfig } from '@expo/config-types'; import type { ConfigPlugin } from 'expo/config-plugins'; -import { withAppBuildGradle, withDangerousMod } from 'expo/config-plugins'; +import { withAppBuildGradle, withDangerousMod, withMainApplication } from 'expo/config-plugins'; import * as path from 'path'; -import { warnOnce, writeSentryPropertiesTo } from './utils'; +import { warnOnce } from './logger'; +import { writeSentryPropertiesTo } from './utils'; -export const withSentryAndroid: ConfigPlugin = (config, sentryProperties: string) => { - const cfg = withAppBuildGradle(config, config => { +export const withSentryAndroid: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = ( + config, + { sentryProperties, useNativeInit = false }, +) => { + const appBuildGradleCfg = withAppBuildGradle(config, config => { if (config.modResults.language === 'groovy') { config.modResults.contents = modifyAppBuildGradle(config.modResults.contents); } else { @@ -13,7 +18,10 @@ export const withSentryAndroid: ConfigPlugin = (config, sentryProperties } return config; }); - return withDangerousMod(cfg, [ + + const mainApplicationCfg = useNativeInit ? modifyMainApplication(appBuildGradleCfg) : appBuildGradleCfg; + + return withDangerousMod(mainApplicationCfg, [ 'android', config => { writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'android'), sentryProperties); @@ -49,3 +57,59 @@ export function modifyAppBuildGradle(buildGradle: string): string { return buildGradle.replace(pattern, match => `${applyFrom}\n\n${match}`); } + +export function modifyMainApplication(config: ExpoConfig): ExpoConfig { + return withMainApplication(config, config => { + if (!config.modResults || !config.modResults.path) { + warnOnce("Can't add 'RNSentrySDK.init' to Android MainApplication, because the file was not found."); + return config; + } + + const fileName = path.basename(config.modResults.path); + + if (config.modResults.contents.includes('RNSentrySDK.init')) { + warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.init', the native code won't be updated.`); + return config; + } + + if (config.modResults.language === 'java') { + // Add RNSentrySDK.init + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(super\.onCreate\(\)[;\n]*)([ \t]*)/, + `$1\n$2RNSentrySDK.init(this);\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert 'RNSentrySDK.init' in '${fileName}'.`); + } else if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK;')) { + // Insert import statement after package declaration + config.modResults.contents = config.modResults.contents.replace( + /(package .*;\n\n?)/, + `$1import io.sentry.react.RNSentrySDK;\n`, + ); + } + } else if (config.modResults.language === 'kt') { + // Add RNSentrySDK.init + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(super\.onCreate\(\)[;\n]*)([ \t]*)/, + `$1\n$2RNSentrySDK.init(this)\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert 'RNSentrySDK.init' in '${fileName}'.`); + } else if (!config.modResults.contents.includes('import io.sentry.react.RNSentrySDK')) { + // Insert import statement after package declaration + config.modResults.contents = config.modResults.contents.replace( + /(package .*\n\n?)/, + `$1import io.sentry.react.RNSentrySDK\n`, + ); + } + } else { + warnOnce( + `Unsupported language '${config.modResults.language}' detected in '${fileName}', the native code won't be updated.`, + ); + } + + return config; + }); +} diff --git a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts index b4df682137..2e5880c646 100644 --- a/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts +++ b/packages/core/plugin/src/withSentryAndroidGradlePlugin.ts @@ -1,6 +1,6 @@ import { withAppBuildGradle, withProjectBuildGradle } from '@expo/config-plugins'; -import { warnOnce } from './utils'; +import { warnOnce } from './logger'; export interface SentryAndroidGradlePluginOptions { enableAndroidGradlePlugin?: boolean; diff --git a/packages/core/plugin/src/withSentryIOS.ts b/packages/core/plugin/src/withSentryIOS.ts index db25261839..ba5b9e9d1a 100644 --- a/packages/core/plugin/src/withSentryIOS.ts +++ b/packages/core/plugin/src/withSentryIOS.ts @@ -1,9 +1,11 @@ +import type { ExpoConfig } from '@expo/config-types'; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins'; -import { withDangerousMod, withXcodeProject } from 'expo/config-plugins'; +import { withAppDelegate, withDangerousMod, withXcodeProject } from 'expo/config-plugins'; import * as path from 'path'; -import { warnOnce, writeSentryPropertiesTo } from './utils'; +import { warnOnce } from './logger'; +import { writeSentryPropertiesTo } from './utils'; type BuildPhase = { shellScript: string }; @@ -12,8 +14,11 @@ const SENTRY_REACT_NATIVE_XCODE_PATH = const SENTRY_REACT_NATIVE_XCODE_DEBUG_FILES_PATH = "`${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; -export const withSentryIOS: ConfigPlugin = (config, sentryProperties: string) => { - const cfg = withXcodeProject(config, config => { +export const withSentryIOS: ConfigPlugin<{ sentryProperties: string; useNativeInit: boolean | undefined }> = ( + config, + { sentryProperties, useNativeInit = false }, +) => { + const xcodeProjectCfg = withXcodeProject(config, config => { const xcodeProject: XcodeProject = config.modResults; const sentryBuildPhase = xcodeProject.pbxItemByComment( @@ -36,7 +41,9 @@ export const withSentryIOS: ConfigPlugin = (config, sentryProperties: st return config; }); - return withDangerousMod(cfg, [ + const appDelegateCfc = useNativeInit ? modifyAppDelegate(xcodeProjectCfg) : xcodeProjectCfg; + + return withDangerousMod(appDelegateCfc, [ 'ios', config => { writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties); @@ -79,3 +86,59 @@ export function addSentryWithBundledScriptsToBundleShellScript(script: string): (match: string) => `/bin/sh ${SENTRY_REACT_NATIVE_XCODE_PATH} ${match}`, ); } + +export function modifyAppDelegate(config: ExpoConfig): ExpoConfig { + return withAppDelegate(config, async config => { + if (!config.modResults || !config.modResults.path) { + warnOnce("Can't add 'RNSentrySDK.start()' to the iOS AppDelegate, because the file was not found."); + return config; + } + + const fileName = path.basename(config.modResults.path); + + if (config.modResults.language === 'swift') { + if (config.modResults.contents.includes('RNSentrySDK.start()')) { + warnOnce(`Your '${fileName}' already contains 'RNSentrySDK.start()'.`); + return config; + } + // Add RNSentrySDK.start() at the beginning of application method + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(func application\([^)]*\) -> Bool \{)\s*\n(\s*)/s, + `$1\n$2RNSentrySDK.start()\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert 'RNSentrySDK.start()' in '${fileName}'.`); + } else if (!config.modResults.contents.includes('import RNSentry')) { + // Insert import statement after UIKit import + config.modResults.contents = config.modResults.contents.replace(/(import UIKit\n)/, `$1import RNSentry\n`); + } + } else if (['objcpp', 'objc'].includes(config.modResults.language)) { + if (config.modResults.contents.includes('[RNSentrySDK start]')) { + warnOnce(`Your '${fileName}' already contains '[RNSentrySDK start]'.`); + return config; + } + // Add [RNSentrySDK start] at the beginning of application:didFinishLaunchingWithOptions method + const originalContents = config.modResults.contents; + config.modResults.contents = config.modResults.contents.replace( + /(- \(BOOL\)application:[\s\S]*?didFinishLaunchingWithOptions:[\s\S]*?\{\n)(\s*)/s, + `$1$2[RNSentrySDK start];\n$2`, + ); + if (config.modResults.contents === originalContents) { + warnOnce(`Failed to insert '[RNSentrySDK start]' in '${fileName}.`); + } else if (!config.modResults.contents.includes('#import ')) { + // Add import after AppDelegate.h + config.modResults.contents = config.modResults.contents.replace( + /(#import "AppDelegate.h"\n)/, + `$1#import \n`, + ); + } + } else { + warnOnce( + `Unsupported language '${config.modResults.language}' detected in '${fileName}', the native code won't be updated.`, + ); + } + + return config; + }); +} diff --git a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts index e1363983da..0dcc9b33d6 100644 --- a/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts +++ b/packages/core/test/expo-plugin/modifyAppBuildGradle.test.ts @@ -1,7 +1,7 @@ -import { warnOnce } from '../../plugin/src/utils'; +import { warnOnce } from '../../plugin/src/logger'; import { modifyAppBuildGradle } from '../../plugin/src/withSentryAndroid'; -jest.mock('../../plugin/src/utils'); +jest.mock('../../plugin/src/logger'); const buildGradleWithSentry = ` apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") diff --git a/packages/core/test/expo-plugin/modifyAppDelegate.test.ts b/packages/core/test/expo-plugin/modifyAppDelegate.test.ts new file mode 100644 index 0000000000..fe42e3f123 --- /dev/null +++ b/packages/core/test/expo-plugin/modifyAppDelegate.test.ts @@ -0,0 +1,207 @@ +import type { ExpoConfig } from '@expo/config-types'; + +import { warnOnce } from '../../plugin/src/logger'; +import { modifyAppDelegate } from '../../plugin/src/withSentryIOS'; + +// Mock dependencies +jest.mock('@expo/config-plugins', () => ({ + ...jest.requireActual('@expo/config-plugins'), + withAppDelegate: jest.fn((config, callback) => callback(config)), +})); + +jest.mock('../../plugin/src/logger', () => ({ + warnOnce: jest.fn(), +})); + +interface MockedExpoConfig extends ExpoConfig { + modResults: { + path: string; + contents: string; + language: 'swift' | 'objc' | 'objcpp' | string; + }; +} + +const objcContents = `#import "AppDelegate.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + self.moduleName = @"main"; + + // You can add your custom initial props in the dictionary below. + // They will be passed down to the ViewController used by React Native. + self.initialProps = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end +`; + +const objcExpected = `#import "AppDelegate.h" +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + [RNSentrySDK start]; + self.moduleName = @"main"; + + // You can add your custom initial props in the dictionary below. + // They will be passed down to the ViewController used by React Native. + self.initialProps = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end +`; + +const swiftContents = `import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import UIKit + +@main +class AppDelegate: RCTAppDelegate { + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + self.moduleName = "sentry-react-native-sample" + self.dependencyProvider = RCTAppDependencyProvider() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +}`; + +const swiftExpected = `import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import UIKit +import RNSentry + +@main +class AppDelegate: RCTAppDelegate { + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + RNSentrySDK.start() + self.moduleName = "sentry-react-native-sample" + self.dependencyProvider = RCTAppDependencyProvider() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +}`; + +describe('modifyAppDelegate', () => { + let config: MockedExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset to a mocked Swift config after each test + config = createMockConfig(); + }); + + it('should skip modification if modResults or path is missing', async () => { + config.modResults.path = undefined; + + const result = await modifyAppDelegate(config); + + expect(warnOnce).toHaveBeenCalledWith( + `Can't add 'RNSentrySDK.start()' to the iOS AppDelegate, because the file was not found.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should warn if RNSentrySDK.start() is already present in a Swift project', async () => { + config.modResults.contents = 'RNSentrySDK.start();'; + + const result = await modifyAppDelegate(config); + + expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.swift' already contains 'RNSentrySDK.start()'.`); + expect(result).toBe(config); // No modification + }); + + it('should warn if [RNSentrySDK start] is already present in an Objective-C project', async () => { + config.modResults.language = 'objc'; + config.modResults.path = 'samples/react-native/ios/AppDelegate.mm'; + config.modResults.contents = '[RNSentrySDK start];'; + + const result = await modifyAppDelegate(config); + + expect(warnOnce).toHaveBeenCalledWith(`Your 'AppDelegate.mm' already contains '[RNSentrySDK start]'.`); + expect(result).toBe(config); // No modification + }); + + it('should modify a Swift file by adding the RNSentrySDK import and start', async () => { + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('import RNSentry'); + expect(result.modResults.contents).toContain('RNSentrySDK.start()'); + expect(result.modResults.contents).toBe(swiftExpected); + }); + + it('should modify an Objective-C file by adding the RNSentrySDK import and start', async () => { + config.modResults.language = 'objc'; + config.modResults.contents = objcContents; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('#import '); + expect(result.modResults.contents).toContain('[RNSentrySDK start];'); + expect(result.modResults.contents).toBe(objcExpected); + }); + + it('should modify an Objective-C++ file by adding the RNSentrySDK import and start', async () => { + config.modResults.language = 'objcpp'; + config.modResults.contents = objcContents; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('#import '); + expect(result.modResults.contents).toContain('[RNSentrySDK start];'); + expect(result.modResults.contents).toBe(objcExpected); + }); + + it('should not modify a source file if the language is not supported', async () => { + config.modResults.language = 'cpp'; + config.modResults.contents = objcContents; + config.modResults.path = 'samples/react-native/ios/AppDelegate.cpp'; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + expect(warnOnce).toHaveBeenCalledWith( + `Unsupported language 'cpp' detected in 'AppDelegate.cpp', the native code won't be updated.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should insert import statements only once in an Swift project', async () => { + config.modResults.contents = + 'import UIKit\nimport RNSentrySDK\n\noverride func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {'; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + const importCount = (result.modResults.contents.match(/import RNSentrySDK/g) || []).length; + expect(importCount).toBe(1); + }); + + it('should insert import statements only once in an Objective-C project', async () => { + config.modResults.language = 'objc'; + config.modResults.contents = + '#import "AppDelegate.h"\n#import \n\n- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {'; + + const result = (await modifyAppDelegate(config)) as MockedExpoConfig; + + const importCount = (result.modResults.contents.match(/#import /g) || []).length; + expect(importCount).toBe(1); + }); +}); + +function createMockConfig(): MockedExpoConfig { + return { + name: 'test', + slug: 'test', + modResults: { + path: 'samples/react-native/ios/AppDelegate.swift', + contents: swiftContents, + language: 'swift', + }, + }; +} diff --git a/packages/core/test/expo-plugin/modifyMainApplication.test.ts b/packages/core/test/expo-plugin/modifyMainApplication.test.ts new file mode 100644 index 0000000000..8ff7329c2e --- /dev/null +++ b/packages/core/test/expo-plugin/modifyMainApplication.test.ts @@ -0,0 +1,192 @@ +import type { ExpoConfig } from '@expo/config-types'; + +import { warnOnce } from '../../plugin/src/logger'; +import { modifyMainApplication } from '../../plugin/src/withSentryAndroid'; + +// Mock dependencies +jest.mock('@expo/config-plugins', () => ({ + ...jest.requireActual('@expo/config-plugins'), + withMainApplication: jest.fn((config, callback) => callback(config)), +})); + +jest.mock('../../plugin/src/logger', () => ({ + warnOnce: jest.fn(), +})); + +interface MockedExpoConfig extends ExpoConfig { + modResults: { + path: string; + contents: string; + language: 'java' | 'kt'; + }; +} + +const kotlinContents = `package io.sentry.expo.sample + +import android.app.Application + +import com.facebook.react.ReactApplication +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher + +class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } +} +`; + +const kotlinExpected = `package io.sentry.expo.sample + +import io.sentry.react.RNSentrySDK +import android.app.Application + +import com.facebook.react.ReactApplication +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher + +class MainApplication : Application(), ReactApplication { + override fun onCreate() { + super.onCreate() + + RNSentrySDK.init(this) + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } +} +`; + +const javaContents = `package com.testappplain; + +import android.app.Application; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.config.ReactFeatureFlags; +import com.facebook.soloader.SoLoader; + +public class MainApplication extends Application implements ReactApplication { + @Override + public void onCreate() { + super.onCreate(); + // If you opted-in for the New Architecture, we enable the TurboModule system + ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + SoLoader.init(this, /* native exopackage */ false); + initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); + } +} +`; + +const javaExpected = `package com.testappplain; + +import io.sentry.react.RNSentrySDK; +import android.app.Application; +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactInstanceManager; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.config.ReactFeatureFlags; +import com.facebook.soloader.SoLoader; + +public class MainApplication extends Application implements ReactApplication { + @Override + public void onCreate() { + super.onCreate(); + + RNSentrySDK.init(this); + // If you opted-in for the New Architecture, we enable the TurboModule system + ReactFeatureFlags.useTurboModules = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + SoLoader.init(this, /* native exopackage */ false); + initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); + } +} +`; + +describe('modifyMainApplication', () => { + let config: MockedExpoConfig; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset to a mocked Java config after each test + config = createMockConfig(); + }); + + it('should skip modification if modResults or path is missing', async () => { + config.modResults.path = undefined; + + const result = await modifyMainApplication(config); + + expect(warnOnce).toHaveBeenCalledWith( + `Can't add 'RNSentrySDK.init' to Android MainApplication, because the file was not found.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should warn if RNSentrySDK.init is already present', async () => { + config.modResults.contents = 'package com.example;\nsuper.onCreate();\nRNSentrySDK.init(this);'; + + const result = await modifyMainApplication(config); + + expect(warnOnce).toHaveBeenCalledWith( + `Your 'MainApplication.java' already contains 'RNSentrySDK.init', the native code won't be updated.`, + ); + expect(result).toBe(config); // No modification + }); + + it('should modify a Java file by adding the RNSentrySDK import and init', async () => { + const result = (await modifyMainApplication(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('import io.sentry.react.RNSentrySDK;'); + expect(result.modResults.contents).toContain('RNSentrySDK.init(this);'); + expect(result.modResults.contents).toBe(javaExpected); + }); + + it('should modify a Kotlin file by adding the RNSentrySDK import and init', async () => { + config.modResults.language = 'kt'; + config.modResults.contents = kotlinContents; + + const result = (await modifyMainApplication(config)) as MockedExpoConfig; + + expect(result.modResults.contents).toContain('import io.sentry.react.RNSentrySDK'); + expect(result.modResults.contents).toContain('RNSentrySDK.init(this)'); + expect(result.modResults.contents).toBe(kotlinExpected); + }); + + it('should insert import statements only once', async () => { + config.modResults.contents = 'package com.example;\nimport io.sentry.react.RNSentrySDK;\nsuper.onCreate();'; + + const result = (await modifyMainApplication(config)) as MockedExpoConfig; + + const importCount = (result.modResults.contents.match(/import io.sentry.react.RNSentrySDK/g) || []).length; + expect(importCount).toBe(1); + }); +}); + +function createMockConfig(): MockedExpoConfig { + return { + name: 'test', + slug: 'test', + modResults: { + path: '/android/app/src/main/java/com/example/MainApplication.java', + contents: javaContents, + language: 'java', + }, + }; +} diff --git a/packages/core/test/expo-plugin/modifyXcodeProject.test.ts b/packages/core/test/expo-plugin/modifyXcodeProject.test.ts index bbd570fdf9..92dc615835 100644 --- a/packages/core/test/expo-plugin/modifyXcodeProject.test.ts +++ b/packages/core/test/expo-plugin/modifyXcodeProject.test.ts @@ -1,7 +1,7 @@ -import { warnOnce } from '../../plugin/src/utils'; +import { warnOnce } from '../../plugin/src/logger'; import { modifyExistingXcodeBuildScript } from '../../plugin/src/withSentryIOS'; -jest.mock('../../plugin/src/utils'); +jest.mock('../../plugin/src/logger'); const buildScriptWithoutSentry = { shellScript: JSON.stringify(`" diff --git a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts index 0ed9a95551..f00c90d098 100644 --- a/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts +++ b/packages/core/test/expo-plugin/withSentryAndroidGradlePlugin.test.ts @@ -1,6 +1,6 @@ import { withAppBuildGradle, withProjectBuildGradle } from '@expo/config-plugins'; -import { warnOnce } from '../../plugin/src/utils'; +import { warnOnce } from '../../plugin/src/logger'; import type { SentryAndroidGradlePluginOptions } from '../../plugin/src/withSentryAndroidGradlePlugin'; import { withSentryAndroidGradlePlugin } from '../../plugin/src/withSentryAndroidGradlePlugin'; @@ -9,7 +9,7 @@ jest.mock('@expo/config-plugins', () => ({ withAppBuildGradle: jest.fn(), })); -jest.mock('../../plugin/src/utils', () => ({ +jest.mock('../../plugin/src/logger', () => ({ warnOnce: jest.fn(), })); diff --git a/samples/expo/app.json b/samples/expo/app.json index 1f1c89980d..2978475605 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -45,6 +45,7 @@ "url": "https://sentry.io/", "project": "sentry-react-native", "organization": "sentry-sdks", + "useNativeInit": true, "experimental_android": { "enableAndroidGradlePlugin": true, "autoUploadProguardMapping": true, @@ -71,4 +72,4 @@ ] ] } -} \ No newline at end of file +}