Add native iOS screenshots to integration_test (#84611)

This commit is contained in:
Jenn Magder 2021-08-06 12:45:04 -07:00 committed by GitHub
parent a694778531
commit 28dfb44559
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 124 additions and 50 deletions

View File

@ -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>

View File

@ -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**

View File

@ -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;

View File

@ -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

View File

@ -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]]) {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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');