From 61c848c1bd4c41852bbd2fe2ce98301fc6a325dd Mon Sep 17 00:00:00 2001 From: Jenn Magder Date: Tue, 16 Mar 2021 09:43:02 -0700 Subject: [PATCH] Point "flutter build ipa --analyze-size" to archive app output (#78259) --- .../lib/src/commands/build_ios.dart | 34 ++- .../hermetic/build_ios_test.dart | 252 ++++++++++++++++++ .../hermetic/build_ipa_test.dart | 34 ++- 3 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart index 0b77af25ea..d3fd27c0e0 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -57,6 +57,9 @@ class BuildIOSCommand extends _BuildIOSSubCommand { @override bool get shouldCodesign => boolArg('codesign'); + + @override + Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs.directory(xcodeResultOutput); } /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for @@ -98,6 +101,12 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { String get exportOptionsPlist => stringArg('export-options-plist'); + @override + Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs + .directory(xcodeResultOutput) + .childDirectory('Products') + .childDirectory('Applications'); + @override Future runCommand() async { if (exportOptionsPlist != null) { @@ -209,6 +218,8 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { BuildableIOSApp _buildableIOSApp; + Directory _outputAppDirectory(String xcodeResultOutput); + @override Future runCommand() async { defaultBuildMode = forSimulator ? BuildMode.debug : BuildMode.release; @@ -273,16 +284,19 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { final File precompilerTrace = globals.fs.directory(buildInfo.codeSizeDirectory) .childFile('trace.$arch.json'); - // This analysis is only supported for release builds, which also excludes the simulator. - // Attempt to guess the correct .app by picking the first one. - final Directory candidateDirectory = globals.fs.directory( - globals.fs.path.join(getIosBuildDirectory(), 'Release-iphoneos'), - ); - final Directory appDirectory = candidateDirectory.listSync() - .whereType() - .firstWhere((Directory directory) { - return globals.fs.path.extension(directory.path) == '.app'; - }); + final Directory outputAppDirectoryCandidate = _outputAppDirectory(result.output); + + Directory appDirectory; + if (outputAppDirectoryCandidate.existsSync()) { + appDirectory = outputAppDirectoryCandidate.listSync() + .whereType() + .firstWhere((Directory directory) { + return globals.fs.path.extension(directory.path) == '.app'; + }, orElse: () => null); + } + if (appDirectory == null) { + throwToolExit('Could not find app to analyze code size in ${outputAppDirectoryCandidate.path}'); + } final Map output = await sizeAnalyzer.analyzeAotSnapshot( aotSnapshot: aotSnapshot, precompilerTrace: precompilerTrace, diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart new file mode 100644 index 0000000000..0bc22fd3c4 --- /dev/null +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart @@ -0,0 +1,252 @@ +// 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. + +// @dart = 2.8 + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/commands/build.dart'; +import 'package:flutter_tools/src/ios/xcodeproj.dart'; +import 'package:flutter_tools/src/reporting/reporting.dart'; +import 'package:process/process.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; +import '../../src/testbed.dart'; + +class FakeXcodeProjectInterpreterWithBuildSettings extends FakeXcodeProjectInterpreter { + @override + Future> getBuildSettings( + String projectPath, { + String scheme, + Duration timeout = const Duration(minutes: 1), + }) async { + return { + 'PRODUCT_BUNDLE_IDENTIFIER': 'io.flutter.someProject', + 'DEVELOPMENT_TEAM': 'abc', + }; + } +} + +final Platform macosPlatform = FakePlatform( + operatingSystem: 'macos', + environment: { + 'FLUTTER_ROOT': '/', + 'HOME': '/', + } +); +final Platform notMacosPlatform = FakePlatform( + operatingSystem: 'linux', + environment: { + 'FLUTTER_ROOT': '/', + } +); + +void main() { + FileSystem fileSystem; + TestUsage usage; + + setUpAll(() { + Cache.disableLocking(); + }); + + setUp(() { + fileSystem = MemoryFileSystem.test(); + usage = TestUsage(); + }); + + // Sets up the minimal mock project files necessary to look like a Flutter project. + void _createCoreMockProjectFiles() { + fileSystem.file('pubspec.yaml').createSync(); + fileSystem.file('.packages').createSync(); + fileSystem.file(fileSystem.path.join('lib', 'main.dart')).createSync(recursive: true); + } + + // Sets up the minimal mock project files necessary for iOS builds to succeed. + void _createMinimalMockProjectFiles() { + fileSystem.directory(fileSystem.path.join('ios', 'Runner.xcodeproj')).createSync(recursive: true); + fileSystem.directory(fileSystem.path.join('ios', 'Runner.xcworkspace')).createSync(recursive: true); + fileSystem.file(fileSystem.path.join('ios', 'Runner.xcodeproj', 'project.pbxproj')).createSync(); + _createCoreMockProjectFiles(); + } + + const FakeCommand xattrCommand = FakeCommand(command: [ + 'xattr', '-r', '-d', 'com.apple.FinderInfo', '/ios' + ]); + + FakeCommand _setUpRsyncCommand({void Function() onRun}) { + return FakeCommand( + command: const [ + 'rsync', + '-av', + '--delete', + 'build/ios/Release-iphoneos/Runner.app', + 'build/ios/iphoneos', + ], + onRun: onRun); + } + + // Creates a FakeCommand for the xcodebuild call to build the app + // in the given configuration. + FakeCommand _setUpFakeXcodeBuildHandler({ bool verbose = false, bool showBuildSettings = false, void Function() onRun }) { + return FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-configuration', 'Release', + if (verbose) + 'VERBOSE_SCRIPT_LOGGING=YES' + else + '-quiet', + '-workspace', 'Runner.xcworkspace', + '-scheme', 'Runner', + 'BUILD_DIR=/build/ios', + '-sdk', 'iphoneos', + 'FLUTTER_SUPPRESS_ANALYTICS=true', + 'COMPILER_INDEX_STORE_ENABLE=NO', + if (showBuildSettings) + '-showBuildSettings', + ], + stdout: ''' + TARGET_BUILD_DIR=build/ios/Release-iphoneos + WRAPPER_NAME=Runner.app +''', + onRun: onRun, + ); + } + + testUsingContext('ios build fails when there is no ios project', () async { + final BuildCommand command = BuildCommand(); + _createCoreMockProjectFiles(); + + expect(createTestCommandRunner(command).run( + const ['build', 'ios', '--no-pub'] + ), throwsToolExit(message: 'Application not configured for iOS')); + }, overrides: { + Platform: () => macosPlatform, + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('ios build fails in debug with code analysis', () async { + final BuildCommand command = BuildCommand(); + _createCoreMockProjectFiles(); + + expect(createTestCommandRunner(command).run( + const ['build', 'ios', '--no-pub', '--debug', '--analyze-size'] + ), throwsToolExit(message: '--analyze-size" can only be used on release builds')); + }, overrides: { + Platform: () => macosPlatform, + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('ios build fails on non-macOS platform', () async { + final BuildCommand command = BuildCommand(); + fileSystem.file('pubspec.yaml').createSync(); + fileSystem.file('.packages').createSync(); + fileSystem.file(fileSystem.path.join('lib', 'main.dart')) + .createSync(recursive: true); + + expect(createTestCommandRunner(command).run( + const ['build', 'ios', '--no-pub'] + ), throwsToolExit()); + }, overrides: { + Platform: () => notMacosPlatform, + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('ios build invokes xcode build', () async { + final BuildCommand command = BuildCommand(); + _createMinimalMockProjectFiles(); + + await createTestCommandRunner(command).run( + const ['build', 'ios', '--no-pub'] + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + xattrCommand, + _setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.directory('build/ios/Release-iphoneos/Runner.app').createSync(recursive: true); + }), + _setUpFakeXcodeBuildHandler(showBuildSettings: true), + _setUpRsyncCommand(), + ]), + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('ios build invokes xcode build with verbosity', () async { + final BuildCommand command = BuildCommand(); + _createMinimalMockProjectFiles(); + + await createTestCommandRunner(command).run( + const ['build', 'ios', '--no-pub', '-v'] + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + xattrCommand, + _setUpFakeXcodeBuildHandler(verbose: true, onRun: () { + fileSystem.directory('build/ios/Release-iphoneos/Runner.app').createSync(recursive: true); + }), + _setUpFakeXcodeBuildHandler(verbose: true, showBuildSettings: true), + _setUpRsyncCommand(), + ]), + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('Performs code size analysis and sends analytics', () async { + final BuildCommand command = BuildCommand(); + _createMinimalMockProjectFiles(); + + await createTestCommandRunner(command).run( + const ['build', 'ios', '--no-pub', '--analyze-size'] + ); + + expect(testLogger.statusText, contains('A summary of your iOS bundle analysis can be found at')); + expect(testLogger.statusText, contains('flutter pub global activate devtools; flutter pub global run devtools --appSizeBase=')); + expect(usage.events, contains( + const TestUsageEvent('code-size-analysis', 'ios'), + )); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + xattrCommand, + _setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.directory('build/ios/Release-iphoneos/Runner.app').createSync(recursive: true); + fileSystem.file('build/flutter_size_01/snapshot.arm64.json') + ..createSync(recursive: true) + ..writeAsStringSync(''' +[ + { + "l": "dart:_internal", + "c": "SubListIterable", + "n": "[Optimized] skip", + "s": 2400 + } +]'''); + fileSystem.file('build/flutter_size_01/trace.arm64.json') + ..createSync(recursive: true) + ..writeAsStringSync('{}'); + }), + _setUpFakeXcodeBuildHandler(showBuildSettings: true), + _setUpRsyncCommand(onRun: () => fileSystem.file('build/ios/iphoneos/Runner.app/Frameworks/App.framework/App') + ..createSync(recursive: true) + ..writeAsBytesSync(List.generate(10000, (int index) => 0))), + ]), + Platform: () => macosPlatform, + FileSystemUtils: () => FileSystemUtils(fileSystem: fileSystem, platform: macosPlatform), + Usage: () => usage, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); +} diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart index 1f459c0c52..4e4ac2930e 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart @@ -135,6 +135,20 @@ void main() { XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); + testUsingContext('ipa build fails in debug with code analysis', () async { + final BuildCommand command = BuildCommand(); + createCoreMockProjectFiles(); + + expect(createTestCommandRunner(command).run( + const ['build', 'ipa', '--no-pub', '--debug', '--analyze-size'] + ), throwsToolExit(message: '--analyze-size" can only be used on release builds')); + }, overrides: { + Platform: () => macosPlatform, + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + testUsingContext('ipa build fails on non-macOS platform', () async { final BuildCommand command = BuildCommand(); fileSystem.file('pubspec.yaml').createSync(); @@ -234,11 +248,29 @@ void main() { XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); + testUsingContext('code size analysis fails when app not found', () async { + final BuildCommand command = BuildCommand(); + createMinimalMockProjectFiles(); + + await expectToolExitLater( + createTestCommandRunner(command).run( + const ['build', 'ipa', '--no-pub', '--analyze-size'] + ), + contains('Could not find app to analyze code size'), + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => + FakeXcodeProjectInterpreterWithBuildSettings(), + }); + testUsingContext('Performs code size analysis and sends analytics', () async { final BuildCommand command = BuildCommand(); createMinimalMockProjectFiles(); - fileSystem.file('build/ios/Release-iphoneos/Runner.app/Frameworks/App.framework/App') + fileSystem.file('build/ios/archive/Runner.xcarchive/Products/Applications/Runner.app/Frameworks/App.framework/App') ..createSync(recursive: true) ..writeAsBytesSync(List.generate(10000, (int index) => 0));