1// Copyright 2012 The Chromium Authors 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5#import "base/test/test_support_ios.h" 6 7#import <UIKit/UIKit.h> 8 9#include "base/check.h" 10#include "base/command_line.h" 11#include "base/debug/debugger.h" 12#include "base/message_loop/message_pump.h" 13#include "base/message_loop/message_pump_apple.h" 14#import "base/test/ios/google_test_runner_delegate.h" 15#include "base/test/test_suite.h" 16#include "base/test/test_switches.h" 17#include "build/blink_buildflags.h" 18#include "testing/coverage_util_ios.h" 19 20// Springboard will kill any iOS app that fails to check in after launch within 21// a given time. Starting a UIApplication before invoking TestSuite::Run 22// prevents this from happening. 23 24// InitIOSRunHook saves the TestSuite and argc/argv, then invoking 25// RunTestsFromIOSApp calls UIApplicationMain(), providing an application 26// delegate class: ChromeUnitTestDelegate. The delegate implements 27// application:didFinishLaunchingWithOptions: to invoke the TestSuite's Run 28// method. 29 30// Since the executable isn't likely to be a real iOS UI, the delegate puts up a 31// window displaying the app name. If a bunch of apps using MainHook are being 32// run in a row, this provides an indication of which one is currently running. 33 34static base::RunTestSuiteCallback g_test_suite_callback; 35static int g_argc; 36static char** g_argv; 37 38namespace { 39void PopulateUIWindow(UIWindow* window) { 40 window.backgroundColor = UIColor.whiteColor; 41 [window makeKeyAndVisible]; 42 CGRect bounds = UIScreen.mainScreen.bounds; 43 // Add a label with the app name. 44 UILabel* label = [[UILabel alloc] initWithFrame:bounds]; 45 label.text = NSProcessInfo.processInfo.processName; 46 label.textAlignment = NSTextAlignmentCenter; 47 [window addSubview:label]; 48 49 // An NSInternalInconsistencyException is thrown if the app doesn't have a 50 // root view controller. Set an empty one here. 51 window.rootViewController = [[UIViewController alloc] init]; 52} 53 54bool IsSceneStartupEnabled() { 55 return [NSBundle.mainBundle.infoDictionary 56 objectForKey:@"UIApplicationSceneManifest"]; 57} 58} 59 60@interface UIApplication (Testing) 61- (void)_terminateWithStatus:(int)status; 62@end 63 64#if TARGET_IPHONE_SIMULATOR 65// Xcode 6 introduced behavior in the iOS Simulator where the software 66// keyboard does not appear if a hardware keyboard is connected. The following 67// declaration allows this behavior to be overridden when the app starts up. 68@interface UIKeyboardImpl 69+ (instancetype)sharedInstance; 70- (void)setAutomaticMinimizationEnabled:(BOOL)enabled; 71- (void)setSoftwareKeyboardShownByTouch:(BOOL)enabled; 72@end 73#endif // TARGET_IPHONE_SIMULATOR 74 75// Can be used to easily check if the current application is being used for 76// running tests. 77@interface ChromeUnitTestApplication : UIApplication 78- (BOOL)isRunningTests; 79@end 80 81@implementation ChromeUnitTestApplication 82- (BOOL)isRunningTests { 83 return YES; 84} 85@end 86 87// No-op scene delegate for unit tests. Note that this is created along with 88// the application delegate, so they need to be separate objects (the same 89// object can't be both the app and scene delegate, since new scene delegates 90// are created for each scene). 91@interface ChromeUnitTestSceneDelegate : NSObject <UIWindowSceneDelegate> { 92 UIWindow* __strong _window; 93} 94 95@end 96 97@interface ChromeUnitTestDelegate : NSObject <GoogleTestRunnerDelegate> { 98 UIWindow* __strong _window; 99} 100- (void)runTests; 101@end 102 103@implementation ChromeUnitTestSceneDelegate 104 105- (void)scene:(UIScene*)scene 106 willConnectToSession:(UISceneSession*)session 107 options:(UISceneConnectionOptions*)connectionOptions 108 API_AVAILABLE(ios(13)) { 109 _window = 110 [[UIWindow alloc] initWithWindowScene:static_cast<UIWindowScene*>(scene)]; 111 PopulateUIWindow(_window); 112} 113 114- (void)sceneDidDisconnect:(UIScene*)scene API_AVAILABLE(ios(13)) { 115 _window = nil; 116} 117 118@end 119 120@implementation ChromeUnitTestDelegate 121 122- (BOOL)application:(UIApplication*)application 123 didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { 124#if TARGET_IPHONE_SIMULATOR 125 // Xcode 6 introduced behavior in the iOS Simulator where the software 126 // keyboard does not appear if a hardware keyboard is connected. The following 127 // calls override this behavior by ensuring that the software keyboard is 128 // always shown. 129 [[UIKeyboardImpl sharedInstance] setAutomaticMinimizationEnabled:NO]; 130 if (@available(iOS 15, *)) { 131 } else { 132 [[UIKeyboardImpl sharedInstance] setSoftwareKeyboardShownByTouch:YES]; 133 } 134#endif // TARGET_IPHONE_SIMULATOR 135 136 if (!IsSceneStartupEnabled()) { 137 CGRect bounds = UIScreen.mainScreen.bounds; 138 139 _window = [[UIWindow alloc] initWithFrame:bounds]; 140 PopulateUIWindow(_window); 141 } 142 143 if ([self shouldRedirectOutputToFile]) 144 [self redirectOutput]; 145 146 // Queue up the test run. 147 if (!base::ShouldRunIOSUnittestsWithXCTest()) { 148 // When running in XCTest mode, XCTest will invoke |runGoogleTest| directly. 149 // Otherwise, schedule a call to |runTests|. 150 [self performSelector:@selector(runTests) withObject:nil afterDelay:0.1]; 151 } 152 153 return YES; 154} 155 156// Returns true if the gtest output should be redirected to a file, then sent 157// to NSLog when complete. This redirection is used because gtest only writes 158// output to stdout, but results must be written to NSLog in order to show up in 159// the device log that is retrieved from the device by the host. 160- (BOOL)shouldRedirectOutputToFile { 161#if !TARGET_IPHONE_SIMULATOR 162 // Tests in XCTest mode don't need to redirect output to a file because the 163 // test result parser analyzes console output. 164 return !base::ShouldRunIOSUnittestsWithXCTest() && 165 !base::debug::BeingDebugged(); 166#else 167 return NO; 168#endif // TARGET_IPHONE_SIMULATOR 169} 170 171// Returns the path to the directory to store gtest output files. 172- (NSString*)outputPath { 173 NSArray* searchPath = 174 NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, 175 NSUserDomainMask, 176 YES); 177 CHECK(searchPath.count > 0) << "Failed to get the Documents folder"; 178 return searchPath[0]; 179} 180 181// Returns the path to file that stdout is redirected to. 182- (NSString*)stdoutPath { 183 return [[self outputPath] stringByAppendingPathComponent:@"stdout.log"]; 184} 185 186// Returns the path to file that stderr is redirected to. 187- (NSString*)stderrPath { 188 return [[self outputPath] stringByAppendingPathComponent:@"stderr.log"]; 189} 190 191// Redirects stdout and stderr to files in the Documents folder in the app's 192// sandbox. 193- (void)redirectOutput { 194 freopen([[self stdoutPath] UTF8String], "w+", stdout); 195 freopen([[self stderrPath] UTF8String], "w+", stderr); 196} 197 198// Reads the redirected gtest output from a file and writes it to NSLog. 199- (void)writeOutputToNSLog { 200 // Close the redirected stdout and stderr files so that the content written to 201 // NSLog doesn't end up in these files. 202 fclose(stdout); 203 fclose(stderr); 204 for (NSString* path in @[ [self stdoutPath], [self stderrPath]]) { 205 NSString* content = [NSString stringWithContentsOfFile:path 206 encoding:NSUTF8StringEncoding 207 error:nil]; 208 NSArray* lines = 209 [content componentsSeparatedByCharactersInSet:NSCharacterSet 210 .newlineCharacterSet]; 211 212 NSLog(@"Writing contents of %@ to NSLog", path); 213 for (NSString* line in lines) { 214 NSLog(@"%@", line); 215 } 216 } 217} 218 219- (BOOL)supportsRunningGoogleTests { 220 return base::ShouldRunIOSUnittestsWithXCTest(); 221} 222 223- (int)runGoogleTests { 224 coverage_util::ConfigureCoverageReportPath(); 225 226 int exitStatus = std::move(g_test_suite_callback).Run(); 227 228 if ([self shouldRedirectOutputToFile]) 229 [self writeOutputToNSLog]; 230 231 return exitStatus; 232} 233 234- (void)runTests { 235 DCHECK(!base::ShouldRunIOSUnittestsWithXCTest()); 236 237 int exitStatus = [self runGoogleTests]; 238 239 // The Blink code path uses a spawning test launcher and this wait isn't 240 // really necessary for that code path. 241#if !BUILDFLAG(USE_BLINK) 242 // If a test app is too fast, it will exit before Instruments has has a 243 // a chance to initialize and no test results will be seen. 244 [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:2.0]]; 245#endif 246 _window = nil; 247 248 // Use the hidden selector to try and cleanly take down the app (otherwise 249 // things can think the app crashed even on a zero exit status). 250 UIApplication* application = [UIApplication sharedApplication]; 251 [application _terminateWithStatus:exitStatus]; 252 253 exit(exitStatus); 254} 255 256@end 257 258namespace { 259 260std::unique_ptr<base::MessagePump> CreateMessagePumpForUIForTests() { 261 // A basic MessagePump will do quite nicely in tests. 262 return std::unique_ptr<base::MessagePump>(new base::MessagePumpCFRunLoop()); 263} 264 265} // namespace 266 267namespace base { 268 269void InitIOSTestMessageLoop() { 270 MessagePump::OverrideMessagePumpForUIFactory(&CreateMessagePumpForUIForTests); 271} 272 273void InitIOSRunHook(RunTestSuiteCallback callback) { 274 g_test_suite_callback = std::move(callback); 275} 276 277void InitIOSArgs(int argc, char* argv[]) { 278 g_argc = argc; 279 g_argv = argv; 280} 281 282int RunTestsFromIOSApp() { 283 // When LaunchUnitTests is invoked it calls RunTestsFromIOSApp(). On its 284 // invocation, this method fires up an iOS app via UIApplicationMain. The 285 // TestSuite::Run will have be passed via InitIOSRunHook which will execute 286 // the TestSuite once the UIApplication is ready. 287 @autoreleasepool { 288 return UIApplicationMain(g_argc, g_argv, @"ChromeUnitTestApplication", 289 @"ChromeUnitTestDelegate"); 290 } 291} 292 293bool ShouldRunIOSUnittestsWithXCTest() { 294 return base::CommandLine::ForCurrentProcess()->HasSwitch( 295 switches::kEnableRunIOSUnittestsWithXCTest); 296} 297 298} // namespace base 299