| // 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:async'; | |
| import 'dart:convert'; | |
| import 'dart:ffi'; | |
| 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 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.template = 'plugin', | |
| this.cocoapodsTransitiveFlutterDependency = false, | |
| }); | |
| final String buildTarget; | |
| final List<String> options; | |
| final Map<String, String>? pluginCreateEnvironment; | |
| final Map<String, String>? appCreateEnvironment; | |
| final bool dartOnlyPlugin; | |
| final String template; | |
| final bool cocoapodsTransitiveFlutterDependency; | |
| 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 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(); | |
| } | |
| section('Test plugin'); | |
| if (runFlutterTest) { | |
| await plugin.runFlutterTest(); | |
| if (!dartOnlyPlugin) { | |
| await plugin.example.runNativeTests(buildTarget); | |
| if (buildTarget == 'ios') { | |
| var runResult = false; | |
| await testWithNewIOSSimulator('TestPluginExample', (String deviceId) async { | |
| runResult = await plugin.example.runApp(options: <String>['-d', deviceId]); | |
| }); | |
| if (!runResult) { | |
| return TaskResult.failure('flutter run either failed or had an engine error.'); | |
| } | |
| } | |
| } | |
| } | |
| section('Create Flutter app'); | |
| final _FlutterProject app = await _FlutterProject.create( | |
| tempDir, | |
| options, | |
| buildTarget, | |
| name: 'plugintestapp', | |
| template: 'app', | |
| environment: appCreateEnvironment, | |
| ); | |
| try { | |
| if (cocoapodsTransitiveFlutterDependency) { | |
| section('Disable Swift Package Manager'); | |
| await app.disableSwiftPackageManager(); | |
| } | |
| 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 (cocoapodsTransitiveFlutterDependency) { | |
| section('Test app with Flutter as a transitive CocoaPods dependency'); | |
| await app.addCocoapodsTransitiveFlutterDependency(); | |
| await app.build(buildTarget, validateNativeBuildProject: !dartOnlyPlugin); | |
| } | |
| if (runFlutterTest) { | |
| section('Test app'); | |
| await app.runFlutterTest(); | |
| } | |
| // Validate local engine handling. Currently only implemented for macOS. | |
| if (!dartOnlyPlugin) { | |
| section('Validate local engine configuration'); | |
| final String fakeEngineSourcePath = path.join(tempDir.path, 'engine'); | |
| await _testLocalEngineConfiguration(app, fakeEngineSourcePath); | |
| } | |
| } finally { | |
| await plugin.delete(); | |
| await app.delete(); | |
| } | |
| return TaskResult.success(null); | |
| } catch (e) { | |
| return TaskResult.failure(e.toString()); | |
| } finally { | |
| rmTree(tempDir); | |
| } | |
| } | |
| Future<void> _testLocalEngineConfiguration( | |
| _FlutterProject app, | |
| String fakeEngineSourcePath, | |
| ) async { | |
| // The tool requires that a directory that looks like an engine build | |
| // actually exists when passing --local-engine, so create a fake skeleton. | |
| final buildDir = Directory(path.join(fakeEngineSourcePath, 'out', 'foo')); | |
| buildDir.createSync(recursive: true); | |
| // Currently this test is only implemented for macOS; it can be extended to | |
| // others as needed. | |
| if (buildTarget == 'macos') { | |
| // When using a local engine, podhelper.rb will search for a "macos-" | |
| // directory within the FlutterMacOS.xcframework, so create a dummy one. | |
| Directory(path.join(buildDir.path, 'FlutterMacOS.xcframework/macos-arm64_x86_64')) | |
| .createSync(recursive: true); | |
| // Clean before regenerating the config to ensure that the pod steps run. | |
| await inDirectory(Directory(app.rootPath), () async { | |
| await evalFlutter('clean'); | |
| }); | |
| await app.build(buildTarget, configOnly: true, localEngine: buildDir); | |
| } | |
| } | |
| } | |
| 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> disableSwiftPackageManager() async { | |
| final File pubspec = pubspecFile; | |
| String content = await pubspec.readAsString(); | |
| content = content.replaceFirst( | |
| '# The following section is specific to Flutter packages.\n' | |
| 'flutter:\n', | |
| '# The following section is specific to Flutter packages.\n' | |
| 'flutter:\n' | |
| '\n' | |
| ' config:\n' | |
| ' enable-swift-package-manager: false\n', | |
| ); | |
| await pubspec.writeAsString(content, flush: true); | |
| } | |
| Future<void> addPlugin(String plugin, {String? pluginPath}) async { | |
| final File pubspec = pubspecFile; | |
| String content = await pubspec.readAsString(); | |
| final 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 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 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 platforms = <String>['android', 'ios', 'linux', 'macos', 'windows']; | |
| for (final platform in platforms) { | |
| final platformDir = Directory(path.join(rootPath, platform)); | |
| if (platformDir.existsSync()) { | |
| await platformDir.delete(recursive: 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, buildMode: 'debug'); | |
| switch (buildTarget) { | |
| case 'apk': | |
| if (await exec( | |
| path.join('.', 'gradlew'), | |
| <String>['testDebugUnitTest'], | |
| workingDirectory: path.join(rootPath, 'android'), | |
| canFail: true, | |
| ) != | |
| 0) { | |
| throw TaskResult.failure('Platform unit tests failed'); | |
| } | |
| case 'ios': | |
| String? simulatorDeviceId; | |
| try { | |
| await testWithNewIOSSimulator('TestNativeUnitTests', (String deviceId) async { | |
| simulatorDeviceId = deviceId; | |
| 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'); | |
| } | |
| }); | |
| } finally { | |
| await removeIOSSimulator(simulatorDeviceId); | |
| } | |
| case 'linux': | |
| if (await exec( | |
| path.join( | |
| rootPath, | |
| 'build', | |
| 'linux', | |
| 'x64', | |
| 'debug', | |
| 'plugins', | |
| 'plugintest', | |
| 'plugintest_test', | |
| ), | |
| <String>[], | |
| canFail: true, | |
| ) != | |
| 0) { | |
| throw TaskResult.failure('Platform unit tests failed'); | |
| } | |
| case 'macos': | |
| if (!await runXcodeTests( | |
| platformDirectory: path.join(rootPath, 'macos'), | |
| destination: 'platform=macOS', | |
| configuration: 'Debug', | |
| testName: 'native_plugin_unit_tests_macos', | |
| extraOptions: <String>['-parallel-testing-enabled', 'NO'], | |
| skipCodesign: true, | |
| )) { | |
| throw TaskResult.failure('Platform unit tests failed'); | |
| } | |
| case 'windows': | |
| final arch = Abi.current() == Abi.windowsX64 ? 'x64' : 'arm64'; | |
| if (await exec( | |
| path.join( | |
| rootPath, | |
| 'build', | |
| 'windows', | |
| arch, | |
| 'plugins', | |
| 'plugintest', | |
| 'Debug', | |
| 'plugintest_test.exe', | |
| ), | |
| <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 { | |
| final bool isDarwin = target == 'ios' || target == 'macos'; | |
| await inDirectory(directory, () async { | |
| await flutter( | |
| 'create', | |
| options: <String>[ | |
| '--template=$template', | |
| '--org', | |
| 'io.flutter.devicelab', | |
| if (template == 'app' && isDarwin) '--platforms=$target' else ...options, | |
| name, | |
| ], | |
| environment: environment, | |
| ); | |
| }); | |
| final project = _FlutterProject(directory, name); | |
| if (template == 'plugin' && isDarwin) { | |
| project._reduceDarwinPluginMinimumVersion(name, target); | |
| } | |
| return project; | |
| } | |
| /// Creates a Pod that uses a Flutter plugin as a dependency and therefore | |
| /// Flutter as a transitive dependency. | |
| Future<void> addCocoapodsTransitiveFlutterDependency() async { | |
| final String iosDirectoryPath = path.join(rootPath, 'ios'); | |
| final nativePod = File(path.join(iosDirectoryPath, 'NativePod', 'NativePod.podspec')); | |
| nativePod.createSync(recursive: true); | |
| nativePod.writeAsStringSync(''' | |
| Pod::Spec.new do |s| | |
| s.name = 'NativePod' | |
| s.version = '1.0.0' | |
| s.summary = 'A pod to test Flutter as a transitive dependency.' | |
| s.homepage = 'https://flutter.dev' | |
| s.license = { :type => 'BSD' } | |
| s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } | |
| s.source = { :path => '.' } | |
| s.source_files = "Classes", "Classes/**/*.{h,m}" | |
| s.dependency 'plugintest' | |
| s.ios.deployment_target = '12.0' | |
| s.osx.deployment_target = '10.14' | |
| end | |
| '''); | |
| final nativePodClass = File( | |
| path.join(iosDirectoryPath, 'NativePod', 'Classes', 'NativePodTest.m'), | |
| ); | |
| nativePodClass.createSync(recursive: true); | |
| nativePodClass.writeAsStringSync(''' | |
| #import <Flutter/Flutter.h> | |
| @interface NativePodTest : NSObject | |
| @end | |
| @implementation NativePodTest | |
| @end | |
| '''); | |
| final podfileFile = File(path.join(iosDirectoryPath, 'Podfile')); | |
| final List<String> podfileContents = podfileFile.readAsLinesSync(); | |
| final int index = podfileContents.indexWhere( | |
| (String line) => line.contains('flutter_install_all_ios_pods'), | |
| ); | |
| podfileContents.insert(index, "pod 'NativePod', :path => 'NativePod'"); | |
| podfileFile.writeAsStringSync(podfileContents.join('\n')); | |
| } | |
| // Make the platform version artificially low to test that the "deployment | |
| // version too low" warning is never emitted. | |
| void _reduceDarwinPluginMinimumVersion(String plugin, String target) { | |
| var podspec = File(path.join(rootPath, target, '$plugin.podspec')); | |
| if (!podspec.existsSync()) { | |
| // Fallback to darwin directory for shared darwin plugins | |
| podspec = File(path.join(rootPath, 'darwin', '$plugin.podspec')); | |
| } | |
| if (!podspec.existsSync()) { | |
| throw TaskResult.failure('podspec file missing at ${podspec.path}'); | |
| } | |
| String podspecContent = podspec.readAsStringSync(); | |
| final bool isMultiPlatform = podspecContent.contains('s.osx.deployment_target'); | |
| final String versionString; | |
| if (isMultiPlatform) { | |
| versionString = target == 'ios' | |
| ? "s.ios.deployment_target = '15.0'" | |
| : "s.osx.deployment_target = '12.0'"; | |
| } else { | |
| versionString = target == 'ios' ? "s.platform = :ios, '15.0'" : "s.platform = :osx, '10.11'"; | |
| } | |
| if (!podspecContent.contains(versionString)) { | |
| throw TaskResult.failure( | |
| 'Update this test to match plugin minimum $target deployment version', | |
| ); | |
| } | |
| // Add transitive dependency on AppAuth 1.6 targeting iOS 8 and macOS 10.9, which no longer builds in Xcode | |
| // to test the version is forced higher and builds. | |
| final String replacementContent; | |
| if (isMultiPlatform) { | |
| replacementContent = target == 'ios' | |
| ? "s.ios.deployment_target = '10.0'\n s.dependency 'AppAuth', '1.6.0'" | |
| : "s.osx.deployment_target = '10.8'\n s.dependency 'AppAuth', '1.6.0'"; | |
| } else { | |
| replacementContent = target == 'ios' | |
| ? "s.platform = :ios, '10.0'\ns.dependency 'AppAuth', '1.6.0'" | |
| : "s.platform = :osx, '10.8'\ns.dependency 'AppAuth', '1.6.0'"; | |
| } | |
| podspecContent = podspecContent.replaceFirst(versionString, replacementContent); | |
| podspec.writeAsStringSync(podspecContent, flush: true); | |
| } | |
| Future<void> build( | |
| String target, { | |
| bool validateNativeBuildProject = true, | |
| bool configOnly = false, | |
| String buildMode = 'release', | |
| Directory? localEngine, | |
| }) async { | |
| assert(const <String>{'debug', 'release', 'profile'}.contains(buildMode)); | |
| await inDirectory(Directory(rootPath), () async { | |
| final String buildOutput = await evalFlutter( | |
| 'build', | |
| options: <String>[ | |
| target, | |
| '-v', | |
| if (target == 'ios') '--no-codesign', | |
| if (configOnly) '--config-only', | |
| if (buildMode != 'release') '--$buildMode', | |
| if (localEngine != null) | |
| // The engine directory is of the form <fake-source-path>/out/<fakename>, | |
| // which has to be broken up into the component flags. | |
| ...<String>[ | |
| '--local-engine-src-path=${localEngine.parent.parent.path}', | |
| '--local-engine=${path.basename(localEngine.path)}', | |
| '--local-engine-host=${path.basename(localEngine.path)}', | |
| ], | |
| ], | |
| ); | |
| 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( | |
| 'is set to 10.0, but the range of supported deployment target versions', | |
| ) || | |
| buildOutput.contains( | |
| 'is set to 10.8, but the range of supported deployment target versions', | |
| )) { | |
| throw TaskResult.failure('Minimum plugin version warning present'); | |
| } | |
| if (validateNativeBuildProject) { | |
| final generatedSwiftManifest = File( | |
| path.join( | |
| rootPath, | |
| target, | |
| 'Flutter', | |
| 'ephemeral', | |
| 'Packages', | |
| 'FlutterGeneratedPluginSwiftPackage', | |
| 'Package.swift', | |
| ), | |
| ); | |
| final bool swiftPackageManagerEnabled = generatedSwiftManifest.existsSync(); | |
| if (!swiftPackageManagerEnabled) { | |
| final 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', | |
| ); | |
| } | |
| // Transitive dependency AppAuth targeting too-low 8.0 was not fixed. | |
| if (podsProjectContent.contains('IPHONEOS_DEPLOYMENT_TARGET = 8')) { | |
| throw TaskResult.failure( | |
| 'Transitive dependency build setting IPHONEOS_DEPLOYMENT_TARGET=8 not removed', | |
| ); | |
| } | |
| if (!podsProjectContent.contains( | |
| r'"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "$(inherited) i386";', | |
| )) { | |
| throw TaskResult.failure(r'EXCLUDED_ARCHS is not "$(inherited) i386"'); | |
| } | |
| } else if (target == 'macos') { | |
| // Same for macOS deployment target, but 10.8. | |
| // The plugintest target should not have MACOSX_DEPLOYMENT_TARGET set. | |
| if (podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.8')) { | |
| throw TaskResult.failure( | |
| 'Plugin build setting MACOSX_DEPLOYMENT_TARGET not removed', | |
| ); | |
| } | |
| // Transitive dependency AppAuth targeting too-low 10.9 was not fixed. | |
| if (podsProjectContent.contains('MACOSX_DEPLOYMENT_TARGET = 10.9')) { | |
| throw TaskResult.failure( | |
| 'Transitive dependency build setting MACOSX_DEPLOYMENT_TARGET=10.9 not removed', | |
| ); | |
| } | |
| } | |
| if (localEngine != null) { | |
| final localEngineSearchPath = RegExp( | |
| 'FRAMEWORK_SEARCH_PATHS\\s*=[^;]*${localEngine.path}', | |
| ); | |
| if (!localEngineSearchPath.hasMatch(podsProjectContent)) { | |
| throw TaskResult.failure( | |
| 'FRAMEWORK_SEARCH_PATHS does not contain the --local-engine path', | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| 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); | |
| } | |
| Future<bool> runApp({required List<String> options}) async { | |
| return inDirectory(Directory(rootPath), () async { | |
| final Process process = await startFlutter('run', options: options); | |
| final stdoutDone = Completer<void>(); | |
| final stderrDone = Completer<void>(); | |
| var engineError = false; | |
| void onStdout(String line) { | |
| print('stdout: $line'); | |
| if (line.contains('The Flutter DevTools debugger and profiler')) { | |
| process.stdin.writeln('R'); | |
| } | |
| if (line.contains('Restarted application')) { | |
| // Do a hot restart. | |
| process.stdin.writeln('q'); | |
| } | |
| if (line.contains('ERROR:')) { | |
| engineError = true; | |
| // Quit the app. This makes the 'flutter run' process exit. | |
| process.stdin.writeln('q'); | |
| } | |
| } | |
| process.stdout | |
| .transform<String>(utf8.decoder) | |
| .transform<String>(const LineSplitter()) | |
| .listen(onStdout, onDone: stdoutDone.complete); | |
| process.stderr | |
| .transform<String>(utf8.decoder) | |
| .transform<String>(const LineSplitter()) | |
| .listen((String line) => print('stderr: $line'), onDone: stderrDone.complete); | |
| await Future.wait<void>(<Future<void>>[stdoutDone.future, stderrDone.future]); | |
| if (engineError) { | |
| return false; | |
| } | |
| final int exitCode = await process.exitCode; | |
| return exitCode == 0; | |
| }); | |
| } | |
| } |