diff --git a/.ci.yaml b/.ci.yaml index 93b3c65449..84735200fb 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -3926,6 +3926,16 @@ targets: ["devicelab", "ios", "mac"] task_name: flavors_test_ios + - name: Mac_arm64_ios flavors_test_ios_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: flavors_test_ios_xcode_debug + bringup: true + - name: Mac_ios flutter_gallery_ios__compile recipe: devicelab/devicelab_drone presubmit: false diff --git a/TESTOWNERS b/TESTOWNERS index d268226c3a..f6ee9bf3b0 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -174,6 +174,7 @@ /dev/devicelab/bin/tasks/cubic_bezier_perf_ios_sksl_warmup__timeline_summary.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/external_ui_integration_test_ios.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/flavors_test_ios.dart @vashworth @flutter/tool +/dev/devicelab/bin/tasks/flavors_test_ios_xcode_debug.dart @vashworth @flutter/tool /dev/devicelab/bin/tasks/flutter_gallery__transition_perf_e2e_ios.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/flutter_gallery_ios__compile.dart @vashworth @flutter/engine /dev/devicelab/bin/tasks/flutter_gallery_ios__start_up.dart @vashworth @flutter/engine diff --git a/dev/devicelab/bin/tasks/flavors_test_ios_xcode_debug.dart b/dev/devicelab/bin/tasks/flavors_test_ios_xcode_debug.dart new file mode 100644 index 0000000000..88565dfecf --- /dev/null +++ b/dev/devicelab/bin/tasks/flavors_test_ios_xcode_debug.dart @@ -0,0 +1,52 @@ +// 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 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/framework/task_result.dart'; +import 'package:flutter_devicelab/framework/utils.dart'; +import 'package:flutter_devicelab/tasks/integration_tests.dart'; + +Future main() async { + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(() async { + await createFlavorsTest(environment: { + 'FORCE_XCODE_DEBUG': 'true', + }).call(); + await createIntegrationTestFlavorsTest(environment: { + 'FORCE_XCODE_DEBUG': 'true', + }).call(); + // test install and uninstall of flavors app + final TaskResult installTestsResult = await inDirectory( + '${flutterDirectory.path}/dev/integration_tests/flavors', + () async { + await flutter( + 'install', + options: ['--flavor', 'paid'], + ); + await flutter( + 'install', + options: ['--flavor', 'paid', '--uninstall-only'], + ); + final StringBuffer stderr = StringBuffer(); + await evalFlutter( + 'install', + canFail: true, + stderr: stderr, + options: ['--flavor', 'bogus'], + ); + + final String stderrString = stderr.toString(); + if (!stderrString.contains('The Xcode project defines schemes: free, paid')) { + print(stderrString); + return TaskResult.failure('Should not succeed with bogus flavor'); + } + + return TaskResult.success(null); + }, + ); + + return installTestsResult; + }); +} diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart index c06e717874..f3177e1f10 100644 --- a/dev/devicelab/lib/tasks/integration_tests.dart +++ b/dev/devicelab/lib/tasks/integration_tests.dart @@ -22,19 +22,21 @@ TaskFunction createPlatformInteractionTest() { ).call; } -TaskFunction createFlavorsTest() { +TaskFunction createFlavorsTest({Map? environment}) { return DriverTest( '${flutterDirectory.path}/dev/integration_tests/flavors', 'lib/main.dart', extraOptions: ['--flavor', 'paid'], + environment: environment, ).call; } -TaskFunction createIntegrationTestFlavorsTest() { +TaskFunction createIntegrationTestFlavorsTest({Map? environment}) { return IntegrationTest( '${flutterDirectory.path}/dev/integration_tests/flavors', 'integration_test/integration_test.dart', extraOptions: ['--flavor', 'paid'], + environment: environment, ).call; } @@ -219,6 +221,7 @@ class IntegrationTest { this.extraOptions = const [], this.createPlatforms = const [], this.withTalkBack = false, + this.environment, } ); @@ -227,6 +230,7 @@ class IntegrationTest { final List extraOptions; final List createPlatforms; final bool withTalkBack; + final Map? environment; Future call() { return inDirectory(testDirectory, () async { @@ -258,7 +262,7 @@ class IntegrationTest { testTarget, ...extraOptions, ]; - await flutter('test', options: options); + await flutter('test', options: options, environment: environment); if (withTalkBack) { await disableTalkBack(); diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 56cfa87ae6..cf2f0249cc 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -877,6 +877,8 @@ class IOSDevice extends Device { projectInfo.reportFlavorNotFoundAndExit(); } + _xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme)); + debugProject = XcodeDebugProject( scheme: scheme, xcodeProject: project.xcodeProject, diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart index 563ec9d8e3..778bc1af72 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_debug.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -6,7 +6,10 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; +import 'package:xml/xml.dart'; +import 'package:xml/xpath.dart'; +import '../base/common.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/io.dart'; @@ -58,7 +61,6 @@ class XcodeDebug { required String deviceId, required List launchArguments, }) async { - // If project is not already opened in Xcode, open it. if (!await _isProjectOpenInXcode(project: project)) { final bool openResult = await _openProjectInXcode(xcodeWorkspace: project.xcodeWorkspace); @@ -411,6 +413,49 @@ class XcodeDebug { verboseLogging: verboseLogging, ); } + + /// Ensure the Xcode project is set up to launch an LLDB debugger. If these + /// settings are not set, the launch will fail with a "Cannot create a + /// FlutterEngine instance in debug mode without Flutter tooling or Xcode." + /// error message. These settings should be correct by default, but some users + /// reported them not being so after upgrading to Xcode 15. + void ensureXcodeDebuggerLaunchAction(File schemeFile) { + if (!schemeFile.existsSync()) { + _logger.printError('Failed to find ${schemeFile.path}'); + return; + } + + final String schemeXml = schemeFile.readAsStringSync(); + try { + final XmlDocument document = XmlDocument.parse(schemeXml); + final Iterable nodes = document.xpath('/Scheme/LaunchAction'); + if (nodes.isEmpty) { + _logger.printError('Failed to find LaunchAction for the Scheme in ${schemeFile.path}.'); + return; + } + final XmlNode launchAction = nodes.first; + final XmlAttribute? debuggerIdentifer = launchAction.attributes + .where((XmlAttribute attribute) => + attribute.localName == 'selectedDebuggerIdentifier') + .firstOrNull; + final XmlAttribute? launcherIdentifer = launchAction.attributes + .where((XmlAttribute attribute) => + attribute.localName == 'selectedLauncherIdentifier') + .firstOrNull; + if (debuggerIdentifer == null || + launcherIdentifer == null || + !debuggerIdentifer.value.contains('LLDB') || + !launcherIdentifer.value.contains('LLDB')) { + throwToolExit(''' +Your Xcode project is not setup to start a debugger. To fix this, launch Xcode +and select "Product > Scheme > Edit Scheme", select "Run" in the sidebar, +and ensure "Debug executable" is checked in the "Info" tab. +'''); + } + } on XmlException catch (exception) { + _logger.printError('Failed to parse ${schemeFile.path}: $exception'); + } + } } @visibleForTesting diff --git a/packages/flutter_tools/lib/src/migrations/xcode_project_object_version_migration.dart b/packages/flutter_tools/lib/src/migrations/xcode_project_object_version_migration.dart index 36731a151c..4fa9bd83ba 100644 --- a/packages/flutter_tools/lib/src/migrations/xcode_project_object_version_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/xcode_project_object_version_migration.dart @@ -12,7 +12,7 @@ class XcodeProjectObjectVersionMigration extends ProjectMigrator { XcodeBasedProject project, super.logger, ) : _xcodeProjectInfoFile = project.xcodeProjectInfoFile, - _xcodeProjectSchemeFile = project.xcodeProjectSchemeFile; + _xcodeProjectSchemeFile = project.xcodeProjectSchemeFile(); final File _xcodeProjectInfoFile; final File _xcodeProjectSchemeFile; diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index 48e7041d8a..afc544bb9d 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -68,8 +68,10 @@ abstract class XcodeBasedProject extends FlutterProjectPlatform { File get xcodeProjectInfoFile => xcodeProject.childFile('project.pbxproj'); /// The 'Runner.xcscheme' file of [xcodeProject]. - File get xcodeProjectSchemeFile => - xcodeProject.childDirectory('xcshareddata').childDirectory('xcschemes').childFile('Runner.xcscheme'); + File xcodeProjectSchemeFile({String? scheme}) { + final String schemeName = scheme ?? 'Runner'; + return xcodeProject.childDirectory('xcshareddata').childDirectory('xcschemes').childFile('$schemeName.xcscheme'); + } File get xcodeProjectWorkspaceData => xcodeProject diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index 68d78e5f9b..ebbcb4946c 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -520,6 +520,81 @@ void main() { Xcode: () => xcode, }); + group('with flavor', () { + setUp(() { + projectInfo = XcodeProjectInfo( + ['Runner'], + ['Debug', 'Release', 'Debug-free', 'Release-free'], + ['Runner', 'free'], + logger, + ); + fakeXcodeProjectInterpreter = FakeXcodeProjectInterpreter(projectInfo: projectInfo); + xcode = Xcode.test(processManager: FakeProcessManager.any(), xcodeProjectInterpreter: fakeXcodeProjectInterpreter); + }); + + testUsingContext('succeeds', () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'free', + xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), + xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + hostAppProjectName: 'Runner', + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedSchemeFilePath: '/ios/Runner.xcodeproj/xcshareddata/xcschemes/free.xcscheme', + ), + ); + + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + 'free', + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + + expect(logger.errorText, isEmpty); + expect(fileSystem.directory('build/ios/iphoneos'), exists); + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + }); + testUsingContext('updates Generated.xcconfig before and after launch', () async { final Completer debugStartedCompleter = Completer(); final Completer debugEndedCompleter = Completer(); @@ -829,6 +904,7 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { this.expectedProject, this.expectedDeviceId, this.expectedLaunchArguments, + this.expectedSchemeFilePath = '/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', this.debugStartedCompleter, this.debugEndedCompleter, }); @@ -840,6 +916,7 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { final List? expectedLaunchArguments; final Completer? debugStartedCompleter; final Completer? debugEndedCompleter; + final String expectedSchemeFilePath; @override Future debugApp({ @@ -863,6 +940,11 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { await debugEndedCompleter?.future; return debugSuccess; } + + @override + void ensureXcodeDebuggerLaunchAction(File schemeFile) { + expect(schemeFile.path, expectedSchemeFilePath); + } } class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { diff --git a/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart index 3f2277a22c..5e5432eafd 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_project_migration_test.dart @@ -662,7 +662,7 @@ platform :ios, '11.0' project.xcodeProjectInfoFile = xcodeProjectInfoFile; xcodeProjectSchemeFile = memoryFileSystem.file('Runner.xcscheme'); - project.xcodeProjectSchemeFile = xcodeProjectSchemeFile; + project.schemeFile = xcodeProjectSchemeFile; }); testWithoutContext('skipped if files are missing', () { @@ -1370,8 +1370,10 @@ class FakeIosProject extends Fake implements IosProject { @override File xcodeProjectInfoFile = MemoryFileSystem.test().file('xcodeProjectInfoFile'); + File? schemeFile; + @override - File xcodeProjectSchemeFile = MemoryFileSystem.test().file('xcodeProjectSchemeFile'); + File xcodeProjectSchemeFile({String? scheme}) => schemeFile ?? MemoryFileSystem.test().file('xcodeProjectSchemeFile'); @override File appFrameworkInfoPlist = MemoryFileSystem.test().file('appFrameworkInfoPlist'); diff --git a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart index 0fe3a8aa9b..ae0efb7cb4 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcode_debug_test.dart @@ -1064,6 +1064,96 @@ void main() { 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', () { @@ -1161,3 +1251,89 @@ class FakeProcess extends Fake implements Process { return true; } } + +const String validSchemeXml = ''' + + + + + + + + + + + + + + + +'''; + +const String disabledDebugExecutableSchemeXml = ''' + + + + + + + + + + + + + + + +''';