diff --git a/build/ios/Default.png b/build/ios/Default.png new file mode 100644 index 00000000..8c9089d5 Binary files /dev/null and b/build/ios/Default.png differ diff --git a/build/ios/Unittest-Info.plist b/build/ios/Unittest-Info.plist new file mode 100644 index 00000000..9256fb44 --- /dev/null +++ b/build/ios/Unittest-Info.plist @@ -0,0 +1,10 @@ + + + + + CFBundleIdentifier + ${IOS_BUNDLE_ID_PREFIX}.gtest.${GTEST_BUNDLE_ID_SUFFIX:rfc1034identifier} + UIApplicationDelegate + CrashpadUnitTestDelegate + + diff --git a/build/test.gni b/build/test.gni index f46520b7..24c343d7 100644 --- a/build/test.gni +++ b/build/test.gni @@ -18,9 +18,35 @@ if (crashpad_is_in_chromium) { import("//testing/test.gni") } else { template("test") { - executable(target_name) { - testonly = true - forward_variables_from(invoker, "*") + if (crashpad_is_ios) { + import("//third_party/mini_chromium/mini_chromium/build/ios/rules.gni") + + _launch_image_bundle_target = target_name + "_launch_image" + bundle_data(_launch_image_bundle_target) { + forward_variables_from(invoker, [ "testonly" ]) + sources = [ + "//build/ios/Default.png", + ] + outputs = [ + "{{bundle_contents_dir}}/{{source_file_part}}", + ] + } + + ios_app_bundle(target_name) { + testonly = true + info_plist = "//build/ios/Unittest-Info.plist" + extra_substitutions = [ "GTEST_BUNDLE_ID_SUFFIX=$target_name" ] + forward_variables_from(invoker, "*") + if (!defined(deps)) { + deps = [] + } + deps += [ ":$_launch_image_bundle_target" ] + } + } else { + executable(target_name) { + testonly = true + forward_variables_from(invoker, "*") + } } } } diff --git a/test/BUILD.gn b/test/BUILD.gn index 5159744b..f9c81324 100644 --- a/test/BUILD.gn +++ b/test/BUILD.gn @@ -243,6 +243,21 @@ if (!crashpad_is_ios) { } } +if (crashpad_is_ios) { + source_set("test_runner_ios") { + testonly = true + sources = [ + "gtest_runner_ios.h", + "gtest_runner_ios.mm", + ] + configs += [ "..:crashpad_config" ] + deps = [ + "../third_party/gtest:gtest", + ] + libs = [ "UIKit.framework" ] + } +} + static_library("gmock_main") { testonly = true sources = [ @@ -257,6 +272,9 @@ static_library("gmock_main") { "../third_party/mini_chromium:base", "../third_party/mini_chromium:base_test_support", ] + if (crashpad_is_ios) { + deps += [ ":test_runner_ios" ] + } } static_library("gtest_main") { @@ -272,4 +290,7 @@ static_library("gtest_main") { "../third_party/mini_chromium:base", "../third_party/mini_chromium:base_test_support", ] + if (crashpad_is_ios) { + deps += [ ":test_runner_ios" ] + } } diff --git a/test/gtest_main.cc b/test/gtest_main.cc index 0a7a2d3a..f6b6a69e 100644 --- a/test/gtest_main.cc +++ b/test/gtest_main.cc @@ -21,6 +21,10 @@ #include "gmock/gmock.h" #endif // CRASHPAD_TEST_LAUNCHER_GMOCK +#if defined(OS_IOS) +#include "test/gtest_runner_ios.h" +#endif + #if defined(OS_WIN) #include "test/win/win_child_process.h" #endif // OS_WIN @@ -93,5 +97,12 @@ int main(int argc, char* argv[]) { #error #define CRASHPAD_TEST_LAUNCHER_GTEST or CRASHPAD_TEST_LAUNCHER_GMOCK #endif // CRASHPAD_TEST_LAUNCHER_GMOCK +#if defined(OS_IOS) + // iOS needs to run tests within the context of an app, so call a helper that + // invokes UIApplicationMain(). The application delegate will call + // RUN_ALL_TESTS() and exit before returning control to this function. + crashpad::test::IOSLaunchApplicationAndRunTests(argc, argv); +#else return RUN_ALL_TESTS(); +#endif } diff --git a/test/gtest_runner_ios.h b/test/gtest_runner_ios.h new file mode 100644 index 00000000..564fe5c2 --- /dev/null +++ b/test/gtest_runner_ios.h @@ -0,0 +1,33 @@ +// Copyright 2019 The Crashpad Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef CRASHPAD_TEST_GTEST_RUNNER_IOS_ +#define CRASHPAD_TEST_GTEST_RUNNER_IOS_ + +namespace crashpad { +namespace test { + +//! \brief Runs all registered tests in the context of a UIKit application. +//! +//! Invokes UIApplicationMain() to launch the iOS application and runs all +//! registered tests after the application finishes +//! launching. UIApplicationMain() brings up the main runloop and never returns, +//! so therefore this function never returns either. It invokes _exit() to +//! terminate the application after tests have completed. +void IOSLaunchApplicationAndRunTests(int argc, char* argv[]); + +} // namespace test +} // namespace crashpad + +#endif // CRASHPAD_TEST_GTEST_RUNNER_IOS_ diff --git a/test/gtest_runner_ios.mm b/test/gtest_runner_ios.mm new file mode 100644 index 00000000..8738fcb9 --- /dev/null +++ b/test/gtest_runner_ios.mm @@ -0,0 +1,114 @@ +// Copyright 2019 The Crashpad Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "test/gtest_runner_ios.h" + +#import + +#include "gtest/gtest.h" + +@interface UIApplication (Testing) +- (void)_terminateWithStatus:(int)status; +@end + +namespace { + +// The iOS watchdog timer will kill an app that doesn't spin the main event +// loop often enough. This uses a Gtest TestEventListener to spin the current +// loop after each test finishes. However, if any individual test takes too +// long, it is still possible that the app will get killed. +class IOSRunLoopListener : public testing::EmptyTestEventListener { + public: + virtual void OnTestEnd(const testing::TestInfo& test_info) { + @autoreleasepool { + // At the end of the test, spin the default loop for a moment. + NSDate* stop_date = [NSDate dateWithTimeIntervalSinceNow:0.001]; + [[NSRunLoop currentRunLoop] runUntilDate:stop_date]; + } + } +}; + +void RegisterTestEndListener() { + testing::TestEventListeners& listeners = + testing::UnitTest::GetInstance()->listeners(); + listeners.Append(new IOSRunLoopListener); +} + +} // namespace + +@interface CrashpadUnitTestDelegate : NSObject +@property(nonatomic, readwrite, strong) UIWindow* window; +- (void)runTests; +@end + +@implementation CrashpadUnitTestDelegate + +- (BOOL)application:(UIApplication*)application + didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { + self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds]; + self.window.backgroundColor = UIColor.whiteColor; + [self.window makeKeyAndVisible]; + + UIViewController* controller = [[UIViewController alloc] init]; + [self.window setRootViewController:controller]; + + // Add a label with the app name. + UILabel* label = [[UILabel alloc] initWithFrame:controller.view.bounds]; + label.text = [[NSProcessInfo processInfo] processName]; + label.textAlignment = NSTextAlignmentCenter; + label.textColor = UIColor.blackColor; + [controller.view addSubview:label]; + + // Queue up the test run. + [self performSelector:@selector(runTests) withObject:nil afterDelay:0.1]; + + return YES; +} + +- (void)runTests { + RegisterTestEndListener(); + + int exitStatus = RUN_ALL_TESTS(); + + // If a test app is too fast, it will exit before Instruments has has a + // a chance to initialize and no test results will be seen. + // TODO(crbug.com/137010): Figure out how much time is actually needed, and + // sleep only to make sure that much time has elapsed since launch. + [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:2.0]]; + self.window = nil; + + // Use the hidden selector to try and cleanly take down the app (otherwise + // things can think the app crashed even on a zero exit status). + UIApplication* application = [UIApplication sharedApplication]; + [application _terminateWithStatus:exitStatus]; + + exit(exitStatus); +} + +@end + + +namespace crashpad { +namespace test { + +void IOSLaunchApplicationAndRunTests(int argc, char* argv[]) { + @autoreleasepool { + int exit_status = + UIApplicationMain(argc, argv, nil, @"CrashpadUnitTestDelegate"); + exit(exit_status); + } +} + +} // namespace crashpad +} // namespace test