diff --git a/packages/platforms/react-native/ios/BugsnagPerformanceConfiguration.h b/packages/platforms/react-native/ios/BugsnagPerformanceConfiguration.h index 9f2916a2a..bfe23da4c 100644 --- a/packages/platforms/react-native/ios/BugsnagPerformanceConfiguration.h +++ b/packages/platforms/react-native/ios/BugsnagPerformanceConfiguration.h @@ -1,7 +1,12 @@ // Copied from BugsnagPerformanceConfiguration.h in bugsnag-cocoa-performance +#import "BugsnagPerformanceSpan.h" NS_ASSUME_NONNULL_BEGIN +typedef void (^ BugsnagPerformanceSpanStartCallback)(BugsnagPerformanceSpan *span); + +typedef BOOL (^ BugsnagPerformanceSpanEndCallback)(BugsnagPerformanceSpan *span); + @interface BugsnagPerformanceConfiguration : NSObject @property (nonatomic) NSString *apiKey; diff --git a/packages/platforms/react-native/ios/BugsnagPerformancePlugin.h b/packages/platforms/react-native/ios/BugsnagPerformancePlugin.h new file mode 100644 index 000000000..d6511e798 --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagPerformancePlugin.h @@ -0,0 +1,36 @@ +// Copied from BugsnagPerformancePlugin.h in bugsnag-cocoa-performance +#import + +@class BugsnagPerformancePluginContext; + +NS_ASSUME_NONNULL_BEGIN + +/** + * A plugin interface that provides a way to extend the functionality of the performance monitoring + * library. Plugins are added to the library via the -[BugsnagPerformanceConfiguration addPlugin:] + * method, and are called when the library is started. + */ +@protocol BugsnagPerformancePlugin + +/** + * Called when the plugin is loaded. This is where you can set up any necessary resources or + * configurations for the plugin. This is called synchronously as part of + * +[BugsnagPerformance startWithConfiguration:] to configure any callbacks and hooks that the plugin needs to + * perform its work. + * + * @param context The context in which the plugin is being loaded. The context should not be used again after this method returns. + * @see +[BugsnagPerformance startWithConfiguration:] + */ +- (void)installWithContext:(BugsnagPerformancePluginContext *)context; + +/** + * Start the plugin. This is called after all plugins have been installed and is where you can + * start any background tasks or other operations that the plugin needs to perform. This is + * called asynchronously after [BugsnagPerformance.start] to allow the plugin to perform + * any necessary work without blocking the main thread. + */ +- (void)start; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/platforms/react-native/ios/BugsnagPerformancePluginContext.h b/packages/platforms/react-native/ios/BugsnagPerformancePluginContext.h new file mode 100644 index 000000000..8905cacd0 --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagPerformancePluginContext.h @@ -0,0 +1,69 @@ +// Copied from BugsnagPerformancePluginContext.h in bugsnag-cocoa-performance +#import +#import "BugsnagPerformancePriority.h" +#import "BugsnagPerformanceSpan.h" +#import "BugsnagPerformanceConfiguration.h" + +NS_ASSUME_NONNULL_BEGIN + +OBJC_EXPORT +@interface BugsnagPerformancePluginContext : NSObject + +/** + * The user provided configuration for the performance monitoring library. Changes made by + * the plugin to this configuration may be *ignored* by the library, so plugins should not + * modify this configuration directly (instead making any changes via the [BugsnagPerformancePluginContext] methods). + */ +@property (nonatomic, readonly) BugsnagPerformanceConfiguration *cofiguration; + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Add a [BugsnagPerformanceSpanStartCallback] to the list of callbacks that will be called when a span is + * started. This is a convenience method that is the same as calling + * -[BugsnagPerformancePluginContext addOnSpanStartCallback:priority:] with the default priority of [BugsnagPerformancePriorityMedium]. + * + * @param callback Callback to be called on span start. Adding the same callback multiple times will not take effect + * @see -[BugsnagPerformancePluginContext addOnSpanStartCallback:priority:] + */ +- (void)addOnSpanStartCallback:(BugsnagPerformanceSpanStartCallback)callback; + +/** + * Add a [BugsnagPerformanceSpanStartCallback] to the list of callbacks that will be called when a span is + * started. The priority of the callback determines the order in which it will be called, + * with lower numbers being called first. The default priority is [BugsnagPerformancePriorityMedium] (which + * is also the priority of the [BugsnagPerformanceSpanStartCallback] added in + * -[BugsnagPerformanceConfiguration addOnSpanStartCallback:]). + * + * @param callback Callback to be called on span start. Adding the same callback multiple times will not take effect + * @param priority The priority of the callback determines the order in which it will be called, with higher priorities being called first. + * @see -[BugsnagPerformanceConfiguration addOnSpanStartCallback:] + */ +- (void)addOnSpanStartCallback:(BugsnagPerformanceSpanStartCallback)callback priority:(BugsnagPerformancePriority)priority; + +/** + * Add a [BugsnagPerformanceSpanEndCallback] to the list of callbacks that will be called when a span is + * ended. This is a convenience method that is the same as calling + * -[BugsnagPerformancePluginContext addOnSpanEndCallback:priority:] with the default priority of [BugsnagPerformancePriorityMedium]. + * + * @param callback Callback to be called on span end. Adding the same callback multiple times will not take effect + * @see -[BugsnagPerformancePluginContext addOnSpanEndCallback:priority:] + */ +- (void)addOnSpanEndCallback:(BugsnagPerformanceSpanEndCallback)callback; + +/** + * Add a [BugsnagPerformanceSpanEndCallback] to the list of callbacks that will be called when a span is + * ended. The priority of the callback determines the order in which it will be called, + * with lower numbers being called first. The default priority is [BugsnagPerformancePriorityMedium] (which + * is also the priority of the [BugsnagPerformanceSpanEndCallback] added in + * -[BugsnagPerformanceConfiguration addOnSpanEndCallback:]). + * + * @param callback Callback to be called on span end. Adding the same callback multiple times will not take effect + * @param priority The priority of the callback determines the order in which it will be called, with higher priorities being called first. + * @see -[BugsnagPerformanceConfiguration addOnSpanEndCallback:] + */ +- (void)addOnSpanEndCallback:(BugsnagPerformanceSpanEndCallback)callback priority:(BugsnagPerformancePriority)priority; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/platforms/react-native/ios/BugsnagPerformancePriority.h b/packages/platforms/react-native/ios/BugsnagPerformancePriority.h new file mode 100644 index 000000000..71c7c2a4e --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagPerformancePriority.h @@ -0,0 +1,22 @@ +// Copied from BugsnagPerformancePriority.h in bugsnag-cocoa-performance +#import + +/** + * Calling priority for callbacks and providers. + */ +typedef NSInteger BugsnagPerformancePriority; + +/** + * High priority, for callbacks and providers that need to be called early + */ +extern const BugsnagPerformancePriority BugsnagPerformancePriorityHigh; + +/** + * Default priority + */ +extern const BugsnagPerformancePriority BugsnagPerformancePriorityMedium; + +/** + * Low priority, for callbacks and providers that need to be called late + */ +extern const BugsnagPerformancePriority BugsnagPerformancePriorityLow; diff --git a/packages/platforms/react-native/ios/BugsnagPerformanceSpan.h b/packages/platforms/react-native/ios/BugsnagPerformanceSpan.h index f6951ea14..d945e0e38 100644 --- a/packages/platforms/react-native/ios/BugsnagPerformanceSpan.h +++ b/packages/platforms/react-native/ios/BugsnagPerformanceSpan.h @@ -1,5 +1,6 @@ // Copied from BugsnagPerformanceSpan.h in bugsnag-cocoa-performance #import "BugsnagPerformanceSpanContext.h" +#import "BugsnagPerformanceSpanCondition.h" NS_ASSUME_NONNULL_BEGIN @@ -26,6 +27,8 @@ OBJC_EXPORT - (void)setAttribute:(NSString *)attributeName withValue:(_Nullable id)value; +- (BugsnagPerformanceSpanCondition *_Nullable)blockWithTimeout:(NSTimeInterval)timeout NS_SWIFT_NAME(block(timeout:)); + #pragma mark Private APIs (BugsnagPerformanceSpan+Private.h) @property (nonatomic,readonly) NSMutableDictionary *attributes; diff --git a/packages/platforms/react-native/ios/BugsnagPerformanceSpanCondition.h b/packages/platforms/react-native/ios/BugsnagPerformanceSpanCondition.h new file mode 100644 index 000000000..1a1082459 --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagPerformanceSpanCondition.h @@ -0,0 +1,15 @@ +// Copied from BugsnagPerformanceSpanCondition.h in bugsnag-cocoa-performance +#import +#import "BugsnagPerformanceSpanContext.h" + +OBJC_EXPORT +@interface BugsnagPerformanceSpanCondition: NSObject + +@property (nonatomic) BOOL isActive; + +- (void)closeWithEndTime:(NSDate *)endTime NS_SWIFT_NAME(close(endTime:)); +- (BugsnagPerformanceSpanContext *)upgrade; +- (void)cancel; + +@end + diff --git a/packages/platforms/react-native/ios/BugsnagPerformanceSpanContext.h b/packages/platforms/react-native/ios/BugsnagPerformanceSpanContext.h index 8d46ad52a..113d86a98 100644 --- a/packages/platforms/react-native/ios/BugsnagPerformanceSpanContext.h +++ b/packages/platforms/react-native/ios/BugsnagPerformanceSpanContext.h @@ -14,6 +14,8 @@ OBJC_EXPORT - (instancetype) initWithTraceIdHi:(uint64_t)traceIdHi traceIdLo:(uint64_t)traceIdLo spanId:(SpanId)spanId; +- (NSString *)encodedAsTraceParent; + @end NS_ASSUME_NONNULL_END diff --git a/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin+Private.h b/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin+Private.h new file mode 100644 index 000000000..03f98418b --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin+Private.h @@ -0,0 +1,15 @@ +#import "BugsnagReactNativeAppStartPlugin.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface BugsnagReactNativeAppStartPlugin () + ++ (id _Nullable)singleton; + +- (NSString * _Nullable)getAppStartParent; + +- (void)endAppStart:(NSDate *)endTime; + +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin.h b/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin.h new file mode 100644 index 000000000..9519e94c9 --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin.h @@ -0,0 +1,10 @@ +#import +#import "BugsnagPerformancePlugin.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface BugsnagReactNativeAppStartPlugin: NSObject + +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin.mm b/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin.mm new file mode 100644 index 000000000..9e2d607d7 --- /dev/null +++ b/packages/platforms/react-native/ios/BugsnagReactNativeAppStartPlugin.mm @@ -0,0 +1,123 @@ +#import "BugsnagPerformancePluginContext.h" +#import "BugsnagPerformanceSpan.h" +#import "BugsnagPerformanceSpanCondition.h" +#import "BugsnagPerformanceSpanContext.h" +#import "BugsnagReactNativeAppStartPlugin+Private.h" + +static const NSTimeInterval kSpanBlockTimeoutInterval = 0.5; // 500ms timeout + +@interface BugsnagReactNativeAppStartPlugin () +@property (nonatomic, strong) NSString *currentSpanId; +@property (nonatomic, strong) BugsnagPerformanceSpanCondition *currentCondition; +@property (atomic, assign) BOOL appStartComplete; +@end + +@implementation BugsnagReactNativeAppStartPlugin + +static BugsnagReactNativeAppStartPlugin *_sharedInstance = nil; + ++ (id)singleton { + return _sharedInstance; +} + +- (void)installWithContext:(BugsnagPerformancePluginContext *)context { + _sharedInstance = self; + __weak BugsnagReactNativeAppStartPlugin *weakSelf = self; + + // Add span start callback with high priority (equivalent to NORM_PRIORITY + 1) + BugsnagPerformanceSpanStartCallback spanStartCallback = ^(BugsnagPerformanceSpan *span) { + [weakSelf onSpanStart:span]; + }; + + // Add span end callback with low priority (equivalent to NORM_PRIORITY - 1) + BugsnagPerformanceSpanEndCallback spanEndCallback = ^(BugsnagPerformanceSpan *span) { + return [weakSelf onSpanEnd:span]; + }; + + [context addOnSpanStartCallback:spanStartCallback priority:BugsnagPerformancePriorityHigh]; + [context addOnSpanEndCallback:spanEndCallback priority:BugsnagPerformancePriorityLow]; +} + +- (void)start { + // Plugin start implementation +} + +- (NSString *)getAppStartParent { + BugsnagPerformanceSpanCondition *condition; + @synchronized (self) { + condition = _currentCondition; + } + + if (condition) { + BugsnagPerformanceSpanContext *nativeParent = [condition upgrade]; + if (nativeParent) { + return [nativeParent encodedAsTraceParent]; + } + } + return nil; +} + +- (void)endAppStart:(NSDate *)endTime { + BugsnagPerformanceSpanCondition *condition; + @synchronized (self) { + condition = _currentCondition; + _currentCondition = nil; + _currentSpanId = nil; + _appStartComplete = YES; + } + + if (condition) { + [condition closeWithEndTime:endTime]; + } +} + +- (void)onSpanStart:(BugsnagPerformanceSpan *)span { + // Check app start completion status (atomic read) + if (_appStartComplete) { + return; + } + + // Check if this is a view_load span by examining attributes + NSString *category = span.attributes[@"bugsnag.span.category"]; + if (![category isEqualToString:@"view_load"]) { + return; + } + + // Block the span (outside synchronized block) + BugsnagPerformanceSpanCondition *spanCondition = [span blockWithTimeout:kSpanBlockTimeoutInterval]; + + // Update shared state atomically + @synchronized (self) { + // Cancel any existing condition + if (_currentCondition) { + [_currentCondition cancel]; + } + + // Only set current condition if block() returned a non-null value + if (spanCondition) { + _currentSpanId = [span encodedAsTraceParent]; + _currentCondition = spanCondition; + } + } +} + +- (BOOL)onSpanEnd:(BugsnagPerformanceSpan *)span { + NSString *spanId = [span encodedAsTraceParent]; + + BugsnagPerformanceSpanCondition *conditionToCancel = nil; + @synchronized (self) { + if (_currentCondition && [spanId isEqualToString:_currentSpanId]) { + conditionToCancel = _currentCondition; + _currentCondition = nil; + _currentSpanId = nil; + } + } + + if (conditionToCancel) { + [conditionToCancel cancel]; + } + + return YES; +} + +@end \ No newline at end of file diff --git a/packages/platforms/react-native/ios/BugsnagReactNativePerformance.mm b/packages/platforms/react-native/ios/BugsnagReactNativePerformance.mm index 6efef466d..0c5e133fd 100644 --- a/packages/platforms/react-native/ios/BugsnagReactNativePerformance.mm +++ b/packages/platforms/react-native/ios/BugsnagReactNativePerformance.mm @@ -1,3 +1,4 @@ +#import "BugsnagReactNativeAppStartPlugin+Private.h" #import "BugsnagReactNativePerformance.h" #import "BugsnagReactNativePerformanceCrossTalkAPIClient.h" #import "ReactNativeSpanAttributes.h" @@ -286,6 +287,14 @@ static uint64_t hexStringToUInt64(NSString *hexString) { config[@"enabledReleaseStages"] = [nativeConfig.enabledReleaseStages allObjects]; } + BugsnagReactNativeAppStartPlugin *plugin = [BugsnagReactNativeAppStartPlugin singleton]; + if (plugin != nil) { + NSString *appStartParent = [plugin getAppStartParent]; + if (appStartParent != nil) { + config[@"appStartParentContext"] = appStartParent; + } + } + return config; } @@ -403,6 +412,11 @@ static uint64_t hexStringToUInt64(NSString *hexString) { RCT_EXPORT_METHOD(endNativeAppStart:(double)endTime resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { + BugsnagReactNativeAppStartPlugin *plugin = [BugsnagReactNativeAppStartPlugin singleton]; + if (plugin != nil) { + NSDate *nativeEndTime = [NSDate dateWithTimeIntervalSince1970: endTime / NSEC_PER_SEC]; + [plugin endAppStart:nativeEndTime]; + } resolve(nil); } diff --git a/test/react-native/features/app-start-spans.feature b/test/react-native/features/app-start-spans.feature index f4c04b2ef..ad93dda63 100644 --- a/test/react-native/features/app-start-spans.feature +++ b/test/react-native/features/app-start-spans.feature @@ -102,7 +102,7 @@ Feature: App Start spans And the trace payload field "resourceSpans.0.scopeSpans.0.spans.0" string attribute "bugsnag.app_start.type" equals "ReactNativeInit" And the trace payload field "resourceSpans.0.scopeSpans.0.spans.0" string attribute "bugsnag.app_start.name" equals "AppStartSpanControlScenario" - @native_integration @android_only + @native_integration @native_app_starts @android_only Scenario: Automatic app start spans can be nested under native view load spans When I run 'NativeAppStartScenario' And I relaunch the app after shutdown @@ -111,7 +111,7 @@ Feature: App Start spans And I wait to receive 2 traces And a span named '[AppStart/ReactNativeInit]' has a parent named '[ViewLoad/Activity]MainActivity' - @native_integration @android_only + @native_integration @native_app_starts @android_only Scenario: Manual app start spans can be nested under native view load spans When I run 'NativeManualAppStartScenario' And I relaunch the app after shutdown @@ -119,3 +119,21 @@ Feature: App Start spans And I wait to receive 2 sampling requests And I wait to receive 2 traces And a span named '[AppStart/ReactNativeInit]' has a parent named '[ViewLoad/Activity]MainActivity' + + @native_integration @native_app_starts @ios_only + Scenario: Automatic app start spans can be nested under native view load spans + When I run 'NativeAppStartScenario' + And I relaunch the app after shutdown + + And I wait to receive 2 sampling requests + And I wait to receive 2 traces + And a span named '[AppStart/ReactNativeInit]' has a parent named '[ViewLoad/UIKit]/BSGViewController' + + @native_integration @native_app_starts @ios_only + Scenario: Manual app start spans can be nested under native view load spans + When I run 'NativeManualAppStartScenario' + And I relaunch the app after shutdown + + And I wait to receive 2 sampling requests + And I wait to receive 2 traces + And a span named '[AppStart/ReactNativeInit]' has a parent named '[ViewLoad/UIKit]/BSGViewController' \ No newline at end of file diff --git a/test/react-native/features/support/env.rb b/test/react-native/features/support/env.rb index 0c8e72ee6..cc8bab0cd 100644 --- a/test/react-native/features/support/env.rb +++ b/test/react-native/features/support/env.rb @@ -38,11 +38,6 @@ skip_this_scenario("Skipping scenario: Not running native integration fixture") unless ENV["NATIVE_INTEGRATION"] end -Before('@skip_android_old_arch_079') do |scenario| - current_version = ENV['RN_VERSION'].nil? ? 0 : ENV['RN_VERSION'].to_f - skip_this_scenario("Skipping scenario") if Maze::Helper.get_current_platform == 'android' && !ENV['RCT_NEW_ARCH_ENABLED'].eql?('1') && current_version == 0.79 -end - Before('@skip_expo') do |scenario| skip_this_scenario("Skipping scenario: Not supported in Expo") if ENV["EXPO_VERSION"] end @@ -52,9 +47,16 @@ end Before('@ios_only') do |scenario| - skip_this_scenario("Skipping scenario") unless Maze::Helper.get_current_platform == 'ios' + skip_this_scenario("Skipping scenario: Not running iOS fixture") unless Maze::Helper.get_current_platform == 'ios' end Before('@android_only') do |scenario| - skip_this_scenario("Skipping scenario") unless Maze::Helper.get_current_platform == 'android' + skip_this_scenario("Skipping scenario: Not running Android fixture") unless Maze::Helper.get_current_platform == 'android' end + +# native app start tests are skipped on RN 0.72 iOS due to absence of the RCTAppDelegate methods we override to set a custom root view controller +Before('@native_app_starts') do |scenario| + current_version = ENV['RN_VERSION'].nil? ? 0 : ENV['RN_VERSION'].to_f + skip_this_scenario("Skipping scenario: Not running native integration fixture") unless ENV["NATIVE_INTEGRATION"] + skip_this_scenario("Skipping scenario: Not supported in 0.72") if Maze::Helper.get_current_platform == 'ios' && current_version == 0.72 +end \ No newline at end of file diff --git a/test/react-native/native-test-utils/ios/BSGViewController.h b/test/react-native/native-test-utils/ios/BSGViewController.h new file mode 100644 index 000000000..c64ed1489 --- /dev/null +++ b/test/react-native/native-test-utils/ios/BSGViewController.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface BSGViewController : UIViewController + +@property (nonatomic, copy, nullable) UIView *(^viewFactory)(); + +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/test/react-native/native-test-utils/ios/BSGViewController.m b/test/react-native/native-test-utils/ios/BSGViewController.m new file mode 100644 index 000000000..046f7c9ee --- /dev/null +++ b/test/react-native/native-test-utils/ios/BSGViewController.m @@ -0,0 +1,13 @@ +#import "BSGViewController.h" + +@implementation BSGViewController + +- (void)loadView { + if (self.viewFactory) { + self.view = self.viewFactory(); + } else { + [super loadView]; + } +} + +@end \ No newline at end of file diff --git a/test/react-native/native-test-utils/ios/BugsnagTestUtils.h b/test/react-native/native-test-utils/ios/BugsnagTestUtils.h index daaf88beb..5b1e47668 100644 --- a/test/react-native/native-test-utils/ios/BugsnagTestUtils.h +++ b/test/react-native/native-test-utils/ios/BugsnagTestUtils.h @@ -1,4 +1,5 @@ #import +#import "BSGViewController.h" NS_ASSUME_NONNULL_BEGIN diff --git a/test/react-native/native-test-utils/ios/BugsnagTestUtils.mm b/test/react-native/native-test-utils/ios/BugsnagTestUtils.mm index a8d61bcb2..3216784b8 100644 --- a/test/react-native/native-test-utils/ios/BugsnagTestUtils.mm +++ b/test/react-native/native-test-utils/ios/BugsnagTestUtils.mm @@ -6,6 +6,7 @@ #import #import "BugsnagNativeSpansPlugin.h" #import "BugsnagJavascriptSpansPlugin.h" +#import "BugsnagReactNativeAppStartPlugin.h" #endif @implementation BugsnagTestUtils @@ -86,7 +87,7 @@ + (BOOL)startNativePerformanceWithConfiguration:(NSDictionary *)configuration { [config addPlugin:[BugsnagNativeSpansPlugin new]]; [config addPlugin:[BugsnagJavascriptSpansPlugin new]]; - // [config addPlugin:[ReactNativeAppStartPlugin new]]; + [config addPlugin:[BugsnagReactNativeAppStartPlugin new]]; [BugsnagPerformance startWithConfiguration:config]; diff --git a/test/react-native/scripts/generate-react-native-fixture.js b/test/react-native/scripts/generate-react-native-fixture.js index 74dc75257..a6a2e0b6b 100644 --- a/test/react-native/scripts/generate-react-native-fixture.js +++ b/test/react-native/scripts/generate-react-native-fixture.js @@ -15,16 +15,21 @@ const { } = require('./utils/dependency-utils') const { replaceGeneratedFixtureFiles, - configureIOSProject, + configureReactNativeNavigation +} = require('./utils/react-native-config') +const { configureAndroidProject, installAndroidPerformance, - installCocoaPerformance, - configureReactNativeNavigation, installNativeTestUtilsAndroid, + configureMainApplicationForTestUtils +} = require('./utils/android-utils') +const { + configureIOSProject, + installCocoaPerformance, installNativeTestUtilsIOS, - configureMainApplicationForTestUtils, - configureAppDelegateForTestUtils -} = require('./utils/react-native-config') + configureAppDelegateForTestUtils, + applyViewControllerChanges +} = require('./utils/ios-utils') const { configureRN064Fixture } = require('./utils/rn-064-config') const { buildAndroidFixture, buildIOSFixture } = require('./utils/platform-builds') @@ -125,6 +130,8 @@ if (!process.env.SKIP_GENERATE_FIXTURE) { configureMainApplicationForTestUtils(fixtureDir, reactNativeVersion) configureAppDelegateForTestUtils(fixtureDir, reactNativeVersion) + + applyViewControllerChanges(fixtureDir, reactNativeVersion) } // Configure React Native Navigation if needed diff --git a/test/react-native/scripts/utils/android-utils.js b/test/react-native/scripts/utils/android-utils.js new file mode 100644 index 000000000..109179cd8 --- /dev/null +++ b/test/react-native/scripts/utils/android-utils.js @@ -0,0 +1,130 @@ +const fs = require('fs') +const { resolve } = require('path') +const { ROOT_DIR } = require('./constants') +const { replaceInFile, appendToFileIfNotExists } = require('./file-utils') + +/** + * Configure Android project settings + */ +function configureAndroidProject (fixtureDir, isNewArchEnabled, reactNativeVersion) { + // set android:usesCleartextTraffic="true" in AndroidManifest.xml + const androidManifestPath = `${fixtureDir}/android/app/src/main/AndroidManifest.xml` + replaceInFile(androidManifestPath, 'NSAllowsArbitraryLoads')) { + searchPattern = 'NSAllowsArbitraryLoads\n\t\t' + replacement = allowArbitraryLoads + } else { + searchPattern = 'NSAppTransportSecurity\n\t' + replacement = `${searchPattern}\n\t\t${allowArbitraryLoads}` + } + + // remove the NSAllowsLocalNetworking key if it exists as this causes NSAllowsArbitraryLoads to be ignored + const allowLocalNetworking = 'NSAllowsLocalNetworking\n\t\t' + plistContents = plistContents.replace(allowLocalNetworking, '') + + fs.writeFileSync(plistpath, plistContents.replace(searchPattern, replacement)) +} + +function installNativeTestUtilsIOS(fixtureDir) { + const podfilePath = resolve(fixtureDir, 'ios/Podfile') + const testUtilsPod = `pod 'BugsnagTestUtils', :path => '${resolve(ROOT_DIR, 'test/react-native/native-test-utils/ios/BugsnagTestUtils.podspec')}'` + const targetSection = 'target \'reactnative\' do' + + replaceInFile(podfilePath, targetSection, `${targetSection}\n ${testUtilsPod}`) +} + +/** + * Install Cocoa Performance dependency + */ +function installCocoaPerformance (fixtureDir) { + const podfilePath = resolve(fixtureDir, 'ios/Podfile') + const performancePod = "pod 'BugsnagPerformance', :git => 'https://github.com/bugsnag/bugsnag-cocoa-performance.git', :branch => 'integration/v2'" + const targetSection = 'target \'reactnative\' do' + + replaceInFile(podfilePath, targetSection, `${targetSection}\n ${performancePod}`) +} + +/** + * Configure AppDelegate to import BugsnagTestUtils and call startNativePerformance + */ +function configureAppDelegateForTestUtils (fixtureDir, reactNativeVersion) { + // Determine file type based on React Native version + const isSwift = parseFloat(reactNativeVersion) >= 0.78 + const fileExtension = isSwift ? 'swift' : (parseFloat(reactNativeVersion) >= 0.72 ? 'mm' : 'm') + const appDelegatePath = `${fixtureDir}/ios/reactnative/AppDelegate.${fileExtension}` + + if (!fs.existsSync(appDelegatePath)) { + console.warn(`AppDelegate file not found at ${appDelegatePath}`) + return + } + + const fileContents = fs.readFileSync(appDelegatePath, 'utf8') + const importStatement = isSwift ? 'import BugsnagTestUtils' : '#import ' + const methodCall = isSwift ? 'BugsnagTestUtils.startNativePerformanceIfConfigured()' : '[BugsnagTestUtils startNativePerformanceIfConfigured];' + const indentation = isSwift ? ' ' : ' ' + + // Add import statement at the top + prependToFileIfNotExists(appDelegatePath, `${importStatement}\n`) + + // Add method call at the start of didFinishLaunchingWithOptions + if (!fileContents.includes(methodCall)) { + const didFinishMatch = fileContents.match(/didFinishLaunchingWithOptions[^{]*{\n/) + if (didFinishMatch) { + replaceInFile(appDelegatePath, didFinishMatch[0], `${didFinishMatch[0]}${indentation}${methodCall}\n\n`) + } + } +} + +/** + * Apply view controller changes for view load instrumentation compatibility + * This adds the necessary overrides to make React Native work with Cocoa Performance view load instrumentation + */ +function applyViewControllerChanges (fixtureDir, reactNativeVersion) { + const version = parseFloat(reactNativeVersion) + const isSwift = version >= 0.78 + const fileExtension = isSwift ? 'swift' : (version >= 0.72 ? 'mm' : 'm') + const appDelegatePath = `${fixtureDir}/ios/reactnative/AppDelegate.${fileExtension}` + + if (!fs.existsSync(appDelegatePath)) { + console.warn(`AppDelegate file not found at ${appDelegatePath}`) + return + } + + const fileContents = fs.readFileSync(appDelegatePath, 'utf8') + + // Add import for BSGViewController + const importStatement = isSwift ? 'import BugsnagTestUtils' : '#import ' + if (!fileContents.includes(importStatement)) { + prependToFileIfNotExists(appDelegatePath, `${importStatement}\n`) + } + + if (isSwift) { + applySwiftViewControllerChanges(appDelegatePath, fileContents) + } else if (version >= 0.74) { + applyObjectiveCModernViewControllerChanges(appDelegatePath, fileContents) + } else if (version < 0.72) { + applyObjectiveCLegacyViewControllerChanges(appDelegatePath, fileContents) + } + // Skip 0.72-0.73 as they don't expose a setRootView method for us to override +} + +/** + * Apply view controller changes for Swift AppDelegate files (0.78+) + */ +function applySwiftViewControllerChanges (appDelegatePath, fileContents) { + if (fileContents.includes('override func createRootViewController()')) { + return // Already configured + } + + const viewControllerMethods = ` + override func createRootViewController() -> UIViewController { + return BSGViewController() // Custom view controller for view load instrumentation + } + + override func setRootView(_ rootView: UIView, toRootViewController rootViewController: UIViewController) { + if let viewController = rootViewController as? BSGViewController { + viewController.viewFactory = { + return rootView + } + } else { + super.setRootView(rootView, toRootViewController: rootViewController) + } + } +` + + // Find an anchor point to insert the methods - try multiple common patterns + const anchors = [ + 'override func sourceURL(for bridge: RCTBridge)', + 'override func bundleURL()', + 'func application(_ application: UIApplication, didFinishLaunchingWithOptions' + ] + + for (const anchor of anchors) { + if (fileContents.includes(anchor)) { + const escapedAnchor = anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = fileContents.match(new RegExp(`(\n\\s*${escapedAnchor})`, 'm')) + if (match) { + replaceInFile(appDelegatePath, match[0], `${viewControllerMethods}${match[0]}`) + return + } + } + } +} + +/** + * Apply view controller changes for modern Objective-C AppDelegate files (0.74-0.76) + */ +function applyObjectiveCModernViewControllerChanges (appDelegatePath, fileContents) { + if (fileContents.includes('- (UIViewController *)createRootViewController')) { + return // Already configured + } + + const viewControllerMethods = ` +- (UIViewController *)createRootViewController +{ + return [BSGViewController new]; // Custom view controller for view load instrumentation +} + +- (void)setRootView:(UIView *)rootView toRootViewController:(UIViewController *)rootViewController +{ + if ([rootViewController isKindOfClass:[BSGViewController class]]) { + ((BSGViewController *)rootViewController).viewFactory = ^UIView *{ + return rootView; + }; + } else { + [super setRootView:rootView toRootViewController:rootViewController]; + } +} +` + + // Insert before sourceURLForBridge or at the end before @end + if (fileContents.includes('- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge')) { + const match = fileContents.match(/(\n- \(NSURL \*\)sourceURLForBridge:\(RCTBridge \*\)bridge)/) + if (match) { + replaceInFile(appDelegatePath, match[0], `${viewControllerMethods}${match[0]}`) + } + } else { + const match = fileContents.match(/(\n@end\s*)$/) + if (match) { + replaceInFile(appDelegatePath, match[0], `${viewControllerMethods}${match[0]}`) + } + } +} + +/** + * Apply view controller changes for legacy Objective-C AppDelegate files (0.64-0.71) + */ +function applyObjectiveCLegacyViewControllerChanges (appDelegatePath, fileContents) { + if (fileContents.includes('[BSGViewController new]')) { + return // Already configured + } + + // Replace UIViewController with BSGViewController + replaceInFile( + appDelegatePath, + 'UIViewController *rootViewController = [UIViewController new];', + 'BSGViewController *rootViewController = [BSGViewController new];' + ) + + // Replace direct view assignment with viewFactory pattern + replaceInFile( + appDelegatePath, + 'rootViewController.view = rootView;', + `rootViewController.viewFactory = ^UIView *{ + return rootView; + };` + ) +} + +module.exports = { + configureIOSProject, + installNativeTestUtilsIOS, + installCocoaPerformance, + configureAppDelegateForTestUtils, + applyViewControllerChanges +} \ No newline at end of file diff --git a/test/react-native/scripts/utils/react-native-config.js b/test/react-native/scripts/utils/react-native-config.js index 8de64c6d6..370a8a06e 100644 --- a/test/react-native/scripts/utils/react-native-config.js +++ b/test/react-native/scripts/utils/react-native-config.js @@ -3,7 +3,7 @@ const { resolve } = require('path') const util = require('util') const { execSync } = require('child_process') const { ROOT_DIR } = require('./constants') -const { replaceInFile, safeCopyFile, removeFileIfExists, appendToFileIfNotExists } = require('./file-utils') +const { replaceInFile, safeCopyFile, removeFileIfExists } = require('./file-utils') /** * Replace native files generated by react-native cli with pre-configured files @@ -40,156 +40,7 @@ function replaceGeneratedFixtureFiles (fixtureDir, isReactNativeNavigation) { fs.writeFileSync(resolve(fixtureDir, 'babel.config.js'), `module.exports = ${util.inspect(babelConfig)}`) } -/** - * Configure iOS project settings - */ -function configureIOSProject (fixtureDir, reactNativeVersion) { - // disable Flipper - let podfileContents = fs.readFileSync(`${fixtureDir}/ios/Podfile`, 'utf8') - if (podfileContents.includes('use_flipper!')) { - podfileContents = podfileContents.replace(/use_flipper!/, '# use_flipper!') - } else if (podfileContents.includes(':flipper_configuration')) { - podfileContents = podfileContents.replace(/:flipper_configuration/, '# :flipper_configuration') - } - - // for RN versions < 0.73, bump the minimum iOS version to 13 (required for Cocoa Performance) - if (parseFloat(reactNativeVersion) < 0.73) { - podfileContents = podfileContents.replace(/platform\s*:ios,\s*(?:'[\d.]+'|min_ios_version_supported)/, "platform :ios, '13.0'") - } - - fs.writeFileSync(`${fixtureDir}/ios/Podfile`, podfileContents) - - // pin xcodeproj version to < 1.26.0 - const gemfilePath = resolve(fixtureDir, 'Gemfile') - if (fs.existsSync(gemfilePath)) { - appendToFileIfNotExists(gemfilePath, "gem 'xcodeproj', '< 1.26.0'", 'xcodeproj') - appendToFileIfNotExists(gemfilePath, "gem 'concurrent-ruby', '<= 1.3.4'", 'concurrent-ruby') - } - // set NSAllowsArbitraryLoads to allow http traffic for all domains (bitbar public IP + bs-local.com) - const plistpath = `${fixtureDir}/ios/reactnative/Info.plist` - let plistContents = fs.readFileSync(plistpath, 'utf8') - const allowArbitraryLoads = 'NSAllowsArbitraryLoads\n\t\t' - let searchPattern, replacement - if (plistContents.includes('NSAllowsArbitraryLoads')) { - searchPattern = 'NSAllowsArbitraryLoads\n\t\t' - replacement = allowArbitraryLoads - } else { - searchPattern = 'NSAppTransportSecurity\n\t' - replacement = `${searchPattern}\n\t\t${allowArbitraryLoads}` - } - - // remove the NSAllowsLocalNetworking key if it exists as this causes NSAllowsArbitraryLoads to be ignored - const allowLocalNetworking = 'NSAllowsLocalNetworking\n\t\t' - plistContents = plistContents.replace(allowLocalNetworking, '') - - fs.writeFileSync(plistpath, plistContents.replace(searchPattern, replacement)) -} - -/** - * Configure Android project settings - */ -function configureAndroidProject (fixtureDir, isNewArchEnabled, reactNativeVersion) { - // set android:usesCleartextTraffic="true" in AndroidManifest.xml - const androidManifestPath = `${fixtureDir}/android/app/src/main/AndroidManifest.xml` - replaceInFile(androidManifestPath, ' '${resolve(ROOT_DIR, 'test/react-native/native-test-utils/ios/BugsnagTestUtils.podspec')}'` - const targetSection = 'target \'reactnative\' do' - - replaceInFile(podfilePath, targetSection, `${targetSection}\n ${testUtilsPod}`) -} - -/** - * Install Cocoa Performance dependency - */ -function installCocoaPerformance (fixtureDir) { - const podfilePath = resolve(fixtureDir, 'ios/Podfile') - const performancePod = "pod 'BugsnagPerformance'" - const targetSection = 'target \'reactnative\' do' - - replaceInFile(podfilePath, targetSection, `${targetSection}\n ${performancePod}`) -} /** * Configure React Native Navigation (Wix) @@ -202,140 +53,9 @@ function configureReactNativeNavigation (fixtureDir) { replaceInFile(gradlePath, /RNNKotlinVersion = "[^"]+"/, 'RNNKotlinVersion = "1.8.0"') } -/** - * Configure MainApplication to import BugsnagTestUtils and call startNativePerformance - */ -function configureMainApplicationForTestUtils (fixtureDir, reactNativeVersion) { - const fileExtension = parseFloat(reactNativeVersion) < 0.73 ? 'java' : 'kt' - const mainApplicationPath = `${fixtureDir}/android/app/src/main/java/com/bugsnag/fixtures/reactnative/performance/MainApplication.${fileExtension}` - - if (!fs.existsSync(mainApplicationPath)) { - console.warn(`MainApplication file not found at ${mainApplicationPath}`) - return - } - - let fileContents = fs.readFileSync(mainApplicationPath, 'utf8') - - if (fileExtension === 'java') { - // Add import for Java files - const importStatement = 'import com.bugsnag.test.utils.BugsnagTestUtils;' - if (!fileContents.includes(importStatement)) { - // Find the last import statement and add our import after it - const lastImportMatch = fileContents.match(/import\s+[^;]+;/g) - if (lastImportMatch) { - const lastImport = lastImportMatch[lastImportMatch.length - 1] - fileContents = fileContents.replace(lastImport, `${lastImport}\n${importStatement}`) - } - } - - // Add BugsnagTestUtils.startNativePerformanceIfConfigured() call after super.onCreate() - const methodCallPattern = /super\.onCreate\(\);/ - const methodCall = 'BugsnagTestUtils.startNativePerformanceIfConfigured(this);' - if (methodCallPattern.test(fileContents) && !fileContents.includes(methodCall)) { - fileContents = fileContents.replace(methodCallPattern, `super.onCreate();\n ${methodCall}`) - } - } else { - // Add import for Kotlin files - const importStatement = 'import com.bugsnag.test.utils.BugsnagTestUtils' - if (!fileContents.includes(importStatement)) { - // Find the last import statement and add our import after it - const lastImportMatch = fileContents.match(/import\s+[^\n]+/g) - if (lastImportMatch) { - const lastImport = lastImportMatch[lastImportMatch.length - 1] - fileContents = fileContents.replace(lastImport, `${lastImport}\n${importStatement}`) - } - } - - // Add BugsnagTestUtils.startNativePerformanceIfConfigured() call after super.onCreate() - const methodCallPattern = /super\.onCreate\(\)/ - const methodCall = 'BugsnagTestUtils.startNativePerformanceIfConfigured(this)' - if (methodCallPattern.test(fileContents) && !fileContents.includes(methodCall)) { - fileContents = fileContents.replace(methodCallPattern, `super.onCreate()\n ${methodCall}`) - } - } - - fs.writeFileSync(mainApplicationPath, fileContents) -} -/** - * Configure AppDelegate to import BugsnagTestUtils and call startNativePerformance - */ -function configureAppDelegateForTestUtils (fixtureDir, reactNativeVersion) { - // Determine file type based on React Native version - const isSwift = parseFloat(reactNativeVersion) >= 0.78 - const fileExtension = isSwift ? 'swift' : (parseFloat(reactNativeVersion) >= 0.72 ? 'mm' : 'm') - const appDelegatePath = `${fixtureDir}/ios/reactnative/AppDelegate.${fileExtension}` - - if (!fs.existsSync(appDelegatePath)) { - console.warn(`AppDelegate file not found at ${appDelegatePath}`) - return - } - - let fileContents = fs.readFileSync(appDelegatePath, 'utf8') - - if (isSwift) { - // Add import for Swift files - const importStatement = 'import BugsnagTestUtils' - if (!fileContents.includes(importStatement)) { - // Find the last import statement and add our import after it - const lastImportMatch = fileContents.match(/import\s+[^\n]+/g) - if (lastImportMatch) { - const lastImport = lastImportMatch[lastImportMatch.length - 1] - fileContents = fileContents.replace(lastImport, `${lastImport}\n${importStatement}`) - } - } - - // Add BugsnagTestUtils.startNativePerformanceIfConfigured() call in didFinishLaunchingWithOptions - const methodCall = 'BugsnagTestUtils.startNativePerformanceIfConfigured()' - if (!fileContents.includes(methodCall)) { - // For 0.78 pattern: find after self.initialProps = [:] - if (fileContents.includes('self.initialProps = [:]')) { - const pattern = /self\.initialProps = \[:]/ - fileContents = fileContents.replace(pattern, `self.initialProps = [:]\n\n ${methodCall}`) - } - // For 0.79+ pattern: find after delegate.dependencyProvider = RCTAppDependencyProvider() - else if (fileContents.includes('delegate.dependencyProvider = RCTAppDependencyProvider()')) { - const pattern = /delegate\.dependencyProvider = RCTAppDependencyProvider\(\)/ - fileContents = fileContents.replace(pattern, `delegate.dependencyProvider = RCTAppDependencyProvider()\n\n ${methodCall}`) - } - } - } else { - // Add import for Objective-C files - const importStatement = '#import ' - if (!fileContents.includes(importStatement)) { - // Add the import statement at the top of the file - fileContents = `${importStatement}\n${fileContents}` - } - - // Add [BugsnagTestUtils startNativePerformanceIfConfigured] call in didFinishLaunchingWithOptions - const methodCall = '[BugsnagTestUtils startNativePerformanceIfConfigured];' - if (!fileContents.includes(methodCall)) { - // For .m files: find after #ifdef FB_SONARKIT_ENABLED block - if (fileContents.includes('#ifdef FB_SONARKIT_ENABLED')) { - const pattern = /(#ifdef FB_SONARKIT_ENABLED\s+InitializeFlipper\(application\);\s+#endif)/ - fileContents = fileContents.replace(pattern, `$1\n\n ${methodCall}`) - } - // For .mm files: find after self.initialProps = @{}; - else if (fileContents.includes('self.initialProps = @{};')) { - const pattern = /self\.initialProps = @\{\};/ - fileContents = fileContents.replace(pattern, `self.initialProps = @{};\n\n ${methodCall}`) - } - } - } - - fs.writeFileSync(appDelegatePath, fileContents) -} module.exports = { replaceGeneratedFixtureFiles, - configureIOSProject, - configureAndroidProject, - configureReactNavigationAndroid, - installAndroidPerformance, - installCocoaPerformance, - configureReactNativeNavigation, - installNativeTestUtilsAndroid, - installNativeTestUtilsIOS, - configureMainApplicationForTestUtils, - configureAppDelegateForTestUtils + configureReactNativeNavigation }