// 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:flutter_devicelab/framework/framework.dart'; import 'package:flutter_devicelab/framework/ios.dart'; import 'package:flutter_devicelab/framework/task_result.dart'; import 'package:flutter_devicelab/framework/utils.dart'; import 'package:path/path.dart' as path; /// Tests that iOS and macOS .xcframeworks can be built. Future main() async { await task(() async { section('Create module project'); final Directory tempDir = Directory.systemTemp.createTempSync('flutter_module_test.'); try { await inDirectory(tempDir, () async { section('Test iOS module template'); final moduleProjectDir = Directory(path.join(tempDir.path, 'hello_module')); await flutter( 'create', options: [ '--org', 'io.flutter.devicelab', '--template', 'module', 'hello_module', ], ); await _addPlugin(moduleProjectDir); await _testBuildIosFramework(moduleProjectDir, isModule: true); section('Test app template'); final projectDir = Directory(path.join(tempDir.path, 'hello_project')); await flutter( 'create', options: ['--org', 'io.flutter.devicelab', 'hello_project'], ); await _addPlugin(projectDir); await _testBuildIosFramework(projectDir); await _testBuildMacOSFramework(projectDir); }); return TaskResult.success(null); } on TaskResult catch (taskResult) { return taskResult; } catch (e, stackTrace) { print('Task exception stack trace:\n$stackTrace'); return TaskResult.failure(e.toString()); } finally { rmTree(tempDir); } }); } Future _addPlugin(Directory projectDir) async { section('Add plugins'); final pubspec = File(path.join(projectDir.path, 'pubspec.yaml')); String content = pubspec.readAsStringSync(); content = content.replaceFirst( '\ndependencies:\n', '\ndependencies:\n package_info: 2.0.2\n connectivity: 3.0.6\n', ); pubspec.writeAsStringSync(content, flush: true); await inDirectory(projectDir, () async { await flutter('packages', options: ['get']); }); } Future _testBuildIosFramework(Directory projectDir, {bool isModule = false}) async { // This builds all build modes' frameworks by default section('Build iOS app'); const outputDirectoryName = 'flutter-frameworks'; await inDirectory(projectDir, () async { await flutter( 'build', options: [ 'ios-framework', '--output=$outputDirectoryName', '--obfuscate', '--split-debug-info=symbols', '--codesign-identity', '-', ], ); }); final String outputPath = path.join(projectDir.path, outputDirectoryName); checkFileExists( path.join( outputPath, 'Debug', 'Flutter.xcframework', 'ios-arm64', 'Flutter.framework', 'Flutter', ), ); final String debugAppFrameworkPath = path.join( outputPath, 'Debug', 'App.xcframework', 'ios-arm64', 'App.framework', 'App', ); checkFileExists(debugAppFrameworkPath); checkFileExists( path.join(outputPath, 'Debug', 'App.xcframework', 'ios-arm64', 'App.framework', 'Info.plist'), ); section('Check debug build has Dart snapshot as asset'); checkFileExists( path.join( outputPath, 'Debug', 'App.xcframework', 'ios-arm64_x86_64-simulator', 'App.framework', 'flutter_assets', 'vm_snapshot_data', ), ); section('Check obfuscation symbols'); checkFileExists(path.join(projectDir.path, 'symbols', 'app.ios-arm64.symbols')); section('Check debug build has no Dart AOT'); final String aotSymbols = await dumpSymbolTable(debugAppFrameworkPath); if (aotSymbols.contains('architecture') || aotSymbols.contains('_kDartVmSnapshot')) { throw TaskResult.failure('Debug App.framework contains AOT'); } section('Check profile, release builds has Dart AOT dylib'); for (final mode in ['Profile', 'Release']) { final String appFrameworkPath = path.join( outputPath, mode, 'App.xcframework', 'ios-arm64', 'App.framework', 'App', ); await _checkDylib(appFrameworkPath); final String aotSymbols = await dumpSymbolTable(appFrameworkPath); if (!aotSymbols.contains('_kDartVmSnapshot')) { throw TaskResult.failure('$mode App.framework missing Dart AOT'); } checkFileNotExists( path.join( outputPath, mode, 'App.xcframework', 'ios-arm64', 'App.framework', 'flutter_assets', 'vm_snapshot_data', ), ); final String dsymPath = path.join(outputPath, mode, 'App.xcframework', 'ios-arm64', 'dSYMs'); checkDirectoryExists(dsymPath); final String appFrameworkDsymPath = path.join(dsymPath, 'App.framework.dSYM'); checkDirectoryExists(appFrameworkDsymPath); if (Directory(dsymPath).listSync().whereType().length != 1) { throw TaskResult.failure('App.framework/dSYMs should ONLY contain App.xcframework.dSYM'); } await _checkDsym(path.join(appFrameworkDsymPath, 'Contents', 'Resources', 'DWARF', 'App')); checkFileExists( path.join( outputPath, mode, 'App.xcframework', 'ios-arm64_x86_64-simulator', 'App.framework', 'App', ), ); checkFileExists( path.join( outputPath, mode, 'App.xcframework', 'ios-arm64_x86_64-simulator', 'App.framework', 'Info.plist', ), ); } section("Check all modes' engine dylib"); for (final mode in ['Debug', 'Profile', 'Release']) { checkFileExists( path.join( outputPath, mode, 'Flutter.xcframework', 'ios-arm64', 'Flutter.framework', 'Flutter', ), ); checkFileExists( path.join( outputPath, mode, 'Flutter.xcframework', 'ios-arm64_x86_64-simulator', 'Flutter.framework', 'Flutter', ), ); checkFileExists( path.join( outputPath, mode, 'Flutter.xcframework', 'ios-arm64_x86_64-simulator', 'Flutter.framework', 'Headers', 'Flutter.h', ), ); } section('Check all modes have plugins'); for (final mode in ['Debug', 'Profile', 'Release']) { final String pluginFrameworkPath = path.join( outputPath, mode, 'connectivity.xcframework', 'ios-arm64', 'connectivity.framework', 'connectivity', ); await _checkDylib(pluginFrameworkPath); if (!await _linksOnFlutter(pluginFrameworkPath)) { throw TaskResult.failure('$pluginFrameworkPath does not link on Flutter'); } final String transitiveDependencyFrameworkPath = path.join( outputPath, mode, 'Reachability.xcframework', 'ios-arm64', 'Reachability.framework', 'Reachability', ); if (!exists(File(transitiveDependencyFrameworkPath))) { throw TaskResult.failure('Expected debug Flutter engine artifact binary to exist'); } if (await _linksOnFlutter(transitiveDependencyFrameworkPath)) { throw TaskResult.failure( 'Transitive dependency $transitiveDependencyFrameworkPath unexpectedly links on Flutter', ); } checkFileExists( path.join( outputPath, mode, 'connectivity.xcframework', 'ios-arm64', 'connectivity.framework', 'Headers', 'FLTConnectivityPlugin.h', ), ); if (mode != 'Debug') { checkDirectoryExists( path.join( outputPath, mode, 'connectivity.xcframework', 'ios-arm64', 'dSYMs', 'connectivity.framework.dSYM', ), ); } final String simulatorFrameworkPath = path.join( outputPath, mode, 'connectivity.xcframework', 'ios-arm64_x86_64-simulator', 'connectivity.framework', 'connectivity', ); final String simulatorFrameworkHeaderPath = path.join( outputPath, mode, 'connectivity.xcframework', 'ios-arm64_x86_64-simulator', 'connectivity.framework', 'Headers', 'FLTConnectivityPlugin.h', ); checkFileExists(simulatorFrameworkPath); checkFileExists(simulatorFrameworkHeaderPath); } section('Check all modes have generated plugin registrant'); for (final mode in ['Debug', 'Profile', 'Release']) { if (!isModule) { continue; } final String registrantFrameworkPath = path.join( outputPath, mode, 'FlutterPluginRegistrant.xcframework', 'ios-arm64', 'FlutterPluginRegistrant.framework', 'FlutterPluginRegistrant', ); await _checkStatic(registrantFrameworkPath); checkFileExists( path.join( outputPath, mode, 'FlutterPluginRegistrant.xcframework', 'ios-arm64', 'FlutterPluginRegistrant.framework', 'Headers', 'GeneratedPluginRegistrant.h', ), ); final String simulatorHeaderPath = path.join( outputPath, mode, 'FlutterPluginRegistrant.xcframework', 'ios-arm64_x86_64-simulator', 'FlutterPluginRegistrant.framework', 'Headers', 'GeneratedPluginRegistrant.h', ); checkFileExists(simulatorHeaderPath); } section('Check all XCFrameworks are codesigned'); for (final mode in ['Debug', 'Profile', 'Release']) { final String modePath = path.join(outputPath, mode); await _checkCodeSignature(path.join(modePath, 'Flutter.xcframework')); await _checkCodeSignature(path.join(modePath, 'App.xcframework')); await _checkCodeSignature(path.join(modePath, 'connectivity.xcframework')); await _checkCodeSignature(path.join(modePath, 'Reachability.xcframework')); if (isModule) { await _checkCodeSignature(path.join(modePath, 'FlutterPluginRegistrant.xcframework')); } } // This builds all build modes' frameworks by default section('Build podspec and static plugins'); const cocoapodsOutputDirectoryName = 'flutter-frameworks-cocoapods'; await inDirectory(projectDir, () async { await flutter( 'build', options: [ 'ios-framework', '--cocoapods', '--force', // Allow podspec creation on master. '--output=$cocoapodsOutputDirectoryName', '--static', '--no-codesign', ], ); }); final String cocoapodsOutputPath = path.join(projectDir.path, cocoapodsOutputDirectoryName); for (final mode in ['Debug', 'Profile', 'Release']) { checkFileExists(path.join(cocoapodsOutputPath, mode, 'Flutter.podspec')); await _checkDylib( path.join(cocoapodsOutputPath, mode, 'App.xcframework', 'ios-arm64', 'App.framework', 'App'), ); if (mode != 'Debug') { final String appFrameworkDsymPath = path.join( cocoapodsOutputPath, mode, 'App.xcframework', 'ios-arm64', 'dSYMs', 'App.framework.dSYM', ); checkDirectoryExists(appFrameworkDsymPath); await _checkDsym(path.join(appFrameworkDsymPath, 'Contents', 'Resources', 'DWARF', 'App')); } if (Directory( path.join(cocoapodsOutputPath, mode, 'FlutterPluginRegistrant.xcframework'), ).existsSync() != isModule) { throw TaskResult.failure('Unexpected FlutterPluginRegistrant.xcframework.'); } await _checkStatic( path.join( cocoapodsOutputPath, mode, 'package_info.xcframework', 'ios-arm64', 'package_info.framework', 'package_info', ), ); await _checkStatic( path.join( cocoapodsOutputPath, mode, 'connectivity.xcframework', 'ios-arm64', 'connectivity.framework', 'connectivity', ), ); checkDirectoryExists(path.join(cocoapodsOutputPath, mode, 'Reachability.xcframework')); } if (File(path.join(outputPath, 'GeneratedPluginRegistrant.h')).existsSync() == isModule) { throw TaskResult.failure('Unexpected GeneratedPluginRegistrant.h.'); } if (File(path.join(outputPath, 'GeneratedPluginRegistrant.m')).existsSync() == isModule) { throw TaskResult.failure('Unexpected GeneratedPluginRegistrant.m.'); } section('Build frameworks without plugins'); await _testBuildFrameworksWithoutPlugins(projectDir, platform: 'ios'); section('check --static cannot be used with the --no-plugins flag'); await _testStaticAndNoPlugins(projectDir); } Future _testBuildMacOSFramework(Directory projectDir) async { // This builds all build modes' frameworks by default section('Build macOS frameworks'); const outputDirectoryName = 'flutter-frameworks'; await inDirectory(projectDir, () async { await flutter( 'build', options: [ 'macos-framework', '--output=$outputDirectoryName', '--obfuscate', '--split-debug-info=symbols', '--codesign-identity', '-', ], ); }); final String outputPath = path.join(projectDir.path, outputDirectoryName); final String flutterFramework = path.join( outputPath, 'Debug', 'FlutterMacOS.xcframework', 'macos-arm64_x86_64', 'FlutterMacOS.framework', ); checkDirectoryExists(flutterFramework); final String debugAppFrameworkPath = path.join( outputPath, 'Debug', 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'App', ); checkSymlinkExists(debugAppFrameworkPath); checkFileExists( path.join( outputPath, 'Debug', 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'Resources', 'Info.plist', ), ); section('Check debug build has Dart snapshot as asset'); checkFileExists( path.join( outputPath, 'Debug', 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'Resources', 'flutter_assets', 'vm_snapshot_data', ), ); section('Check obfuscation symbols'); checkFileExists(path.join(projectDir.path, 'symbols', 'app.darwin-arm64.symbols')); checkFileExists(path.join(projectDir.path, 'symbols', 'app.darwin-x86_64.symbols')); section('Check debug build has no Dart AOT'); final String aotSymbols = await dumpSymbolTable(debugAppFrameworkPath); if (aotSymbols.contains('architecture') || aotSymbols.contains('_kDartVmSnapshot')) { throw TaskResult.failure('Debug App.framework contains AOT'); } section('Check profile, release builds has Dart AOT dylib'); for (final mode in ['Profile', 'Release']) { final String appFrameworkPath = path.join( outputPath, mode, 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'App', ); await _checkDylib(appFrameworkPath); final String aotSymbols = await dumpSymbolTable(appFrameworkPath); if (!aotSymbols.contains('_kDartVmSnapshot')) { throw TaskResult.failure('$mode App.framework missing Dart AOT'); } checkFileNotExists( path.join( outputPath, mode, 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'Resources', 'flutter_assets', 'vm_snapshot_data', ), ); checkFileExists( path.join( outputPath, mode, 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'Resources', 'Info.plist', ), ); final String appFrameworkDsymPath = path.join( outputPath, mode, 'App.xcframework', 'macos-arm64_x86_64', 'dSYMs', 'App.framework.dSYM', ); checkDirectoryExists(appFrameworkDsymPath); await _checkDsym(path.join(appFrameworkDsymPath, 'Contents', 'Resources', 'DWARF', 'App')); } section("Check all modes' engine dylib"); for (final mode in ['Debug', 'Profile', 'Release']) { final String engineBinary = path.join( outputPath, mode, 'FlutterMacOS.xcframework', 'macos-arm64_x86_64', 'FlutterMacOS.framework', 'FlutterMacOS', ); checkSymlinkExists(engineBinary); checkFileExists( path.join( outputPath, mode, 'FlutterMacOS.xcframework', 'macos-arm64_x86_64', 'FlutterMacOS.framework', 'Headers', 'FlutterMacOS.h', ), ); } section('Check all modes have plugins'); for (final mode in ['Debug', 'Profile', 'Release']) { final String pluginFrameworkPath = path.join( outputPath, mode, 'connectivity_macos.xcframework', 'macos-arm64_x86_64', 'connectivity_macos.framework', 'connectivity_macos', ); await _checkDylib(pluginFrameworkPath); if (!await _linksOnFlutterMacOS(pluginFrameworkPath)) { throw TaskResult.failure('$pluginFrameworkPath does not link on Flutter'); } final String transitiveDependencyFrameworkPath = path.join( outputPath, mode, 'Reachability.xcframework', 'macos-arm64_x86_64', 'Reachability.framework', 'Reachability', ); if (await _linksOnFlutterMacOS(transitiveDependencyFrameworkPath)) { throw TaskResult.failure( 'Transitive dependency $transitiveDependencyFrameworkPath unexpectedly links on Flutter', ); } checkFileExists( path.join( outputPath, mode, 'connectivity_macos.xcframework', 'macos-arm64_x86_64', 'connectivity_macos.framework', 'Headers', 'connectivity_macos-Swift.h', ), ); checkDirectoryExists( path.join( outputPath, mode, 'connectivity_macos.xcframework', 'macos-arm64_x86_64', 'connectivity_macos.framework', 'Modules', 'connectivity_macos.swiftmodule', ), ); if (mode != 'Debug') { checkDirectoryExists( path.join( outputPath, mode, 'connectivity_macos.xcframework', 'macos-arm64_x86_64', 'dSYMs', 'connectivity_macos.framework.dSYM', ), ); } checkSymlinkExists( path.join( outputPath, mode, 'connectivity_macos.xcframework', 'macos-arm64_x86_64', 'connectivity_macos.framework', 'connectivity_macos', ), ); } section('Check all XCFrameworks are codesigned'); for (final mode in ['Debug', 'Profile', 'Release']) { final String modePath = path.join(outputPath, mode); await _checkCodeSignature(path.join(modePath, 'FlutterMacOS.xcframework')); await _checkCodeSignature(path.join(modePath, 'App.xcframework')); await _checkCodeSignature(path.join(modePath, 'connectivity_macos.xcframework')); await _checkCodeSignature(path.join(modePath, 'Reachability.xcframework')); } // This builds all build modes' frameworks by default section('Build podspec and static plugins'); const cocoapodsOutputDirectoryName = 'flutter-frameworks-cocoapods'; await inDirectory(projectDir, () async { await flutter( 'build', options: [ 'macos-framework', '--cocoapods', '--force', // Allow podspec creation on master. '--output=$cocoapodsOutputDirectoryName', '--static', '--no-codesign', ], ); }); final String cocoapodsOutputPath = path.join(projectDir.path, cocoapodsOutputDirectoryName); for (final mode in ['Debug', 'Profile', 'Release']) { checkFileExists(path.join(cocoapodsOutputPath, mode, 'FlutterMacOS.podspec')); await _checkDylib( path.join( cocoapodsOutputPath, mode, 'App.xcframework', 'macos-arm64_x86_64', 'App.framework', 'App', ), ); if (mode != 'Debug') { final String appFrameworkDsymPath = path.join( cocoapodsOutputPath, mode, 'App.xcframework', 'macos-arm64_x86_64', 'dSYMs', 'App.framework.dSYM', ); checkDirectoryExists(appFrameworkDsymPath); await _checkDsym(path.join(appFrameworkDsymPath, 'Contents', 'Resources', 'DWARF', 'App')); } await _checkStatic( path.join( cocoapodsOutputPath, mode, 'package_info.xcframework', 'macos-arm64_x86_64', 'package_info.framework', 'package_info', ), ); await _checkStatic( path.join( cocoapodsOutputPath, mode, 'connectivity_macos.xcframework', 'macos-arm64_x86_64', 'connectivity_macos.framework', 'connectivity_macos', ), ); checkDirectoryExists(path.join(cocoapodsOutputPath, mode, 'Reachability.xcframework')); } checkFileExists(path.join(outputPath, 'GeneratedPluginRegistrant.swift')); section('Validate embed FlutterMacOS.framework with CocoaPods'); final podspec = File(path.join(cocoapodsOutputPath, 'Debug', 'FlutterMacOS.podspec')); podspec.writeAsStringSync(podspec.readAsStringSync().replaceFirst('null.null.0', '0.0.0')); final macosDirectory = Directory(path.join(projectDir.path, 'macos')); final podfile = File(path.join(macosDirectory.path, 'Podfile')); final String currentPodfile = podfile.readAsStringSync(); // Temporarily test Add-to-App Cocoapods podspec for framework podfile.writeAsStringSync(''' target 'Runner' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! pod 'FlutterMacOS', :podspec => '${podspec.path}' end '''); await inDirectory(macosDirectory, () async { await eval('pod', ['install']); }); // Change podfile back to original podfile.writeAsStringSync(currentPodfile); await inDirectory(macosDirectory, () async { await eval('pod', ['install']); }); section('Build frameworks without plugins'); await _testBuildFrameworksWithoutPlugins(projectDir, platform: 'macos'); } Future _testBuildFrameworksWithoutPlugins( Directory projectDir, { required String platform, }) async { const noPluginsOutputDir = 'flutter-frameworks-no-plugins'; await inDirectory(projectDir, () async { await flutter( 'build', options: [ '$platform-framework', '--cocoapods', '--force', // Allow podspec creation on master. '--output=$noPluginsOutputDir', '--no-plugins', '--no-codesign', ], ); }); final String noPluginsOutputPath = path.join(projectDir.path, noPluginsOutputDir); for (final mode in ['Debug', 'Profile', 'Release']) { checkFileExists( path.join(noPluginsOutputPath, mode, 'Flutter${platform == 'macos' ? 'MacOS' : ''}.podspec'), ); checkDirectoryExists(path.join(noPluginsOutputPath, mode, 'App.xcframework')); checkDirectoryNotExists(path.join(noPluginsOutputPath, mode, 'package_info.xcframework')); checkDirectoryNotExists(path.join(noPluginsOutputPath, mode, 'connectivity.xcframework')); checkDirectoryNotExists(path.join(noPluginsOutputPath, mode, 'Reachability.xcframework')); } } Future _testStaticAndNoPlugins(Directory projectDir) async { const noPluginsOutputDir = 'flutter-frameworks-no-plugins-static'; final ProcessResult result = await inDirectory(projectDir, () async { return executeFlutter( 'build', options: [ 'ios-framework', '--cocoapods', '--force', // Allow podspec creation on master. '--output=$noPluginsOutputDir', '--no-plugins', '--static', '--no-codesign', ], canFail: true, ); }); if (result.exitCode == 0) { throw TaskResult.failure('Build framework command did not exit with error as expected'); } final output = '${result.stdout}\n${result.stderr}'; if (!output.contains('--static cannot be used with the --no-plugins flag')) { throw TaskResult.failure(output); } } Future _checkDylib(String pathToLibrary) async { final String binaryFileType = await fileType(pathToLibrary); if (!binaryFileType.contains('dynamically linked')) { throw TaskResult.failure('$pathToLibrary is not a dylib, found: $binaryFileType'); } } Future _checkDsym(String pathToSymbolFile) async { final String binaryFileType = await fileType(pathToSymbolFile); if (!binaryFileType.contains('dSYM companion file')) { throw TaskResult.failure('$pathToSymbolFile is not a dSYM, found: $binaryFileType'); } } Future _checkStatic(String pathToLibrary) async { final String binaryFileType = await fileType(pathToLibrary); if (!binaryFileType.contains('current ar archive random library')) { throw TaskResult.failure('$pathToLibrary is not a static library, found: $binaryFileType'); } } Future _checkCodeSignature(String pathToArtifact) async { final stderrBuffer = StringBuffer(); await eval('codesign', ['-dv', pathToArtifact], canFail: true, stderr: stderrBuffer); if (stderrBuffer.toString().contains('not signed at all')) { throw TaskResult.failure('$pathToArtifact is not signed'); } } Future _linksOnFlutter(String pathToBinary) async { final String loadCommands = await eval('otool', ['-l', '-arch', 'arm64', pathToBinary]); return loadCommands.contains('Flutter.framework'); } Future _linksOnFlutterMacOS(String pathToBinary) async { final String loadCommands = await eval('otool', ['-l', '-arch', 'arm64', pathToBinary]); return loadCommands.contains('FlutterMacOS.framework'); }