blob: 2e059bd4e6f9c3df3b5d479116e684e2d864c910 [file] [log] [blame]
// 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:async';
import 'dart:ffi' as ffi;
import 'dart:io' as io;
import 'package:args/args.dart';
import 'package:engine_repo_tools/engine_repo_tools.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
void main(List<String> args) async {
if (!io.Platform.isMacOS) {
io.stderr.writeln('This script is only supported on macOS.');
io.exitCode = 1;
return;
}
final engine = Engine.tryFindWithin();
if (engine == null) {
io.stderr.writeln('Must be run from within the engine repository.');
io.exitCode = 1;
return;
}
if (args.length > 1 || args.contains('-h') || args.contains('--help')) {
io.stderr.writeln('Usage: run_ios_tests.dart [ios_engine_variant]');
io.stderr.writeln(_args.usage);
io.exitCode = 1;
return;
}
// Collect cleanup tasks to run when the script terminates.
final cleanup = <FutureOr<void> Function()>{};
// Parse the command-line arguments.
final results = _args.parse(args);
final String iosEngineVariant;
if (results.rest case [final variant]) {
iosEngineVariant = variant;
} else if (ffi.Abi.current() == ffi.Abi.macosArm64) {
iosEngineVariant = 'ios_debug_sim_unopt_arm64';
} else {
iosEngineVariant = 'ios_debug_sim_unopt';
}
// Null if the tests should create and dispose their own temporary directory.
String? dumpXcresultOnFailurePath;
if (results.option('dump-xcresult-on-failure') case final String path) {
dumpXcresultOnFailurePath = path;
}
// Run the actual script.
final completer = Completer<void>();
runZonedGuarded(() async {
await _run(
cleanup,
engine,
iosEngineVariant: iosEngineVariant,
deviceName: results.option('device-name')!,
deviceIdentifier: results.option('device-identifier')!,
osRuntime: results.option('os-runtime')!,
osVersion: results.option('os-version')!,
withImpeller: results.flag('with-impeller'),
withSkia: results.flag('with-skia'),
dumpXcresultOnFailure: dumpXcresultOnFailurePath,
);
completer.complete();
}, (e, s) {
if (e is _ToolFailure) {
io.stderr.writeln(e);
io.exitCode = 1;
} else {
io.stderr.writeln('Uncaught exception: $e\n$s');
io.exitCode = 255;
}
completer.complete();
});
// We can't await the result of runZonedGuarded becauase async errors in futures never cross different errorZone boundaries.
await completer.future;
// Run cleanup tasks.
for (final task in cleanup) {
await task();
}
}
void _deleteIfPresent(io.FileSystemEntity entity) {
if (entity.existsSync()) {
entity.deleteSync(recursive: true);
}
}
/// Runs the script.
///
/// The [cleanup] set contains cleanup tasks to run when the script is either
/// completed normally or terminated early. For example, deleting a temporary
/// directory or killing a process.
///
/// Each named argument cooresponds to a flag or option in the `ArgParser`.
Future<void> _run(
Set<FutureOr<void> Function()> cleanup,
Engine engine, {
required String iosEngineVariant,
required String deviceName,
required String deviceIdentifier,
required String osRuntime,
required String osVersion,
required bool withImpeller,
required bool withSkia,
required String? dumpXcresultOnFailure,
}) async {
// Terminate early on SIGINT.
late final StreamSubscription<void> sigint;
sigint = io.ProcessSignal.sigint.watch().listen((_) {
throw _ToolFailure('Received SIGINT');
});
cleanup.add(sigint.cancel);
_ensureSimulatorsRotateAutomaticallyForPlatformViewRotationTest();
_deleteAnyExistingDevices(deviceName: deviceName);
_createDevice(
deviceName: deviceName,
deviceIdentifier: deviceIdentifier,
osRuntime: osRuntime,
);
final (scenarioPath, resultBundle) = _buildResultBundlePath(
engine: engine,
iosEngineVariant: iosEngineVariant,
);
cleanup.add(() => _deleteIfPresent(resultBundle));
if (withSkia) {
io.stderr.writeln('Running simulator tests with Skia');
io.stderr.writeln();
final process = await _runTests(
outScenariosPath: scenarioPath,
resultBundlePath: resultBundle.path,
osVersion: osVersion,
deviceName: deviceName,
iosEngineVariant: iosEngineVariant,
xcodeBuildExtraArgs: [
// Plist with `FTEEnableImpeller=NO`; all projects in the workspace require this file.
// For example, `FlutterAppExtensionTestHost` has a dummy file under the below directory.
r'INFOPLIST_FILE=$(TARGET_NAME)/Info_Skia.plist',
],
);
cleanup.add(process.kill);
// Create a temporary directory, if needed.
var storePath = dumpXcresultOnFailure;
if (storePath == null) {
final dumpDir = io.Directory.systemTemp.createTempSync();
storePath = dumpDir.path;
cleanup.add(() => dumpDir.delete(recursive: true));
}
if (await process.exitCode != 0) {
final String outputPath = _zipAndStoreFailedTestResults(
iosEngineVariant: iosEngineVariant,
resultBundle: resultBundle,
storePath: storePath,
);
io.stderr.writeln('Failed test results are stored at $outputPath');
throw _ToolFailure('test failed.');
} else {
io.stderr.writeln('test succcess.');
}
_deleteIfPresent(resultBundle);
}
if (withImpeller) {
final process = await _runTests(
outScenariosPath: scenarioPath,
resultBundlePath: resultBundle.path,
osVersion: osVersion,
deviceName: deviceName,
iosEngineVariant: iosEngineVariant,
);
cleanup.add(process.kill);
// Create a temporary directory, if needed.
var storePath = dumpXcresultOnFailure;
if (storePath == null) {
final dumpDir = io.Directory.systemTemp.createTempSync();
storePath = dumpDir.path;
cleanup.add(() => dumpDir.delete(recursive: true));
}
if (await process.exitCode != 0) {
final String outputPath = _zipAndStoreFailedTestResults(
iosEngineVariant: iosEngineVariant,
resultBundle: resultBundle,
storePath: storePath,
);
io.stderr.writeln('Failed test results are stored at $outputPath');
throw _ToolFailure('test failed.');
} else {
io.stderr.writeln('test succcess.');
}
_deleteIfPresent(resultBundle);
}
}
/// Exception thrown when the tool should halt execution intentionally.
final class _ToolFailure implements Exception {
_ToolFailure(this.message);
final String message;
@override
String toString() => message;
}
final _args = ArgParser()
..addFlag(
'help',
abbr: 'h',
help: 'Prints usage information.',
negatable: false,
)
..addOption(
'device-name',
help: 'The name of the iOS simulator device to use.',
defaultsTo: 'iPhone SE (3rd generation)',
)
..addOption(
'device-identifier',
help: 'The identifier of the iOS simulator device to use.',
defaultsTo:
'com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation',
)
..addOption(
'os-runtime',
help: 'The OS runtime of the iOS simulator device to use.',
defaultsTo: 'com.apple.CoreSimulator.SimRuntime.iOS-17-0',
)
..addOption(
'os-version',
help: 'The OS version of the iOS simulator device to use.',
defaultsTo: '17.0',
)
..addFlag(
'with-impeller',
help: 'Whether to use the Impeller backend to run the tests.\n\nCan be '
'combined with --with-skia to run the test suite with both backends.',
defaultsTo: true,
)
..addFlag(
'with-skia',
help:
'Whether to use the Skia backend to run the tests.\n\nCan be combined '
'with --with-impeller to run the test suite with both backends.',
defaultsTo: true,
)
..addOption(
'dump-xcresult-on-failure',
help: 'The path to dump the xcresult bundle to if the test fails.\n\n'
'Defaults to the environment variable FLUTTER_TEST_OUTPUTS_DIR, '
'otherwise to a randomly generated temporary directory.',
defaultsTo: io.Platform.environment['FLUTTER_TEST_OUTPUTS_DIR'],
);
void _ensureSimulatorsRotateAutomaticallyForPlatformViewRotationTest() {
// Can also be set via Simulator Device > Rotate Device Automatically.
final result = io.Process.runSync(
'defaults',
const [
'write',
'com.apple.iphonesimulator',
'RotateWindowWhenSignaledByGuest',
'-int 1',
],
);
if (result.exitCode != 0) {
throw Exception(
'Failed to enable automatic rotation for iOS simulator: ${result.stderr}',
);
}
}
void _deleteAnyExistingDevices({required String deviceName}) {
io.stderr.writeln(
'Deleting any existing simulator devices named $deviceName...',
);
bool deleteSimulator() {
final result = io.Process.runSync(
'xcrun',
['simctl', 'delete', deviceName],
);
if (result.exitCode == 0) {
io.stderr.writeln('Deleted $deviceName');
return true;
} else {
return false;
}
}
while (deleteSimulator()) {}
}
void _createDevice({
required String deviceName,
required String deviceIdentifier,
required String osRuntime,
}) {
io.stderr.writeln('Creating $deviceName $deviceIdentifier $osRuntime...');
final result = io.Process.runSync(
'xcrun',
[
'simctl',
'create',
deviceName,
deviceIdentifier,
osRuntime,
],
);
if (result.exitCode != 0) {
throw Exception('Failed to create simulator device: ${result.stderr}');
}
}
@useResult
(String scenarios, io.Directory resultBundle) _buildResultBundlePath({
required Engine engine,
required String iosEngineVariant,
}) {
final scenarioPath = path.normalize(path.join(
engine.outDir.path,
iosEngineVariant,
'scenario_app',
'Scenarios',
));
// Create a temporary directory to store the test results.
final result = io.Directory(scenarioPath).createTempSync(
'ios_scenario_xcresult',
);
return (scenarioPath, result);
}
@useResult
Future<io.Process> _runTests({
required String resultBundlePath,
required String outScenariosPath,
required String osVersion,
required String deviceName,
required String iosEngineVariant,
List<String> xcodeBuildExtraArgs = const [],
}) async {
return io.Process.start(
'xcodebuild',
[
'-project',
path.join(outScenariosPath, 'Scenarios.xcodeproj'),
'-sdk',
'iphonesimulator',
'-scheme',
'Scenarios',
'-resultBundlePath',
path.join(resultBundlePath, 'ios_scenario.xcresult'),
'-destination',
'platform=iOS Simulator,OS=$osVersion,name=$deviceName',
'clean',
'test',
'FLUTTER_ENGINE=$iosEngineVariant',
...xcodeBuildExtraArgs,
],
mode: io.ProcessStartMode.inheritStdio,
);
}
@useResult
String _zipAndStoreFailedTestResults({
required String iosEngineVariant,
required io.Directory resultBundle,
required String storePath,
}) {
final outputPath = path.join(storePath, '${iosEngineVariant.replaceAll('/', '_')}.zip');
final result = io.Process.runSync(
'zip',
[
'-q',
'-r',
outputPath,
resultBundle.path,
],
);
if (result.exitCode != 0) {
throw Exception(
'Failed to zip the test results (exit code = ${result.exitCode}).\n\n'
'Stderr: ${result.stderr}\n\n'
'Stdout: ${result.stdout}',
);
}
return outputPath;
}