// 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' as io; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/globals.dart' as globals; import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:test/fake.dart'; import '../../src/common.dart'; import '../../src/context.dart'; import '../../src/fake_process_manager.dart'; void main() { group('Debug project through Xcode', () { late MemoryFileSystem fileSystem; late BufferLogger logger; late FakeProcessManager fakeProcessManager; const String flutterRoot = '/path/to/flutter'; const String pathToXcodeAutomationScript = '$flutterRoot/packages/flutter_tools/bin/xcode_debug.js'; setUp(() { fileSystem = MemoryFileSystem.test(); logger = BufferLogger.test(); fakeProcessManager = FakeProcessManager.empty(); }); group('debugApp', () { const String pathToXcodeApp = '/Applications/Xcode.app'; const String deviceId = '0000001234'; late Xcode xcode; late Directory xcodeproj; late Directory xcworkspace; late XcodeDebugProject project; setUp(() { xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); xcodeproj = fileSystem.directory('Runner.xcodeproj'); xcworkspace = fileSystem.directory('Runner.xcworkspace'); project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', ); }); testWithoutContext( 'succeeds in opening and debugging with launch options, expectedConfigurationBuildDir, and verbose logging', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--verbose', ], stdout: ''' {"status":false,"errorMessage":"Xcode is not running","debugResult":null} ''', ), FakeCommand( command: ['open', '-a', pathToXcodeApp, '-g', '-j', '-F', xcworkspace.path], ), FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'debug', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--project-name', project.hostAppProjectName, '--expected-configuration-build-dir', '/build/ios/iphoneos', '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', '--verbose', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}} ''', ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', expectedConfigurationBuildDir: '/build/ios/iphoneos', verboseLogging: true, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: ['--enable-dart-profiling', '--trace-allowlist="foo,bar"'], ); expect(logger.errorText, isEmpty); expect(logger.traceText, contains('Error checking if project opened in Xcode')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(xcodeDebug.startDebugActionProcess, isNull); expect(status, true); }, ); testWithoutContext( 'succeeds in opening and debugging without launch options, expectedConfigurationBuildDir, and verbose logging', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":false,"errorMessage":"Xcode is not running","debugResult":null} ''', ), FakeCommand( command: ['open', '-a', pathToXcodeApp, '-g', '-j', '-F', xcworkspace.path], ), FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'debug', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--project-name', project.hostAppProjectName, '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', '[]', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"running","errorMessage":null}} ''', ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: [], ); expect(logger.errorText, isEmpty); expect(logger.traceText, contains('Error checking if project opened in Xcode')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(xcodeDebug.startDebugActionProcess, isNull); expect(status, true); }, ); testWithoutContext('fails if project fails to open', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":false,"errorMessage":"Xcode is not running","debugResult":null} ''', ), FakeCommand( command: ['open', '-a', pathToXcodeApp, '-g', '-j', '-F', xcworkspace.path], exception: ProcessException('open', [ '-a', '/non_existent_path', '-g', '-j', '-F', xcworkspace.path, ], 'The application /non_existent_path cannot be opened for an unexpected reason'), ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: ['--enable-dart-profiling', '--trace-allowlist="foo,bar"'], ); expect( logger.errorText, contains('The application /non_existent_path cannot be opened for an unexpected reason'), ); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, false); }); testWithoutContext('fails if osascript errors', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":true,"errorMessage":"","debugResult":null} ''', ), FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'debug', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--project-name', project.hostAppProjectName, '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', ], exitCode: 1, stderr: "/flutter/packages/flutter_tools/bin/xcode_debug.js: execution error: Error: ReferenceError: Can't find variable: y (-2700)", ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: ['--enable-dart-profiling', '--trace-allowlist="foo,bar"'], ); expect(logger.errorText, contains('Error executing osascript')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, false); }); testWithoutContext('fails if osascript output returns false status', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'debug', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--project-name', project.hostAppProjectName, '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', ], stdout: ''' {"status":false,"errorMessage":"Unable to find target device.","debugResult":null} ''', ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: ['--enable-dart-profiling', '--trace-allowlist="foo,bar"'], ); expect(logger.errorText, contains('Error starting debug session in Xcode')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, false); }); testWithoutContext('fails if missing debug results', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'debug', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--project-name', project.hostAppProjectName, '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: ['--enable-dart-profiling', '--trace-allowlist="foo,bar"'], ); expect(logger.errorText, contains('Unable to get debug results from response')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, false); }); testWithoutContext('fails if debug results status is not running', () async { fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'check-workspace-opened', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'debug', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--project-name', project.hostAppProjectName, '--device-id', deviceId, '--scheme', project.scheme, '--skip-building', '--launch-args', r'["--enable-dart-profiling","--trace-allowlist=\"foo,bar\""]', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":{"completed":false,"status":"not yet started","errorMessage":null}} ''', ), ]); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final bool status = await xcodeDebug.debugApp( project: project, deviceId: deviceId, launchArguments: ['--enable-dart-profiling', '--trace-allowlist="foo,bar"'], ); expect(logger.errorText, contains('Unexpected debug results')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, false); }); }); group('parse script response', () { testWithoutContext('fails if osascript output returns non-json output', () async { final Xcode xcode = setupXcode( fakeProcessManager: FakeProcessManager.any(), fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('not json'); expect(logger.errorText, contains('osascript returned non-JSON response')); expect(response, isNull); }); testWithoutContext('fails if osascript output returns unexpected json', () async { final Xcode xcode = setupXcode( fakeProcessManager: FakeProcessManager.any(), fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('[]'); expect(logger.errorText, contains('osascript returned unexpected JSON response')); expect(response, isNull); }); testWithoutContext('fails if osascript output is missing status field', () async { final Xcode xcode = setupXcode( fakeProcessManager: FakeProcessManager.any(), fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final XcodeAutomationScriptResponse? response = xcodeDebug.parseScriptResponse('{}'); expect(logger.errorText, contains('osascript returned unexpected JSON response')); expect(response, isNull); }); }); group('exit', () { const String pathToXcodeApp = '/Applications/Xcode.app'; late Directory projectDirectory; late Directory xcodeproj; late Directory xcworkspace; setUp(() { projectDirectory = fileSystem.directory('FlutterApp'); xcodeproj = projectDirectory.childDirectory('Runner.xcodeproj'); xcworkspace = projectDirectory.childDirectory('Runner.xcworkspace'); }); testWithoutContext('exits when waiting for debug session to start', () async { final Xcode xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebugProject project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'stop', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), ]); xcodeDebug.startDebugActionProcess = FakeProcess(); xcodeDebug.currentDebuggingProject = project; expect(xcodeDebug.startDebugActionProcess, isNotNull); expect(xcodeDebug.currentDebuggingProject, isNotNull); final bool exitStatus = await xcodeDebug.exit(); expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); expect(xcodeDebug.currentDebuggingProject, isNull); expect(logger.errorText, isEmpty); expect(fakeProcessManager, hasNoRemainingExpectations); expect(exitStatus, isTrue); }); testWithoutContext('exits and deletes temporary directory', () async { final Xcode xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); xcodeproj.createSync(recursive: true); xcworkspace.createSync(recursive: true); final XcodeDebugProject project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'stop', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--close-window', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), ]); xcodeDebug.startDebugActionProcess = FakeProcess(); xcodeDebug.currentDebuggingProject = project; expect(xcodeDebug.startDebugActionProcess, isNotNull); expect(xcodeDebug.currentDebuggingProject, isNotNull); expect(projectDirectory.existsSync(), isTrue); expect(xcodeproj.existsSync(), isTrue); expect(xcworkspace.existsSync(), isTrue); final bool status = await xcodeDebug.exit(skipDelay: true); expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); expect(xcodeDebug.currentDebuggingProject, isNull); expect(projectDirectory.existsSync(), isFalse); expect(xcodeproj.existsSync(), isFalse); expect(xcworkspace.existsSync(), isFalse); expect(logger.errorText, isEmpty); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, isTrue); }); testWithoutContext( 'prints error message when deleting temporary directory that is nonexistent', () async { final Xcode xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebugProject project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'stop', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--close-window', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), ]); xcodeDebug.startDebugActionProcess = FakeProcess(); xcodeDebug.currentDebuggingProject = project; expect(xcodeDebug.startDebugActionProcess, isNotNull); expect(xcodeDebug.currentDebuggingProject, isNotNull); expect(projectDirectory.existsSync(), isFalse); expect(xcodeproj.existsSync(), isFalse); expect(xcworkspace.existsSync(), isFalse); final bool status = await xcodeDebug.exit(skipDelay: true); expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); expect(xcodeDebug.currentDebuggingProject, isNull); expect(projectDirectory.existsSync(), isFalse); expect(xcodeproj.existsSync(), isFalse); expect(xcworkspace.existsSync(), isFalse); expect(logger.errorText, contains('Failed to delete temporary Xcode project')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, isTrue); }, ); testWithoutContext('kill Xcode when force exit', () async { final Xcode xcode = setupXcode( fakeProcessManager: FakeProcessManager.any(), fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebugProject project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); fakeProcessManager.addCommands([ const FakeCommand(command: ['killall', '-9', 'Xcode']), ]); xcodeDebug.startDebugActionProcess = FakeProcess(); xcodeDebug.currentDebuggingProject = project; expect(xcodeDebug.startDebugActionProcess, isNotNull); expect(xcodeDebug.currentDebuggingProject, isNotNull); final bool exitStatus = await xcodeDebug.exit(force: true); expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); expect(xcodeDebug.currentDebuggingProject, isNull); expect(logger.errorText, isEmpty); expect(fakeProcessManager, hasNoRemainingExpectations); expect(exitStatus, isTrue); }); testWithoutContext( 'does not crash when deleting temporary directory that is nonexistent when force exiting', () async { final Xcode xcode = setupXcode( fakeProcessManager: FakeProcessManager.any(), fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebugProject project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', isTemporaryProject: true, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: FakeProcessManager.any(), xcode: xcode, fileSystem: fileSystem, ); xcodeDebug.startDebugActionProcess = FakeProcess(); xcodeDebug.currentDebuggingProject = project; expect(xcodeDebug.startDebugActionProcess, isNotNull); expect(xcodeDebug.currentDebuggingProject, isNotNull); expect(projectDirectory.existsSync(), isFalse); expect(xcodeproj.existsSync(), isFalse); expect(xcworkspace.existsSync(), isFalse); final bool status = await xcodeDebug.exit(force: true); expect((xcodeDebug.startDebugActionProcess! as FakeProcess).killed, isTrue); expect(xcodeDebug.currentDebuggingProject, isNull); expect(projectDirectory.existsSync(), isFalse); expect(xcodeproj.existsSync(), isFalse); expect(xcworkspace.existsSync(), isFalse); expect(logger.errorText, isEmpty); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, isTrue); }, ); }); group('stop app', () { const String pathToXcodeApp = '/Applications/Xcode.app'; late Xcode xcode; late Directory xcodeproj; late Directory xcworkspace; late XcodeDebugProject project; setUp(() { xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); xcodeproj = fileSystem.directory('Runner.xcodeproj'); xcworkspace = fileSystem.directory('Runner.xcworkspace'); project = XcodeDebugProject( scheme: 'Runner', xcodeProject: xcodeproj, xcodeWorkspace: xcworkspace, hostAppProjectName: 'Runner', ); }); testWithoutContext('succeeds with all optional flags', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'stop', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--close-window', '--prompt-to-save', ], stdout: ''' {"status":true,"errorMessage":null,"debugResult":null} ''', ), ]); final bool status = await xcodeDebug.stopDebuggingApp( project: project, closeXcode: true, promptToSaveOnClose: true, ); expect(logger.errorText, isEmpty); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, isTrue); }); testWithoutContext('fails if osascript output returns false status', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); fakeProcessManager.addCommands([ FakeCommand( command: [ 'xcrun', 'osascript', '-l', 'JavaScript', pathToXcodeAutomationScript, 'stop', '--xcode-path', pathToXcodeApp, '--project-path', project.xcodeProject.path, '--workspace-path', project.xcodeWorkspace.path, '--close-window', '--prompt-to-save', ], stdout: ''' {"status":false,"errorMessage":"Failed to stop app","debugResult":null} ''', ), ]); final bool status = await xcodeDebug.stopDebuggingApp( project: project, closeXcode: true, promptToSaveOnClose: true, ); expect(logger.errorText, contains('Error stopping app in Xcode')); expect(fakeProcessManager, hasNoRemainingExpectations); expect(status, isFalse); }); }); group('ensureXcodeDebuggerLaunchAction', () { late Xcode xcode; setUp(() { xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); }); testWithoutContext('succeeds', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final File schemeFile = fileSystem.file( 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', ); schemeFile.createSync(recursive: true); schemeFile.writeAsStringSync(validSchemeXml); xcodeDebug.ensureXcodeDebuggerLaunchAction(schemeFile); expect(logger.errorText, isEmpty); }); testWithoutContext('prints error if scheme file not found', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final File schemeFile = fileSystem.file( 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', ); xcodeDebug.ensureXcodeDebuggerLaunchAction(schemeFile); expect(logger.errorText.contains('Failed to find'), isTrue); }); testWithoutContext('throws error if launch action is missing debugger info', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final File schemeFile = fileSystem.file( 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', ); schemeFile.createSync(recursive: true); schemeFile.writeAsStringSync(disabledDebugExecutableSchemeXml); expect( () => xcodeDebug.ensureXcodeDebuggerLaunchAction(schemeFile), throwsToolExit(message: 'Your Xcode project is not setup to start a debugger.'), ); }); testWithoutContext('prints error if unable to find launch action', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final File schemeFile = fileSystem.file( 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', ); schemeFile.createSync(recursive: true); schemeFile.writeAsStringSync(''); xcodeDebug.ensureXcodeDebuggerLaunchAction(schemeFile); expect(logger.errorText.contains('Failed to find LaunchAction for the Scheme'), isTrue); }); testWithoutContext('prints error if invalid xml', () async { final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: fileSystem, ); final File schemeFile = fileSystem.file( 'ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', ); schemeFile.createSync(recursive: true); schemeFile.writeAsStringSync(''); xcodeDebug.ensureXcodeDebuggerLaunchAction(schemeFile); expect(logger.errorText.contains('Failed to parse'), isTrue); }); }); }); group('Debug project through Xcode with app bundle', () { late BufferLogger logger; late FakeProcessManager fakeProcessManager; late MemoryFileSystem fileSystem; const String flutterRoot = '/path/to/flutter'; setUp(() { logger = BufferLogger.test(); fakeProcessManager = FakeProcessManager.empty(); fileSystem = MemoryFileSystem.test(); }); testUsingContext('creates temporary xcode project', () async { final Xcode xcode = setupXcode( fakeProcessManager: fakeProcessManager, fileSystem: fileSystem, flutterRoot: flutterRoot, ); final XcodeDebug xcodeDebug = XcodeDebug( logger: logger, processManager: fakeProcessManager, xcode: xcode, fileSystem: globals.fs, ); final Directory projectDirectory = globals.fs.systemTempDirectory.createTempSync( 'flutter_empty_xcode.', ); try { final XcodeDebugProject project = await xcodeDebug.createXcodeProjectWithCustomBundle( '/path/to/bundle', templateRenderer: globals.templateRenderer, projectDestination: projectDirectory, ); final File schemeFile = projectDirectory .childDirectory('Runner.xcodeproj') .childDirectory('xcshareddata') .childDirectory('xcschemes') .childFile('Runner.xcscheme'); expect(project.scheme, 'Runner'); expect(project.xcodeProject.existsSync(), isTrue); expect(project.xcodeWorkspace.existsSync(), isTrue); expect(project.isTemporaryProject, isTrue); expect(projectDirectory.childDirectory('Runner.xcodeproj').existsSync(), isTrue); expect(projectDirectory.childDirectory('Runner.xcworkspace').existsSync(), isTrue); expect(schemeFile.existsSync(), isTrue); expect(schemeFile.readAsStringSync(), contains('FilePath = "/path/to/bundle"')); } catch (err) { fail(err.toString()); } finally { projectDirectory.deleteSync(recursive: true); } }); }); } Xcode setupXcode({ required FakeProcessManager fakeProcessManager, required FileSystem fileSystem, required String flutterRoot, bool xcodeSelect = true, }) { fakeProcessManager.addCommand( const FakeCommand( command: ['/usr/bin/xcode-select', '--print-path'], stdout: '/Applications/Xcode.app/Contents/Developer', ), ); fileSystem .file('$flutterRoot/packages/flutter_tools/bin/xcode_debug.js') .createSync(recursive: true); final XcodeProjectInterpreter xcodeProjectInterpreter = XcodeProjectInterpreter.test( processManager: FakeProcessManager.any(), version: Version(14, 0, 0), ); return Xcode.test( processManager: fakeProcessManager, xcodeProjectInterpreter: xcodeProjectInterpreter, fileSystem: fileSystem, flutterRoot: flutterRoot, ); } class FakeProcess extends Fake implements Process { bool killed = false; @override bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { killed = true; return true; } } const String validSchemeXml = ''' '''; const String disabledDebugExecutableSchemeXml = ''' ''';