Skip to content

Commit

Permalink
[flutter_plugin_tests] Split analyze out of xctest (flutter#4161)
Browse files Browse the repository at this point in the history
To prep for making a combined command to run native tests across different platforms, rework `xctest`:
- Split analyze out into a new `xcode-analyze` command:
  - Since the analyze step runs a new build over everything with different flags, this is only a small amount slower than the combined version
  - This makes the logic easier to follow
  - This allows us to meaningfully report skips, to better notice missing tests.
- Add the ability to target specific test bundles (RunnerTests or RunnerUITests)

To share code between the commands, this extracts a new `Xcode` helper class.

Part of flutter/flutter#84392 and flutter/flutter#86489
  • Loading branch information
stuartmorgan authored and fotiDim committed Sep 13, 2021
1 parent 4773739 commit b8b8680
Show file tree
Hide file tree
Showing 9 changed files with 1,444 additions and 296 deletions.
4 changes: 4 additions & 0 deletions .cirrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ task:
- xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-5 | xargs xcrun simctl boot
build_script:
- ./script/tool_runner.sh build-examples --ios
xcode_analyze_script:
- ./script/tool_runner.sh xcode-analyze --ios
xctest_script:
- ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest"
drive_script:
Expand Down Expand Up @@ -249,6 +251,8 @@ task:
build_script:
- flutter config --enable-macos-desktop
- ./script/tool_runner.sh build-examples --macos
xcode_analyze_script:
- ./script/tool_runner.sh xcode-analyze --macos
xctest_script:
- ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS
drive_script:
Expand Down
6 changes: 6 additions & 0 deletions script/tool/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## NEXT

- Added an `xctest` flag to select specific test targets, to allow running only
unit tests or integration tests.
- Split Xcode analysis out of `xctest` and into a new `xcode-analyze` command.

## 0.4.1

- Improved `license-check` output.
Expand Down
159 changes: 159 additions & 0 deletions script/tool/lib/src/common/xcode.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2013 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:convert';
import 'dart:io' as io;

import 'package:file/file.dart';

import 'core.dart';
import 'process_runner.dart';

const String _xcodeBuildCommand = 'xcodebuild';
const String _xcRunCommand = 'xcrun';

/// A utility class for interacting with the installed version of Xcode.
class Xcode {
/// Creates an instance that runs commends with the given [processRunner].
///
/// If [log] is true, commands run by this instance will long various status
/// messages.
Xcode({
this.processRunner = const ProcessRunner(),
this.log = false,
});

/// The [ProcessRunner] used to run commands. Overridable for testing.
final ProcessRunner processRunner;

/// Whether or not to log when running commands.
final bool log;

/// Runs an `xcodebuild` in [directory] with the given parameters.
Future<int> runXcodeBuild(
Directory directory, {
List<String> actions = const <String>['build'],
required String workspace,
required String scheme,
String? configuration,
List<String> extraFlags = const <String>[],
}) {
final List<String> args = <String>[
_xcodeBuildCommand,
...actions,
if (workspace != null) ...<String>['-workspace', workspace],
if (scheme != null) ...<String>['-scheme', scheme],
if (configuration != null) ...<String>['-configuration', configuration],
...extraFlags,
];
final String completeTestCommand = '$_xcRunCommand ${args.join(' ')}';
if (log) {
print(completeTestCommand);
}
return processRunner.runAndStream(_xcRunCommand, args,
workingDir: directory);
}

/// Returns true if [project], which should be an .xcodeproj directory,
/// contains a target called [target], false if it does not, and null if the
/// check fails (e.g., if [project] is not an Xcode project).
Future<bool?> projectHasTarget(Directory project, String target) async {
final io.ProcessResult result =
await processRunner.run(_xcRunCommand, <String>[
_xcodeBuildCommand,
'-list',
'-json',
'-project',
project.path,
]);
if (result.exitCode != 0) {
return null;
}
Map<String, dynamic>? projectInfo;
try {
projectInfo = (jsonDecode(result.stdout as String)
as Map<String, dynamic>)['project'] as Map<String, dynamic>?;
} on FormatException {
return null;
}
if (projectInfo == null) {
return null;
}
final List<String>? targets =
(projectInfo['targets'] as List<dynamic>?)?.cast<String>();
return targets?.contains(target) ?? false;
}

/// Returns the newest available simulator (highest OS version, with ties
/// broken in favor of newest device), if any.
Future<String?> findBestAvailableIphoneSimulator() async {
final List<String> findSimulatorsArguments = <String>[
'simctl',
'list',
'devices',
'runtimes',
'available',
'--json',
];
final String findSimulatorCompleteCommand =
'$_xcRunCommand ${findSimulatorsArguments.join(' ')}';
if (log) {
print('Looking for available simulators...');
print(findSimulatorCompleteCommand);
}
final io.ProcessResult findSimulatorsResult =
await processRunner.run(_xcRunCommand, findSimulatorsArguments);
if (findSimulatorsResult.exitCode != 0) {
if (log) {
printError(
'Error occurred while running "$findSimulatorCompleteCommand":\n'
'${findSimulatorsResult.stderr}');
}
return null;
}
final Map<String, dynamic> simulatorListJson =
jsonDecode(findSimulatorsResult.stdout as String)
as Map<String, dynamic>;
final List<Map<String, dynamic>> runtimes =
(simulatorListJson['runtimes'] as List<dynamic>)
.cast<Map<String, dynamic>>();
final Map<String, Object> devices =
(simulatorListJson['devices'] as Map<String, dynamic>)
.cast<String, Object>();
if (runtimes.isEmpty || devices.isEmpty) {
return null;
}
String? id;
// Looking for runtimes, trying to find one with highest OS version.
for (final Map<String, dynamic> rawRuntimeMap in runtimes.reversed) {
final Map<String, Object> runtimeMap =
rawRuntimeMap.cast<String, Object>();
if ((runtimeMap['name'] as String?)?.contains('iOS') != true) {
continue;
}
final String? runtimeID = runtimeMap['identifier'] as String?;
if (runtimeID == null) {
continue;
}
final List<Map<String, dynamic>>? devicesForRuntime =
(devices[runtimeID] as List<dynamic>?)?.cast<Map<String, dynamic>>();
if (devicesForRuntime == null || devicesForRuntime.isEmpty) {
continue;
}
// Looking for runtimes, trying to find latest version of device.
for (final Map<String, dynamic> rawDevice in devicesForRuntime.reversed) {
final Map<String, Object> device = rawDevice.cast<String, Object>();
id = device['udid'] as String?;
if (id == null) {
continue;
}
if (log) {
print('device selected: $device');
}
return id;
}
}
return null;
}
}
2 changes: 2 additions & 0 deletions script/tool/lib/src/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import 'publish_plugin_command.dart';
import 'pubspec_check_command.dart';
import 'test_command.dart';
import 'version_check_command.dart';
import 'xcode_analyze_command.dart';
import 'xctest_command.dart';

void main(List<String> args) {
Expand Down Expand Up @@ -59,6 +60,7 @@ void main(List<String> args) {
..addCommand(PubspecCheckCommand(packagesDir))
..addCommand(TestCommand(packagesDir))
..addCommand(VersionCheckCommand(packagesDir))
..addCommand(XcodeAnalyzeCommand(packagesDir))
..addCommand(XCTestCommand(packagesDir));

commandRunner.run(args).catchError((Object e) {
Expand Down
111 changes: 111 additions & 0 deletions script/tool/lib/src/xcode_analyze_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2013 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 'package:file/file.dart';
import 'package:platform/platform.dart';

import 'common/core.dart';
import 'common/package_looping_command.dart';
import 'common/plugin_utils.dart';
import 'common/process_runner.dart';
import 'common/xcode.dart';

/// The command to run Xcode's static analyzer on plugins.
class XcodeAnalyzeCommand extends PackageLoopingCommand {
/// Creates an instance of the test command.
XcodeAnalyzeCommand(
Directory packagesDir, {
ProcessRunner processRunner = const ProcessRunner(),
Platform platform = const LocalPlatform(),
}) : _xcode = Xcode(processRunner: processRunner, log: true),
super(packagesDir, processRunner: processRunner, platform: platform) {
argParser.addFlag(kPlatformIos, help: 'Analyze iOS');
argParser.addFlag(kPlatformMacos, help: 'Analyze macOS');
}

final Xcode _xcode;

@override
final String name = 'xcode-analyze';

@override
final String description =
'Runs Xcode analysis on the iOS and/or macOS example apps.';

@override
Future<void> initializeRun() async {
if (!(getBoolArg(kPlatformIos) || getBoolArg(kPlatformMacos))) {
printError('At least one platform flag must be provided.');
throw ToolExit(exitInvalidArguments);
}
}

@override
Future<PackageResult> runForPackage(Directory package) async {
final bool testIos = getBoolArg(kPlatformIos) &&
pluginSupportsPlatform(kPlatformIos, package,
requiredMode: PlatformSupport.inline);
final bool testMacos = getBoolArg(kPlatformMacos) &&
pluginSupportsPlatform(kPlatformMacos, package,
requiredMode: PlatformSupport.inline);

final bool multiplePlatformsRequested =
getBoolArg(kPlatformIos) && getBoolArg(kPlatformMacos);
if (!(testIos || testMacos)) {
return PackageResult.skip('Not implemented for target platform(s).');
}

final List<String> failures = <String>[];
if (testIos &&
!await _analyzePlugin(package, 'iOS', extraFlags: <String>[
'-destination',
'generic/platform=iOS Simulator'
])) {
failures.add('iOS');
}
if (testMacos && !await _analyzePlugin(package, 'macOS')) {
failures.add('macOS');
}

// Only provide the failing platform in the failure details if testing
// multiple platforms, otherwise it's just noise.
return failures.isEmpty
? PackageResult.success()
: PackageResult.fail(
multiplePlatformsRequested ? failures : <String>[]);
}

/// Analyzes [plugin] for [platform], returning true if it passed analysis.
Future<bool> _analyzePlugin(
Directory plugin,
String platform, {
List<String> extraFlags = const <String>[],
}) async {
bool passing = true;
for (final Directory example in getExamplesForPlugin(plugin)) {
// Running tests and static analyzer.
final String examplePath =
getRelativePosixPath(example, from: plugin.parent);
print('Running $platform tests and analyzer for $examplePath...');
final int exitCode = await _xcode.runXcodeBuild(
example,
actions: <String>['analyze'],
workspace: '${platform.toLowerCase()}/Runner.xcworkspace',
scheme: 'Runner',
configuration: 'Debug',
extraFlags: <String>[
...extraFlags,
'GCC_TREAT_WARNINGS_AS_ERRORS=YES',
],
);
if (exitCode == 0) {
printSuccess('$examplePath ($platform) passed analysis.');
} else {
printError('$examplePath ($platform) failed analysis.');
passing = false;
}
}
return passing;
}
}
Loading

0 comments on commit b8b8680

Please sign in to comment.