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 \
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**
......
......@@ -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;
......
......@@ -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
......@@ -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]]) {
......
......@@ -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 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)init NS_UNAVAILABLE;
@property(weak, nonatomic) id<FLTIntegrationTestScreenshotDelegate> screenshotDelegate;
@end
NS_ASSUME_NONNULL_END
......@@ -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<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
......@@ -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
......@@ -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<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');
......
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