blob: 21cbd824c5d05803737a9cc3fb4ac8b1c9796878 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
import 'package:path/path.dart' as path;
import '../framework/framework.dart';
import '../framework/ios.dart';
import '../framework/task_result.dart';
import '../framework/utils.dart';
/// Combines several TaskFunctions with trivial success value into one.
TaskFunction combine(List<TaskFunction> tasks) {
return () async {
for (final TaskFunction task in tasks) {
final TaskResult result = await task();
if (result.failed) {
return result;
}
}
return TaskResult.success(null);
};
}
/// Defines task that creates new Flutter project, adds a local and remote
/// plugin, and then builds the specified [buildTarget].
class PluginTest {
PluginTest(
this.buildTarget,
this.options, {
this.pluginCreateEnvironment,
this.appCreateEnvironment,
this.dartOnlyPlugin = false,
this.sharedDarwinSource = false,
this.template = 'plugin',
});
final String buildTarget;
final List<String> options;
final Map<String, String>? pluginCreateEnvironment;
final Map<String, String>? appCreateEnvironment;
final bool dartOnlyPlugin;
final bool sharedDarwinSource;
final String template;
Future<TaskResult> call() async {
final Directory tempDir =
Directory.systemTemp.createTempSync('flutter_devicelab_plugin_test.');
// FFI plugins do not have support for `flutter test`.
// `flutter test` does not do a native build.
// Supporting `flutter test` would require invoking a native build.
final bool runFlutterTest = template != 'plugin_ffi';
try {
section('Create plugin');
final _FlutterProject plugin = await _FlutterProject.create(
tempDir, options, buildTarget,
name: 'plugintest', template: template, environment: pluginCreateEnvironment);
if (dartOnlyPlugin) {
await plugin.convertDefaultPluginToDartPlugin();
}
if (sharedDarwinSource) {
await plugin.convertDefaultPluginToSharedDarwinPlugin();
}
section('Test plugin');
if (runFlutterTest) {
await plugin.runFlutterTest();
if (!dartOnlyPlugin) {
await plugin.example.runNativeTests(buildTarget);
}
}
section('Create Flutter app');
final _FlutterProject app = await _FlutterProject.create(tempDir, options, buildTarget,
name: 'plugintestapp', template: 'app', environment: appCreateEnvironment);
try {
section('Add plugins');
await app.addPlugin('plugintest',
pluginPath: path.join('..', 'plugintest'));
await app.addPlugin('path_provider');
section('Build app');
await app.build(buildTarget, validateNativeBuildProject: !dartOnlyPlugin);
if (runFlutterTest) {
section('Test app');
await app.runFlutterTest();
}
} finally {
await plugin.delete();
await app.delete();
}
return TaskResult.success(null);
} catch (e) {
return TaskResult.failure(e.toString());
} finally {
rmTree(tempDir);
}
}
}
class _FlutterProject {
_FlutterProject(this.parent, this.name);
final Directory parent;
final String name;
String get rootPath => path.join(parent.path, name);
File get pubspecFile => File(path.join(rootPath, 'pubspec.yaml'));
_FlutterProject get example {
return _FlutterProject(Directory(path.join(rootPath)), 'example');
}
Future<void> addPlugin(String plugin, {String? pluginPath}) async {
final File pubspec = pubspecFile;
String content = await pubspec.readAsString();
final String dependency =
pluginPath != null ? '$plugin:\n path: $pluginPath' : '$plugin:';
content = content.replaceFirst(
'\ndependencies:\n',
'\ndependencies:\n $dependency\n',
);
await pubspec.writeAsString(content, flush: true);
}
/// Converts a plugin created from the standard template to a Dart-only
/// plugin.
Future<void> convertDefaultPluginToDartPlugin() async {
final String dartPluginClass = 'DartClassFor$name';
// Convert the metadata.
final File pubspec = pubspecFile;
String content = await pubspec.readAsString();
content = content.replaceAll(
RegExp(r' pluginClass: .*?\n'),
' dartPluginClass: $dartPluginClass\n',
);
await pubspec.writeAsString(content, flush: true);
// Add the Dart registration hook that the build will generate a call to.
final File dartCode = File(path.join(rootPath, 'lib', '$name.dart'));
content = await dartCode.readAsString();
content = '''
$content
class $dartPluginClass {
static void registerWith() {}
}
''';
await dartCode.writeAsString(content, flush: true);
// Remove any native plugin code.
const List<String> platforms = <String>[
'android',
'ios',
'linux',
'macos',
'windows',
];
for (final String platform in platforms) {
final Directory platformDir = Directory(path.join(rootPath, platform));
if (platformDir.existsSync()) {
await platformDir.delete(recursive: true);
}
}
}
/// Converts an iOS/macOS plugin created from the standard template to a shared
/// darwin directory plugin.
Future<void> convertDefaultPluginToSharedDarwinPlugin() async {
// Convert the metadata.
final File pubspec = pubspecFile;
String pubspecContent = await pubspec.readAsString();
const String originalIOSKey = '\n ios:\n';
const String originalMacOSKey = '\n macos:\n';
if (!pubspecContent.contains(originalIOSKey) || !pubspecContent.contains(originalMacOSKey)) {
print(pubspecContent);
throw TaskResult.failure('Missing expected darwin platform plugin keys');
}
pubspecContent = pubspecContent.replaceAll(
originalIOSKey,
'$originalIOSKey sharedDarwinSource: true\n'
);
pubspecContent = pubspecContent.replaceAll(
originalMacOSKey,
'$originalMacOSKey sharedDarwinSource: true\n'
);
await pubspec.writeAsString(pubspecContent, flush: true);
// Copy ios to darwin, and delete macos.
final Directory iosDir = Directory(path.join(rootPath, 'ios'));
final Directory darwinDir = Directory(path.join(rootPath, 'darwin'));
recursiveCopy(iosDir, darwinDir);
await iosDir.delete(recursive: true);
await Directory(path.join(rootPath, 'macos')).delete(recursive: true);
final File podspec = File(path.join(darwinDir.path, '$name.podspec'));
String podspecContent = await podspec.readAsString();
if (!podspecContent.contains('s.platform =')) {
print(podspecContent);
throw TaskResult.failure('Missing expected podspec platform');
}
// Remove "s.platform = :ios" to work on all platforms, including macOS.
podspecContent = podspecContent.replaceFirst(RegExp(r'.*s\.platform.*'), '');
podspecContent = podspecContent.replaceFirst("s.dependency 'Flutter'", "s.ios.dependency 'Flutter'\ns.osx.dependency 'FlutterMacOS'");
await podspec.writeAsString(podspecContent, flush: true);
// Make PlugintestPlugin.swift compile on iOS and macOS with target conditionals.
final String pluginClass = '${name[0].toUpperCase()}${name.substring(1)}Plugin';
print('pluginClass: $pluginClass');
final File pluginRegister = File(path.join(darwinDir.path, 'Classes', '$pluginClass.swift'));
final String pluginRegisterContent = '''
#if os(macOS)
import FlutterMacOS
#elseif os(iOS)
import Flutter
#endif
public class $pluginClass: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
#if os(macOS)
let channel = FlutterMethodChannel(name: "$name", binaryMessenger: registrar.messenger)
#elseif os(iOS)
let channel = FlutterMethodChannel(name: "$name", binaryMessenger: registrar.messenger())
#endif
let instance = $pluginClass()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
#if os(macOS)
result("macOS " + ProcessInfo.processInfo.operatingSystemVersionString)
#elseif os(iOS)
result("iOS " + UIDevice.current.systemVersion)
#endif
}
}
''';
await pluginRegister.writeAsString(pluginRegisterContent, flush: true);
}
Future<void> runFlutterTest() async {
await inDirectory(Directory(rootPath), () async {
await flutter('test');
});
}
Future<void> runNativeTests(String buildTarget) async {
// Native unit tests rely on building the app first to generate necessary
// build files.
await build(buildTarget, validateNativeBuildProject: false);
if (buildTarget == 'ios') {
await testWithNewIOSSimulator('TestNativeUnitTests', (String deviceId) async {
if (!await runXcodeTests(
platformDirectory: path.join(rootPath, 'ios'),
destination: 'id=$deviceId',
configuration: 'Debug',
testName: 'native_plugin_unit_tests_ios',
skipCodesign: true,
)) {
throw TaskResult.failure('Platform unit tests failed');
}
});
} else if (buildTarget == 'macos') {
if (!await runXcodeTests(
platformDirectory: path.join(rootPath, 'macos'),
destination: 'platform=macOS',
configuration: 'Debug',
testName: 'native_plugin_unit_tests_macos',
skipCodesign: true,
)) {
throw TaskResult.failure('Platform unit tests failed');
}
} else if (buildTarget == 'windows') {
if (await exec(
path.join(rootPath, 'build', 'windows', 'plugins', 'plugintest', 'Release', 'plugintest_plugin_test'),
<String>[],
canFail: true,
) != 0) {
throw TaskResult.failure('Platform unit tests failed');
}
}
}
static Future<_FlutterProject> create(
Directory directory,
List<String> options,
String target,
{
required String name,
required String template,
Map<String, String>? environment,
}) async {
await inDirectory(directory, () async {
await flutter(
'create',
options: <String>[
'--template=$template',
'--org',
'io.flutter.devicelab',
...options,
name,
],
environment: environment,
);
});
final _FlutterProject project = _FlutterProject(directory, name);
if (template == 'plugin' && (target == 'ios' || target == 'macos')) {
project._reduceDarwinPluginMinimumVersion(name, target);
}
return project;
}
// Make the platform version artificially low to test that the "deployment
// version too low" warning is never emitted.
void _reduceDarwinPluginMinimumVersion(String plugin, String target) {
final File podspec = File(path.join(rootPath, target, '$plugin.podspec'));
if (!podspec.existsSync()) {
throw TaskResult.failure('podspec file missing at ${podspec.path}');
}
final String versionString = target == 'ios'
? "s.platform = :ios, '9.0'"
: "s.platform = :osx, '10.11'";
String podspecContent = podspec.readAsStringSync();
if (!podspecContent.contains(versionString)) {
throw TaskResult.failure('Update this test to match plugin minimum $target deployment version');
}
podspecContent = podspecContent.replaceFirst(
versionString,
target == 'ios'
? "s.platform = :ios, '10.0'"
: "s.platform = :osx, '10.8'"
);
podspec.writeAsStringSync(podspecContent, flush: true);
}
Future<void> build(String target, {bool validateNativeBuildProject = true}) async {
await inDirectory(Directory(rootPath), () async {
final String buildOutput = await evalFlutter('build', options: <String>[
target,
'-v',
if (target == 'ios')
'--no-codesign',
]);
if (target == 'ios' || target == 'macos') {
// This warning is confusing and shouldn't be emitted. Plugins often support lower versions than the
// Flutter app, but as long as they support the minimum it will work.
// warning: The iOS deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 8.0,
// but the range of supported deployment target versions is 9.0 to 14.0.99.
//
// (or "The macOS deployment target 'MACOSX_DEPLOYMENT_TARGET'"...)
if (buildOutput.contains('the range of supported deployment target versions')) {
throw TaskResult.failure('Minimum plugin version warning present');
}
if (validateNativeBuildProject) {
final File podsProject = File(path.join(rootPath, target, 'Pods', 'Pods.xcodeproj', 'project.pbxproj'));
if (!podsProject.existsSync()) {
throw TaskResult.failure('Xcode Pods project file missing at ${podsProject.path}');
}
final String podsProjectContent = podsProject.readAsStringSync();
if (target == 'ios') {
// Plugins with versions lower than the app version should not have IPHONEOS_DEPLOYMENT_TARGET set.
// The plugintest plugin target should not have IPHONEOS_DEPLOYMENT_TARGET set since it has been lowered
// in _reduceDarwinPluginMinimumVersion to 10, which is below the target version of 11.
if (podsProjectContent.contains('IPHONEOS_DEPLOYMENT_TARGET = 10')) {
throw TaskResult.failure('Plugin build setting IPHONEOS_DEPLOYMENT_TARGET not removed');
}
if (!podsProjectContent.contains(r'"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited) i386";')) {
throw TaskResult.failure(r'EXCLUDED_ARCHS is not "$(inherited) i386"');
}
}
// Same for macOS deployment target, but 10.8.
// The plugintest target should not have MACOSX_DEPLOYMENT_TARGET set.
if (target == 'macos' && podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.8')) {
throw TaskResult.failure('Plugin build setting MACOSX_DEPLOYMENT_TARGET not removed');
}
}
}
});
}
Future<void> delete() async {
if (Platform.isWindows) {
// A running Gradle daemon might prevent us from deleting the project
// folder on Windows.
final String wrapperPath =
path.absolute(path.join(rootPath, 'android', 'gradlew.bat'));
if (File(wrapperPath).existsSync()) {
await exec(wrapperPath, <String>['--stop'], canFail: true);
}
// TODO(ianh): Investigating if flakiness is timing dependent.
await Future<void>.delayed(const Duration(seconds: 10));
}
rmTree(parent);
}
}