Unverified Commit 28dfb445 authored by Jenn Magder's avatar Jenn Magder Committed by GitHub

Add native iOS screenshots to integration_test (#84611)

parent a6947785
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
...@@ -100,9 +100,9 @@ flutter drive \ ...@@ -100,9 +100,9 @@ flutter drive \
You can use `integration_test` to take screenshots of the UI rendered on the mobile device or 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. 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** **integration_test/screenshot_test.dart**
...@@ -115,7 +115,7 @@ void main() { ...@@ -115,7 +115,7 @@ void main() {
// Build the app. // Build the app.
app.main(); app.main();
// This is required prior to taking the screenshot. // This is required prior to taking the screenshot (Android only).
await binding.convertFlutterSurfaceToImage(); await binding.convertFlutterSurfaceToImage();
// Trigger a frame. // Trigger a frame.
...@@ -126,7 +126,8 @@ void main() { ...@@ -126,7 +126,8 @@ void main() {
``` ```
You can use a driver script to pull in the screenshot from the device. 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** **test_driver/integration_test.dart**
......
...@@ -475,21 +475,11 @@ ...@@ -475,21 +475,11 @@
baseConfigurationReference = 09505407E99803EF7AA92DE7 /* Pods-RunnerTests.debug.xcconfig */; baseConfigurationReference = 09505407E99803EF7AA92DE7 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; 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; CODE_SIGN_STYLE = Automatic;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = RunnerTests/Info.plist; INFOPLIST_FILE = RunnerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 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_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner";
}; };
name = Debug; name = Debug;
...@@ -499,20 +489,11 @@ ...@@ -499,20 +489,11 @@
baseConfigurationReference = FCE3953801588FC13ED9E898 /* Pods-RunnerTests.release.xcconfig */; baseConfigurationReference = FCE3953801588FC13ED9E898 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; 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; CODE_SIGN_STYLE = Automatic;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = RunnerTests/Info.plist; INFOPLIST_FILE = RunnerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner";
}; };
name = Release; name = Release;
...@@ -522,20 +503,11 @@ ...@@ -522,20 +503,11 @@
baseConfigurationReference = 750225973AAB5D7832AFA60C /* Pods-RunnerTests.profile.xcconfig */; baseConfigurationReference = 750225973AAB5D7832AFA60C /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; 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; CODE_SIGN_STYLE = Automatic;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = RunnerTests/Info.plist; INFOPLIST_FILE = RunnerTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.example.instrumentationAdapterExample.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner";
}; };
name = Profile; name = Profile;
......
...@@ -4,23 +4,45 @@ ...@@ -4,23 +4,45 @@
#import <Foundation/Foundation.h> #import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@protocol FLTIntegrationTestScreenshotDelegate;
@interface IntegrationTestIosTest : NSObject @interface IntegrationTestIosTest : NSObject
- (BOOL)testIntegrationTest:(NSString **)testResult; - (instancetype)initWithScreenshotDelegate:(nullable id<FLTIntegrationTestScreenshotDelegate>)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 @end
#define INTEGRATION_TEST_IOS_RUNNER(__test_class) \ #define INTEGRATION_TEST_IOS_RUNNER(__test_class) \
@interface __test_class : XCTestCase \ @interface __test_class : XCTestCase<FLTIntegrationTestScreenshotDelegate> \
@end \ @end \
\ \
@implementation __test_class \ @implementation __test_class \
\ \
-(void)testIntegrationTest { \ - (void)testIntegrationTest { \
NSString *testResult; \ NSString *testResult; \
IntegrationTestIosTest *integrationTestIosTest = [[IntegrationTestIosTest alloc] init]; \ IntegrationTestIosTest *integrationTestIosTest = integrationTestIosTest = [[IntegrationTestIosTest alloc] initWithScreenshotDelegate:self]; \
BOOL testPass = [integrationTestIosTest testIntegrationTest:&testResult]; \ BOOL testPass = [integrationTestIosTest testIntegrationTest:&testResult]; \
XCTAssertTrue(testPass, @"%@", 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 @end
NS_ASSUME_NONNULL_END
...@@ -5,10 +5,26 @@ ...@@ -5,10 +5,26 @@
#import "IntegrationTestIosTest.h" #import "IntegrationTestIosTest.h"
#import "IntegrationTestPlugin.h" #import "IntegrationTestPlugin.h"
@interface IntegrationTestIosTest()
@property (nonatomic) IntegrationTestPlugin *integrationTestPlugin;
@end
@implementation IntegrationTestIosTest @implementation IntegrationTestIosTest
- (instancetype)initWithScreenshotDelegate:(id<FLTIntegrationTestScreenshotDelegate>)delegate {
self = [super init];
_integrationTestPlugin = [IntegrationTestPlugin instance];
_integrationTestPlugin.screenshotDelegate = delegate;
return self;
}
- (instancetype)init {
return [self initWithScreenshotDelegate:nil];
}
- (BOOL)testIntegrationTest:(NSString **)testResult { - (BOOL)testIntegrationTest:(NSString **)testResult {
IntegrationTestPlugin *integrationTestPlugin = [IntegrationTestPlugin instance]; IntegrationTestPlugin *integrationTestPlugin = self.integrationTestPlugin;
UIViewController *rootViewController = UIViewController *rootViewController =
[[[[UIApplication sharedApplication] delegate] window] rootViewController]; [[[[UIApplication sharedApplication] delegate] window] rootViewController];
if (![rootViewController isKindOfClass:[FlutterViewController class]]) { if (![rootViewController isKindOfClass:[FlutterViewController class]]) {
......
...@@ -6,14 +6,20 @@ ...@@ -6,14 +6,20 @@
NS_ASSUME_NONNULL_BEGIN 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 /** A Flutter plugin that's responsible for communicating the test results back
* to iOS XCTest. */ * to iOS XCTest. */
@interface IntegrationTestPlugin : NSObject <FlutterPlugin> @interface IntegrationTestPlugin : NSObject <FlutterPlugin>
/** /**
* Test results that are sent from Dart when integration test completes. Before the * Test results that are sent from Dart when integration test completes. Before the
* completion, it is * completion, it is @c nil.
* @c nil.
*/ */
@property(nonatomic, readonly, nullable) NSDictionary<NSString *, NSString *> *testResults; @property(nonatomic, readonly, nullable) NSDictionary<NSString *, NSString *> *testResults;
...@@ -24,6 +30,8 @@ NS_ASSUME_NONNULL_BEGIN ...@@ -24,6 +30,8 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)init NS_UNAVAILABLE; - (instancetype)init NS_UNAVAILABLE;
@property(weak, nonatomic) id<FLTIntegrationTestScreenshotDelegate> screenshotDelegate;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END
...@@ -2,10 +2,15 @@ ...@@ -2,10 +2,15 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
@import UIKit;
#import "IntegrationTestPlugin.h" #import "IntegrationTestPlugin.h"
static NSString *const kIntegrationTestPluginChannel = @"plugins.flutter.io/integration_test"; static NSString *const kIntegrationTestPluginChannel = @"plugins.flutter.io/integration_test";
static NSString *const kMethodTestFinished = @"allTestsFinished"; static NSString *const kMethodTestFinished = @"allTestsFinished";
static NSString *const kMethodScreenshot = @"captureScreenshot";
static NSString *const kMethodConvertSurfaceToImage = @"convertFlutterSurfaceToImage";
static NSString *const kMethodRevertImage = @"revertFlutterImage";
@interface IntegrationTestPlugin () @interface IntegrationTestPlugin ()
...@@ -39,20 +44,55 @@ static NSString *const kMethodTestFinished = @"allTestsFinished"; ...@@ -39,20 +44,55 @@ static NSString *const kMethodTestFinished = @"allTestsFinished";
- (void)setupChannels:(id<FlutterBinaryMessenger>)binaryMessenger { - (void)setupChannels:(id<FlutterBinaryMessenger>)binaryMessenger {
FlutterMethodChannel *channel = FlutterMethodChannel *channel =
[FlutterMethodChannel methodChannelWithName:kIntegrationTestPluginChannel [FlutterMethodChannel methodChannelWithName:kIntegrationTestPluginChannel
binaryMessenger:binaryMessenger]; binaryMessenger:binaryMessenger];
[channel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) { [channel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {
[self handleMethodCall:call result:result]; [self handleMethodCall:call result:result];
}]; }];
} }
- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result {
if ([kMethodTestFinished isEqual:call.method]) { if ([call.method isEqualToString:kMethodTestFinished]) {
self.testResults = call.arguments[@"results"]; self.testResults = call.arguments[@"results"];
result(nil); 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 { } else {
result(FlutterMethodNotImplemented); 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 @end
...@@ -18,6 +18,8 @@ LICENSE ...@@ -18,6 +18,8 @@ LICENSE
s.source_files = 'Classes/**/*' s.source_files = 'Classes/**/*'
s.public_header_files = 'Classes/**/*.h' s.public_header_files = 'Classes/**/*.h'
s.dependency 'Flutter' s.dependency 'Flutter'
s.ios.framework = 'UIKit'
s.platform = :ios, '8.0' s.platform = :ios, '8.0'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
end end
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:io' show Platform;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -60,37 +61,41 @@ class IOCallbackManager implements CallbackManager { ...@@ -60,37 +61,41 @@ class IOCallbackManager implements CallbackManager {
// comes up in the future. For example: `WebCallbackManager.cleanup`. // comes up in the future. For example: `WebCallbackManager.cleanup`.
} }
// Whether the Flutter surface uses an Image. // [convertFlutterSurfaceToImage] has been called and [takeScreenshot] is ready to capture the surface (Android only).
bool _usesFlutterImage = false; bool _isSurfaceRendered = false;
@override @override
Future<void> convertFlutterSurfaceToImage() async { Future<void> 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<void>( await integrationTestChannel.invokeMethod<void>(
'convertFlutterSurfaceToImage', 'convertFlutterSurfaceToImage',
null, null,
); );
_usesFlutterImage = true; _isSurfaceRendered = true;
addTearDown(() async { addTearDown(() async {
assert(_usesFlutterImage, 'Surface is not an image'); assert(_isSurfaceRendered, 'Surface is not an image');
await integrationTestChannel.invokeMethod<void>( await integrationTestChannel.invokeMethod<void>(
'revertFlutterImage', 'revertFlutterImage',
null, null,
); );
_usesFlutterImage = false; _isSurfaceRendered = false;
}); });
} }
@override @override
Future<Map<String, dynamic>> takeScreenshot(String screenshot) async { Future<Map<String, dynamic>> takeScreenshot(String screenshot) async {
if (!_usesFlutterImage) { if (Platform.isAndroid && !_isSurfaceRendered) {
throw StateError('Call convertFlutterSurfaceToImage() before taking a screenshot'); throw StateError('Call convertFlutterSurfaceToImage() before taking a screenshot');
} }
integrationTestChannel.setMethodCallHandler(_onMethodChannelCall); integrationTestChannel.setMethodCallHandler(_onMethodChannelCall);
final List<int>? rawBytes = await integrationTestChannel.invokeMethod<List<int>>( final List<int>? rawBytes = await integrationTestChannel.invokeMethod<List<int>>(
'captureScreenshot', 'captureScreenshot',
null, <String, dynamic>{'name': screenshot},
); );
if (rawBytes == null) { if (rawBytes == null) {
throw StateError('Expected a list of bytes, but instead captureScreenshot returned null'); throw StateError('Expected a list of bytes, but instead captureScreenshot returned null');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment