From 28dfb445593bb747dd70775ecc839659a234a267 Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Fri, 6 Aug 2021 12:45:04 -0700 Subject: [PATCH] Add native iOS screenshots to integration_test (#84611) --- .../xcshareddata/IDEWorkspaceChecks.plist | 8 ++++ packages/integration_test/README.md | 9 ++-- .../ios/Runner.xcodeproj/project.pbxproj | 28 ----------- .../ios/Classes/IntegrationTestIosTest.h | 30 ++++++++++-- .../ios/Classes/IntegrationTestIosTest.m | 18 +++++++- .../ios/Classes/IntegrationTestPlugin.h | 12 ++++- .../ios/Classes/IntegrationTestPlugin.m | 46 +++++++++++++++++-- .../ios/integration_test.podspec | 2 + .../integration_test/lib/_callback_io.dart | 21 +++++---- 9 files changed, 124 insertions(+), 50 deletions(-) create mode 100644 dev/integration_tests/ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/dev/integration_tests/ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/dev/integration_tests/ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/dev/integration_tests/ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/integration_test/README.md b/packages/integration_test/README.md index b25c3576c0..08371120c6 100644 --- a/packages/integration_test/README.md +++ b/packages/integration_test/README.md @@ -100,9 +100,9 @@ flutter drive \ You can use `integration_test` to take screenshots of the UI rendered on the mobile device or Web browser at a specific time during the test. -This feature is currently supported on Android, and Web. +This feature is currently supported on Android, iOS, and Web. -#### Android +#### Android and iOS **integration_test/screenshot_test.dart** @@ -115,7 +115,7 @@ void main() { // Build the app. app.main(); - // This is required prior to taking the screenshot. + // This is required prior to taking the screenshot (Android only). await binding.convertFlutterSurfaceToImage(); // Trigger a frame. @@ -126,7 +126,8 @@ void main() { ``` You can use a driver script to pull in the screenshot from the device. -This way, you can store the images locally on your computer. +This way, you can store the images locally on your computer. On iOS, the +screenshot will also be available in Xcode test results. **test_driver/integration_test.dart** diff --git a/packages/integration_test/example/ios/Runner.xcodeproj/project.pbxproj b/packages/integration_test/example/ios/Runner.xcodeproj/project.pbxproj index ee47ce4715..a519162de3 100644 --- a/packages/integration_test/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/integration_test/example/ios/Runner.xcodeproj/project.pbxproj @@ -475,21 +475,11 @@ baseConfigurationReference = 09505407E99803EF7AA92DE7 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; name = Debug; @@ -499,20 +489,11 @@ baseConfigurationReference = FCE3953801588FC13ED9E898 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; name = Release; @@ -522,20 +503,11 @@ baseConfigurationReference = 750225973AAB5D7832AFA60C /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; - GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = RunnerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; - TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; }; name = Profile; diff --git a/packages/integration_test/ios/Classes/IntegrationTestIosTest.h b/packages/integration_test/ios/Classes/IntegrationTestIosTest.h index 5a127dab11..333b0ece93 100644 --- a/packages/integration_test/ios/Classes/IntegrationTestIosTest.h +++ b/packages/integration_test/ios/Classes/IntegrationTestIosTest.h @@ -4,23 +4,45 @@ #import +NS_ASSUME_NONNULL_BEGIN + +@protocol FLTIntegrationTestScreenshotDelegate; + @interface IntegrationTestIosTest : NSObject -- (BOOL)testIntegrationTest:(NSString **)testResult; +- (instancetype)initWithScreenshotDelegate:(nullable id)delegate NS_DESIGNATED_INITIALIZER; + +/** + * Initate dart tests and wait for results. @c testResult will be set to a string describing the results. + * + * @return @c YES if all tests succeeded. + */ +- (BOOL)testIntegrationTest:(NSString *_Nullable *_Nullable)testResult; @end #define INTEGRATION_TEST_IOS_RUNNER(__test_class) \ - @interface __test_class : XCTestCase \ + @interface __test_class : XCTestCase \ @end \ \ @implementation __test_class \ \ - -(void)testIntegrationTest { \ + - (void)testIntegrationTest { \ NSString *testResult; \ - IntegrationTestIosTest *integrationTestIosTest = [[IntegrationTestIosTest alloc] init]; \ + IntegrationTestIosTest *integrationTestIosTest = integrationTestIosTest = [[IntegrationTestIosTest alloc] initWithScreenshotDelegate:self]; \ BOOL testPass = [integrationTestIosTest testIntegrationTest:&testResult]; \ XCTAssertTrue(testPass, @"%@", testResult); \ } \ \ + - (void)didTakeScreenshot:(UIImage *)screenshot attachmentName:(NSString *)name { \ + XCTAttachment *attachment = [XCTAttachment attachmentWithImage:screenshot]; \ + attachment.lifetime = XCTAttachmentLifetimeKeepAlways; \ + if (name != nil) { \ + attachment.name = name; \ + } \ + [self addAttachment:attachment]; \ + } \ + \ @end + +NS_ASSUME_NONNULL_END diff --git a/packages/integration_test/ios/Classes/IntegrationTestIosTest.m b/packages/integration_test/ios/Classes/IntegrationTestIosTest.m index c989f8e053..6a54ed2c77 100644 --- a/packages/integration_test/ios/Classes/IntegrationTestIosTest.m +++ b/packages/integration_test/ios/Classes/IntegrationTestIosTest.m @@ -5,10 +5,26 @@ #import "IntegrationTestIosTest.h" #import "IntegrationTestPlugin.h" +@interface IntegrationTestIosTest() +@property (nonatomic) IntegrationTestPlugin *integrationTestPlugin; +@end + @implementation IntegrationTestIosTest +- (instancetype)initWithScreenshotDelegate:(id)delegate { + self = [super init]; + _integrationTestPlugin = [IntegrationTestPlugin instance]; + _integrationTestPlugin.screenshotDelegate = delegate; + return self; +} + +- (instancetype)init { + return [self initWithScreenshotDelegate:nil]; +} + - (BOOL)testIntegrationTest:(NSString **)testResult { - IntegrationTestPlugin *integrationTestPlugin = [IntegrationTestPlugin instance]; + IntegrationTestPlugin *integrationTestPlugin = self.integrationTestPlugin; + UIViewController *rootViewController = [[[[UIApplication sharedApplication] delegate] window] rootViewController]; if (![rootViewController isKindOfClass:[FlutterViewController class]]) { diff --git a/packages/integration_test/ios/Classes/IntegrationTestPlugin.h b/packages/integration_test/ios/Classes/IntegrationTestPlugin.h index d73246a6b9..9684835acf 100644 --- a/packages/integration_test/ios/Classes/IntegrationTestPlugin.h +++ b/packages/integration_test/ios/Classes/IntegrationTestPlugin.h @@ -6,14 +6,20 @@ NS_ASSUME_NONNULL_BEGIN +@protocol FLTIntegrationTestScreenshotDelegate + +/** This will be called when a dart integration test triggers a window screenshot with @c takeScreenshot. */ +- (void)didTakeScreenshot:(UIImage *)screenshot attachmentName:(nullable NSString *)name; + +@end + /** A Flutter plugin that's responsible for communicating the test results back * to iOS XCTest. */ @interface IntegrationTestPlugin : NSObject /** * Test results that are sent from Dart when integration test completes. Before the - * completion, it is - * @c nil. + * completion, it is @c nil. */ @property(nonatomic, readonly, nullable) NSDictionary *testResults; @@ -24,6 +30,8 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; +@property(weak, nonatomic) id screenshotDelegate; + @end NS_ASSUME_NONNULL_END diff --git a/packages/integration_test/ios/Classes/IntegrationTestPlugin.m b/packages/integration_test/ios/Classes/IntegrationTestPlugin.m index 8d8f8ae47f..82d263595e 100644 --- a/packages/integration_test/ios/Classes/IntegrationTestPlugin.m +++ b/packages/integration_test/ios/Classes/IntegrationTestPlugin.m @@ -2,10 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@import UIKit; + #import "IntegrationTestPlugin.h" static NSString *const kIntegrationTestPluginChannel = @"plugins.flutter.io/integration_test"; static NSString *const kMethodTestFinished = @"allTestsFinished"; +static NSString *const kMethodScreenshot = @"captureScreenshot"; +static NSString *const kMethodConvertSurfaceToImage = @"convertFlutterSurfaceToImage"; +static NSString *const kMethodRevertImage = @"revertFlutterImage"; @interface IntegrationTestPlugin () @@ -39,20 +44,55 @@ static NSString *const kMethodTestFinished = @"allTestsFinished"; - (void)setupChannels:(id)binaryMessenger { FlutterMethodChannel *channel = - [FlutterMethodChannel methodChannelWithName:kIntegrationTestPluginChannel - binaryMessenger:binaryMessenger]; + [FlutterMethodChannel methodChannelWithName:kIntegrationTestPluginChannel + binaryMessenger:binaryMessenger]; [channel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) { [self handleMethodCall:call result:result]; }]; } - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { - if ([kMethodTestFinished isEqual:call.method]) { + if ([call.method isEqualToString:kMethodTestFinished]) { self.testResults = call.arguments[@"results"]; result(nil); + } else if ([call.method isEqualToString:kMethodScreenshot]) { + // If running as a native Xcode test, attach to test. + UIImage *screenshot = [self capturePngScreenshot]; + NSString *name = call.arguments[@"name"]; + [self.screenshotDelegate didTakeScreenshot:screenshot attachmentName:name]; + + // Also pass back along the channel for the driver to handle. + NSData *pngData = UIImagePNGRepresentation(screenshot); + result([FlutterStandardTypedData typedDataWithBytes:pngData]); + } else if ([call.method isEqualToString:kMethodConvertSurfaceToImage] + || [call.method isEqualToString:kMethodRevertImage]) { + // Android only, no-op on iOS. + result(nil); } else { result(FlutterMethodNotImplemented); } } +- (UIImage *)capturePngScreenshot { + UIWindow *window = [UIApplication.sharedApplication.windows + filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"keyWindow = YES"]].firstObject; + CGRect screenshotBounds = window.bounds; + UIImage *image; + + if (@available(iOS 10, *)) { + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithBounds:screenshotBounds]; + + image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + [window drawViewHierarchyInRect:screenshotBounds afterScreenUpdates:YES]; + }]; + } else { + UIGraphicsBeginImageContextWithOptions(screenshotBounds.size, NO, UIScreen.mainScreen.scale); + [window drawViewHierarchyInRect:screenshotBounds afterScreenUpdates:YES]; + image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + } + + return image; +} + @end diff --git a/packages/integration_test/ios/integration_test.podspec b/packages/integration_test/ios/integration_test.podspec index 4f92e65966..fb24cb0b8f 100644 --- a/packages/integration_test/ios/integration_test.podspec +++ b/packages/integration_test/ios/integration_test.podspec @@ -18,6 +18,8 @@ LICENSE s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' + s.ios.framework = 'UIKit' + s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } end diff --git a/packages/integration_test/lib/_callback_io.dart b/packages/integration_test/lib/_callback_io.dart index 8717305107..4639f130f4 100644 --- a/packages/integration_test/lib/_callback_io.dart +++ b/packages/integration_test/lib/_callback_io.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io' show Platform; import 'dart:ui'; import 'package:flutter/services.dart'; @@ -60,37 +61,41 @@ class IOCallbackManager implements CallbackManager { // comes up in the future. For example: `WebCallbackManager.cleanup`. } - // Whether the Flutter surface uses an Image. - bool _usesFlutterImage = false; + // [convertFlutterSurfaceToImage] has been called and [takeScreenshot] is ready to capture the surface (Android only). + bool _isSurfaceRendered = false; @override Future convertFlutterSurfaceToImage() async { - assert(!_usesFlutterImage, 'Surface already converted to an image'); + if (!Platform.isAndroid) { + // No-op on other platforms. + return; + } + assert(!_isSurfaceRendered, 'Surface already converted to an image'); await integrationTestChannel.invokeMethod( 'convertFlutterSurfaceToImage', null, ); - _usesFlutterImage = true; + _isSurfaceRendered = true; addTearDown(() async { - assert(_usesFlutterImage, 'Surface is not an image'); + assert(_isSurfaceRendered, 'Surface is not an image'); await integrationTestChannel.invokeMethod( 'revertFlutterImage', null, ); - _usesFlutterImage = false; + _isSurfaceRendered = false; }); } @override Future> takeScreenshot(String screenshot) async { - if (!_usesFlutterImage) { + if (Platform.isAndroid && !_isSurfaceRendered) { throw StateError('Call convertFlutterSurfaceToImage() before taking a screenshot'); } integrationTestChannel.setMethodCallHandler(_onMethodChannelCall); final List? rawBytes = await integrationTestChannel.invokeMethod>( 'captureScreenshot', - null, + {'name': screenshot}, ); if (rawBytes == null) { throw StateError('Expected a list of bytes, but instead captureScreenshot returned null');