Add native iOS screenshots to integration_test (#84611)
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 0000000..18d9810
--- /dev/null
+++ b/dev/integration_tests/ui/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+<?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>
diff --git a/packages/integration_test/README.md b/packages/integration_test/README.md
index b25c357..0837112 100644
--- a/packages/integration_test/README.md
+++ b/packages/integration_test/README.md
@@ -100,9 +100,9 @@
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 @@
// 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 @@
```
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 ee47ce4..a519162 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 5a127da..333b0ec 100644
--- a/packages/integration_test/ios/Classes/IntegrationTestIosTest.h
+++ b/packages/integration_test/ios/Classes/IntegrationTestIosTest.h
@@ -4,23 +4,45 @@
#import <Foundation/Foundation.h>
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol FLTIntegrationTestScreenshotDelegate;
+
@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
#define INTEGRATION_TEST_IOS_RUNNER(__test_class) \
- @interface __test_class : XCTestCase \
+ @interface __test_class : XCTestCase<FLTIntegrationTestScreenshotDelegate> \
@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 c989f8e..6a54ed2 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<FLTIntegrationTestScreenshotDelegate>)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 d73246a..9684835 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 <FlutterPlugin>
/**
* 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<NSString *, NSString *> *testResults;
@@ -24,6 +30,8 @@
- (instancetype)init NS_UNAVAILABLE;
+@property(weak, nonatomic) id<FLTIntegrationTestScreenshotDelegate> 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 8d8f8ae..82d2635 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 @@
- (void)setupChannels:(id<FlutterBinaryMessenger>)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 4f92e65..fb24cb0 100644
--- a/packages/integration_test/ios/integration_test.podspec
+++ b/packages/integration_test/ios/integration_test.podspec
@@ -18,6 +18,8 @@
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 8717305..4639f13 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 @@
// 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<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>(
'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<void>(
'revertFlutterImage',
null,
);
- _usesFlutterImage = false;
+ _isSurfaceRendered = false;
});
}
@override
Future<Map<String, dynamic>> takeScreenshot(String screenshot) async {
- if (!_usesFlutterImage) {
+ if (Platform.isAndroid && !_isSurfaceRendered) {
throw StateError('Call convertFlutterSurfaceToImage() before taking a screenshot');
}
integrationTestChannel.setMethodCallHandler(_onMethodChannelCall);
final List<int>? rawBytes = await integrationTestChannel.invokeMethod<List<int>>(
'captureScreenshot',
- null,
+ <String, dynamic>{'name': screenshot},
);
if (rawBytes == null) {
throw StateError('Expected a list of bytes, but instead captureScreenshot returned null');