From e5c286e02eb1b80e11da0c4476a0ec1a8aaec4cc Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Thu, 1 Feb 2024 15:31:28 -0600 Subject: [PATCH] Upload DerivedData logs in CI (#142643) When the Dart VM is not found within 10 minutes in CI on CoreDevices (iOS 17+), stop the app and upload the logs from DerivedData. The app has to be stopped first since the logs are not put in DerivedData until it's stopped. Also, rearranged some logic to have CoreDevice have its own function for Dart VM url discovery. Debugging for https://github.com/flutter/flutter/issues/142448. --- dev/devicelab/lib/framework/utils.dart | 5 +- .../lib/src/commands/attach.dart | 1 + .../flutter_tools/lib/src/commands/run.dart | 2 + .../flutter_tools/lib/src/commands/test.dart | 1 + packages/flutter_tools/lib/src/device.dart | 6 + .../flutter_tools/lib/src/ios/devices.dart | 250 +++++++++++------- .../lib/src/runner/flutter_command.dart | 2 + .../src/runner/flutter_command_runner.dart | 6 + .../commands.shard/hermetic/drive_test.dart | 2 + .../commands.shard/hermetic/run_test.dart | 2 + .../ios/ios_device_start_prebuilt_test.dart | 75 +++++- 11 files changed, 255 insertions(+), 97 deletions(-) diff --git a/dev/devicelab/lib/framework/utils.dart b/dev/devicelab/lib/framework/utils.dart index 159cad2363..9d19e6863c 100644 --- a/dev/devicelab/lib/framework/utils.dart +++ b/dev/devicelab/lib/framework/utils.dart @@ -469,6 +469,7 @@ List _flutterCommandArgs(String command, List options) { final String? localEngineHost = localEngineHostFromEnv; final String? localEngineSrcPath = localEngineSrcPathFromEnv; final String? localWebSdk = localWebSdkFromEnv; + final bool pubOrPackagesCommand = command.startsWith('packages') || command.startsWith('pub'); return [ command, if (deviceOperatingSystem == DeviceOperatingSystem.ios && supportedDeviceTimeoutCommands.contains(command)) @@ -489,7 +490,9 @@ List _flutterCommandArgs(String command, List options) { // Use CI flag when running devicelab tests, except for `packages`/`pub` commands. // `packages`/`pub` commands effectively runs the `pub` tool, which does not have // the same allowed args. - if (!command.startsWith('packages') && !command.startsWith('pub')) '--ci', + if (!pubOrPackagesCommand) '--ci', + if (!pubOrPackagesCommand && hostAgent.dumpDirectory != null) + '--debug-logs-dir=${hostAgent.dumpDirectory!.path}' ]; } diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart index 04a0d91e46..c5a875db79 100644 --- a/packages/flutter_tools/lib/src/commands/attach.dart +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -525,6 +525,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. devToolsServerAddress: devToolsServerAddress, serveObservatory: serveObservatory, usingCISystem: usingCISystem, + debugLogsDirectoryPath: debugLogsDirectoryPath, ); return buildInfo.isDebug diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index 9759d4ef1f..03284538d3 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -264,6 +264,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment enableDartProfiling: enableDartProfiling, enableEmbedderApi: enableEmbedderApi, usingCISystem: usingCISystem, + debugLogsDirectoryPath: debugLogsDirectoryPath, ); } else { return DebuggingOptions.enabled( @@ -319,6 +320,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment enableDartProfiling: enableDartProfiling, enableEmbedderApi: enableEmbedderApi, usingCISystem: usingCISystem, + debugLogsDirectoryPath: debugLogsDirectoryPath, ); } } diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart index 25d7e05019..2bed07aa13 100644 --- a/packages/flutter_tools/lib/src/commands/test.dart +++ b/packages/flutter_tools/lib/src/commands/test.dart @@ -364,6 +364,7 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { nullAssertions: boolArg(FlutterOptions.kNullAssertions), usingCISystem: usingCISystem, enableImpeller: ImpellerStatus.fromBool(argResults!['enable-impeller'] as bool?), + debugLogsDirectoryPath: debugLogsDirectoryPath, ); String? testAssetDirectory; diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index 2639448d0d..69938a9b78 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -963,6 +963,7 @@ class DebuggingOptions { this.enableDartProfiling = true, this.enableEmbedderApi = false, this.usingCISystem = false, + this.debugLogsDirectoryPath, }) : debuggingEnabled = true; DebuggingOptions.disabled(this.buildInfo, { @@ -988,6 +989,7 @@ class DebuggingOptions { this.enableDartProfiling = true, this.enableEmbedderApi = false, this.usingCISystem = false, + this.debugLogsDirectoryPath, }) : debuggingEnabled = false, useTestFonts = false, startPaused = false, @@ -1069,6 +1071,7 @@ class DebuggingOptions { required this.enableDartProfiling, required this.enableEmbedderApi, required this.usingCISystem, + required this.debugLogsDirectoryPath, }); final bool debuggingEnabled; @@ -1112,6 +1115,7 @@ class DebuggingOptions { final bool enableDartProfiling; final bool enableEmbedderApi; final bool usingCISystem; + final String? debugLogsDirectoryPath; /// Whether the tool should try to uninstall a previously installed version of the app. /// @@ -1258,6 +1262,7 @@ class DebuggingOptions { 'enableDartProfiling': enableDartProfiling, 'enableEmbedderApi': enableEmbedderApi, 'usingCISystem': usingCISystem, + 'debugLogsDirectoryPath': debugLogsDirectoryPath, }; static DebuggingOptions fromJson(Map json, BuildInfo buildInfo) => @@ -1313,6 +1318,7 @@ class DebuggingOptions { enableDartProfiling: (json['enableDartProfiling'] as bool?) ?? true, enableEmbedderApi: (json['enableEmbedderApi'] as bool?) ?? false, usingCISystem: (json['usingCISystem'] as bool?) ?? false, + debugLogsDirectoryPath: json['debugLogsDirectoryPath'] as String?, ); } diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 9f6eee7ffc..6682872855 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -620,105 +620,75 @@ class IOSDevice extends Device { }); Uri? localUri; - if (isWirelesslyConnected) { - // When using a CoreDevice, device logs are unavailable and therefore - // cannot be used to get the Dart VM url. Instead, get the Dart VM - // Service by finding services matching the app bundle id and the - // device name. - // - // If not using a CoreDevice, wait for the Dart VM url to be discovered - // via logs and then get the Dart VM Service by finding services matching - // the app bundle id and the Dart VM port. - // - // Then in both cases, get the device IP from the Dart VM Service to - // construct the Dart VM url using the device IP as the host. - if (isCoreDevice) { - localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( - packageId, - this, - usesIpv6: ipv6, - useDeviceIPAsHost: true, + if (isCoreDevice || forceXcodeDebugWorkflow) { + localUri = await _discoverDartVMForCoreDevice( + debuggingOptions: debuggingOptions, + packageId: packageId, + ipv6: ipv6, + vmServiceDiscovery: vmServiceDiscovery, + ); + } else if (isWirelesslyConnected) { + // Wait for the Dart VM url to be discovered via logs (from `ios-deploy`) + // in ProtocolDiscovery. Then via mDNS, construct the Dart VM url using + // the device IP as the host by finding Dart VM services matching the + // app bundle id and Dart VM port. + + // Wait for Dart VM Service to start up. + final Uri? serviceURL = await vmServiceDiscovery?.uri; + if (serviceURL == null) { + await iosDeployDebugger?.stopAndDumpBacktrace(); + await dispose(); + return LaunchResult.failed(); + } + + // If Dart VM Service URL with the device IP is not found within 5 seconds, + // change the status message to prompt users to click Allow. Wait 5 seconds because it + // should only show this message if they have not already approved the permissions. + // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it. + final Timer mDNSLookupTimer = Timer(const Duration(seconds: 5), () { + startAppStatus.stop(); + startAppStatus = _logger.startProgress( + 'Waiting for approval of local network permissions...', ); - } else { - // Wait for Dart VM Service to start up. - final Uri? serviceURL = await vmServiceDiscovery?.uri; - if (serviceURL == null) { - await iosDeployDebugger?.stopAndDumpBacktrace(); + }); + + // Get Dart VM Service URL with the device IP as the host. + localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + deviceVmservicePort: serviceURL.port, + useDeviceIPAsHost: true, + ); + + mDNSLookupTimer.cancel(); + } else { + localUri = await vmServiceDiscovery?.uri; + // If the `ios-deploy` debugger loses connection before it finds the + // Dart Service VM url, try starting the debugger and launching the + // app again. + if (localUri == null && + debuggingOptions.usingCISystem && + iosDeployDebugger != null && + iosDeployDebugger!.lostConnection) { + _logger.printStatus('Lost connection to device. Trying to connect again...'); + await dispose(); + vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery( + package: package, + bundle: bundle, + debuggingOptions: debuggingOptions, + launchArguments: launchArguments, + ipv6: ipv6, + uninstallFirst: false, + skipInstall: true, + ); + installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1; + if (installationResult != 0) { + _printInstallError(bundle); await dispose(); return LaunchResult.failed(); } - - // If Dart VM Service URL with the device IP is not found within 5 seconds, - // change the status message to prompt users to click Allow. Wait 5 seconds because it - // should only show this message if they have not already approved the permissions. - // MDnsVmServiceDiscovery usually takes less than 5 seconds to find it. - final Timer mDNSLookupTimer = Timer(const Duration(seconds: 5), () { - startAppStatus.stop(); - startAppStatus = _logger.startProgress( - 'Waiting for approval of local network permissions...', - ); - }); - - // Get Dart VM Service URL with the device IP as the host. - localUri = await MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( - packageId, - this, - usesIpv6: ipv6, - deviceVmservicePort: serviceURL.port, - useDeviceIPAsHost: true, - ); - - mDNSLookupTimer.cancel(); - } - } else { - if ((isCoreDevice || forceXcodeDebugWorkflow) && vmServiceDiscovery != null) { - // When searching for the Dart VM url, search for it via ProtocolDiscovery - // (device logs) and mDNS simultaneously, since both can be flaky at times. - final Future vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( - packageId, - this, - usesIpv6: ipv6, - ); - final Future vmUrlFromLogs = vmServiceDiscovery.uri; - localUri = await Future.any( - >[vmUrlFromMDns, vmUrlFromLogs] - ); - - // If the first future to return is null, wait for the other to complete. - if (localUri == null) { - final List vmUrls = await Future.wait( - >[vmUrlFromMDns, vmUrlFromLogs] - ); - localUri = vmUrls.where((Uri? vmUrl) => vmUrl != null).firstOrNull; - } - } else { - localUri = await vmServiceDiscovery?.uri; - // If the `ios-deploy` debugger loses connection before it finds the - // Dart Service VM url, try starting the debugger and launching the - // app again. - if (localUri == null && - debuggingOptions.usingCISystem && - iosDeployDebugger != null && - iosDeployDebugger!.lostConnection) { - _logger.printStatus('Lost connection to device. Trying to connect again...'); - await dispose(); - vmServiceDiscovery = _setupDebuggerAndVmServiceDiscovery( - package: package, - bundle: bundle, - debuggingOptions: debuggingOptions, - launchArguments: launchArguments, - ipv6: ipv6, - uninstallFirst: false, - skipInstall: true, - ); - installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1; - if (installationResult != 0) { - _printInstallError(bundle); - await dispose(); - return LaunchResult.failed(); - } - localUri = await vmServiceDiscovery.uri; - } + localUri = await vmServiceDiscovery.uri; } } timer.cancel(); @@ -757,6 +727,96 @@ class IOSDevice extends Device { _logger.printError(''); } + /// Find the Dart VM url using ProtocolDiscovery (logs from `idevicesyslog`) + /// and mDNS simultaneously, using whichever is found first. `idevicesyslog` + /// does not work on wireless devices, so only use mDNS for wireless devices. + /// Wireless devices require using the device IP as the host. + Future _discoverDartVMForCoreDevice({ + required String packageId, + required bool ipv6, + required DebuggingOptions debuggingOptions, + ProtocolDiscovery? vmServiceDiscovery, + }) async { + Timer? maxWaitForCI; + final Completer cancelCompleter = Completer(); + + // When testing in CI, wait a max of 10 minutes for the Dart VM to be found. + // Afterwards, stop the app from running and upload DerivedData Logs to debug + // logs directory. CoreDevices are run through Xcode and launch logs are + // therefore found in DerivedData. + if (debuggingOptions.usingCISystem && debuggingOptions.debugLogsDirectoryPath != null) { + maxWaitForCI = Timer(const Duration(minutes: 10), () async { + _logger.printError('Failed to find Dart VM after 10 minutes.'); + await _xcodeDebug.exit(); + final String? homePath = _platform.environment['HOME']; + Directory? derivedData; + if (homePath != null) { + derivedData = _fileSystem.directory( + _fileSystem.path.join(homePath, 'Library', 'Developer', 'Xcode', 'DerivedData'), + ); + } + if (derivedData != null && derivedData.existsSync()) { + final Directory debugLogsDirectory = _fileSystem.directory( + debuggingOptions.debugLogsDirectoryPath, + ); + debugLogsDirectory.createSync(recursive: true); + for (final FileSystemEntity entity in derivedData.listSync()) { + if (entity is! Directory || !entity.childDirectory('Logs').existsSync()) { + continue; + } + final Directory logsToCopy = entity.childDirectory('Logs'); + final Directory copyDestination = debugLogsDirectory + .childDirectory('DerivedDataLogs') + .childDirectory(entity.basename) + .childDirectory('Logs'); + _logger.printTrace('Copying logs ${logsToCopy.path} to ${copyDestination.path}...'); + copyDirectory(logsToCopy, copyDestination); + } + } + cancelCompleter.complete(); + }); + } + + final Future vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( + packageId, + this, + usesIpv6: ipv6, + useDeviceIPAsHost: isWirelesslyConnected, + ); + + final List> discoveryOptions = >[ + vmUrlFromMDns, + ]; + + // vmServiceDiscovery uses device logs (`idevicesyslog`), which doesn't work + // on wireless devices. + if (vmServiceDiscovery != null && !isWirelesslyConnected) { + final Future vmUrlFromLogs = vmServiceDiscovery.uri; + discoveryOptions.add(vmUrlFromLogs); + } + + Uri? localUri = await Future.any( + >[...discoveryOptions, cancelCompleter.future], + ); + + // If the first future to return is null, wait for the other to complete + // unless canceled. + if (localUri == null && !cancelCompleter.isCompleted) { + final Future> allDiscoveryOptionsComplete = Future.wait(discoveryOptions); + await Future.any(>[ + allDiscoveryOptionsComplete, + cancelCompleter.future, + ]); + if (!cancelCompleter.isCompleted) { + // If it wasn't cancelled, that means one of the discovery options completed. + final List vmUrls = await allDiscoveryOptionsComplete; + localUri = vmUrls.where((Uri? vmUrl) => vmUrl != null).firstOrNull; + } + } + maxWaitForCI?.cancel(); + return localUri; + } + ProtocolDiscovery _setupDebuggerAndVmServiceDiscovery({ required IOSApp package, required Directory bundle, diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index ee8a2104d1..905b0fa254 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -377,6 +377,8 @@ abstract class FlutterCommand extends Command { /// Whether flutter is being run from our CI. bool get usingCISystem => boolArg(FlutterGlobalOptions.kContinuousIntegrationFlag, global: true); + String? get debugLogsDirectoryPath => stringArg(FlutterGlobalOptions.kDebugLogsDirectoryFlag, global: true); + /// The value of the `--filesystem-scheme` argument. /// /// This can be overridden by some of its subclasses. diff --git a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart index 3526937ac0..2970950a60 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart @@ -44,6 +44,7 @@ abstract final class FlutterGlobalOptions { static const String kVersionFlag = 'version'; static const String kWrapColumnOption = 'wrap-column'; static const String kWrapFlag = 'wrap'; + static const String kDebugLogsDirectoryFlag = 'debug-logs-dir'; } class FlutterCommandRunner extends CommandRunner { @@ -164,6 +165,11 @@ class FlutterCommandRunner extends CommandRunner { help: 'Enable a set of CI-specific test debug settings.', hide: !verboseHelp, ); + argParser.addOption( + FlutterGlobalOptions.kDebugLogsDirectoryFlag, + help: 'Path to a directory where logs for debugging may be added.', + hide: !verboseHelp, + ); } @override diff --git a/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart index a8d11116ed..50ffec8db5 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/drive_test.dart @@ -427,6 +427,7 @@ void main() { '--skia-deterministic-rendering', '--enable-embedder-api', '--ci', + '--debug-logs-dir=path/to/logs' ]), throwsToolExit()); final DebuggingOptions options = await command.createDebuggingOptions(false); @@ -444,6 +445,7 @@ void main() { expect(options.enableSoftwareRendering, true); expect(options.skiaDeterministicRendering, true); expect(options.usingCISystem, true); + expect(options.debugLogsDirectoryPath, 'path/to/logs'); }, overrides: { Cache: () => Cache.test(processManager: FakeProcessManager.any()), FileSystem: () => MemoryFileSystem.test(), diff --git a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart index 510adf1c3f..8c097e6c51 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/run_test.dart @@ -1262,6 +1262,7 @@ void main() { '--skia-deterministic-rendering', '--enable-embedder-api', '--ci', + '--debug-logs-dir=path/to/logs' ]), throwsToolExit()); final DebuggingOptions options = await command.createDebuggingOptions(false); @@ -1281,6 +1282,7 @@ void main() { expect(options.enableSoftwareRendering, true); expect(options.skiaDeterministicRendering, true); expect(options.usingCISystem, true); + expect(options.debugLogsDirectoryPath, 'path/to/logs'); }, overrides: { Cache: () => Cache.test(processManager: FakeProcessManager.any()), FileSystem: () => MemoryFileSystem.test(), diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 0e03fb9329..0325f55172 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -966,6 +966,78 @@ void main() { MDnsVmServiceDiscovery: () => mdnsDiscovery, }); }); + + testUsingContext('IOSDevice.startApp fails to find Dart VM in CI', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final FakeProcessManager processManager = FakeProcessManager.empty(); + + const String pathToFlutterLogs = '/path/to/flutter/logs'; + const String pathToHome = '/path/to/home'; + + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory.childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ), + platform: FakePlatform( + operatingSystem: 'macos', + environment: { + 'HOME': pathToHome, + }, + ), + ); + + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + const String projectLogsPath = 'Runner-project1/Logs/Launch/Runner.xcresults'; + fileSystem.directory('$pathToHome/Library/Developer/Xcode/DerivedData/$projectLogsPath').createSync(recursive: true); + + final Completer completer = Completer(); + await FakeAsync().run((FakeAsync time) { + final Future futureLaunchResult = device.startApp(iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled( + BuildInfo.debug, + usingCISystem: true, + debugLogsDirectoryPath: pathToFlutterLogs, + ), + platformArgs: {}, + ); + futureLaunchResult.then((LaunchResult launchResult) { + expect(launchResult.started, false); + expect(launchResult.hasVmService, false); + expect(fileSystem.directory('$pathToFlutterLogs/DerivedDataLogs/$projectLogsPath').existsSync(), true); + completer.complete(); + }); + time.elapse(const Duration(minutes: 15)); + time.flushMicrotasks(); + return completer.future; + }); + }, overrides: { + MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery(returnsNull: true), + }); }); }); } @@ -980,9 +1052,10 @@ IOSDevice setUpIOSDevice({ bool isCoreDevice = false, IOSCoreDeviceControl? coreDeviceControl, FakeXcodeDebug? xcodeDebug, + FakePlatform? platform, }) { final Artifacts artifacts = Artifacts.test(); - final FakePlatform macPlatform = FakePlatform( + final FakePlatform macPlatform = platform ?? FakePlatform( operatingSystem: 'macos', environment: {}, );