diff --git a/packages/shorebird_cli/lib/src/validators/xcodeproj_flutter_override_validator.dart b/packages/shorebird_cli/lib/src/validators/xcodeproj_flutter_override_validator.dart index 1c528745..781e3e71 100644 --- a/packages/shorebird_cli/lib/src/validators/xcodeproj_flutter_override_validator.dart +++ b/packages/shorebird_cli/lib/src/validators/xcodeproj_flutter_override_validator.dart @@ -4,41 +4,21 @@ import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; -/// Checks that ios/Runner.xcodeproj/project.pbxproj does *not* assign any -/// `FLUTTER_`-prefixed build setting. Flutter itself populates these at -/// runtime or via the generated `ios/Flutter/Generated.xcconfig` (which we -/// do not scan), so a hard-coded assignment in `project.pbxproj` can cause +/// Checks that the project's `Runner.xcodeproj/project.pbxproj` does *not* +/// assign any `FLUTTER_`-prefixed build setting. Flutter itself populates +/// these at runtime or via the generated `Flutter/Generated.xcconfig` (which +/// we do not scan), so a hard-coded assignment in `project.pbxproj` can cause /// different versions of Flutter to be used during different parts of the /// build, potentially causing build failures. +/// +/// Both standard Flutter apps (`ios/Runner.xcodeproj`) and Flutter modules +/// used for add-to-app (`.ios/Runner.xcodeproj`, generated by Flutter) are +/// covered. class XcodeprojFlutterOverrideValidator extends Validator { - static final String _iosRunnerXCodeProjPath = p.join( - 'ios', - 'Runner.xcodeproj', - ); - - /// Path to the project.pbxproj file. - static final String _projectPbxprojPath = p.join( - _iosRunnerXCodeProjPath, - 'project.pbxproj', - ); - @override String get description => 'Xcode project does not override FLUTTER_ build settings'; - @override - bool canRunInCurrentContext() => - _iosRunnerXcodeprojDirectory?.existsSync() ?? false; - - // coverage:ignore-start - @override - String get incorrectContextMessage => - ''' -The ${_iosRunnerXcodeprojDirectory?.path ?? _iosRunnerXCodeProjPath} directory does not exist. - -The command you are running must be run within a Flutter app project that supports the iOS platform.'''; - // coverage:ignore-end - @override Future> validate() async { final root = shorebirdEnv.getFlutterProjectRoot(); @@ -46,14 +26,18 @@ The command you are running must be run within a Flutter app project that suppor return []; } - final pbxProjFile = File(p.join(root.path, _projectPbxprojPath)); + final iosDir = _isFlutterModule() ? '.ios' : 'ios'; + final pbxprojRelativePath = p.join( + iosDir, + 'Runner.xcodeproj', + 'project.pbxproj', + ); + final pbxProjFile = File(p.join(root.path, pbxprojRelativePath)); if (!pbxProjFile.existsSync()) { - return [ - ValidationIssue( - severity: ValidationIssueSeverity.error, - message: '''No project.pbxproj file found at $_projectPbxprojPath''', - ), - ]; + // No Xcode project to scan: a module that hasn't been `pub get`-ed yet, + // an app without the iOS platform, or a non-Flutter project. Nothing + // to validate. + return []; } final overrides = _flutterOverridesIn(pbxProjFile); @@ -66,18 +50,21 @@ The command you are running must be run within a Flutter app project that suppor ValidationIssue( severity: ValidationIssueSeverity.error, message: - '$_projectPbxprojPath overrides FLUTTER_ build setting(s): $names. ' + '$pbxprojRelativePath overrides FLUTTER_ build setting(s): $names. ' 'FLUTTER_* variables are set by Flutter (at runtime or via ' - 'ios/Flutter/Generated.xcconfig) and should not be hard-coded ' + '$iosDir/Flutter/Generated.xcconfig) and should not be hard-coded ' 'in the Xcode project.', ), ]; } - Directory? get _iosRunnerXcodeprojDirectory { - final root = shorebirdEnv.getFlutterProjectRoot(); - if (root == null) return null; - return Directory(p.join(root.path, 'ios', 'Runner.xcodeproj')); + /// Whether the current project is a Flutter module (i.e. its `pubspec.yaml` + /// declares a `flutter > module` section). Modules expose their iOS Xcode + /// project under `.ios/` rather than `ios/`. + bool _isFlutterModule() { + final pubspec = shorebirdEnv.getPubspecYaml(); + final module = pubspec?.flutter?['module'] as Map?; + return module != null; } /// Returns the distinct set of `FLUTTER_*` names that are *assigned* in the diff --git a/packages/shorebird_cli/test/src/validators/xcodeproj_flutter_override_validator_test.dart b/packages/shorebird_cli/test/src/validators/xcodeproj_flutter_override_validator_test.dart index c5e9779a..169f6917 100644 --- a/packages/shorebird_cli/test/src/validators/xcodeproj_flutter_override_validator_test.dart +++ b/packages/shorebird_cli/test/src/validators/xcodeproj_flutter_override_validator_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; +import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; @@ -9,15 +10,33 @@ import 'package:test/test.dart'; import '../mocks.dart'; +const _appPubspec = ''' +name: my_app +environment: + sdk: ^3.0.0 +flutter: + uses-material-design: true +'''; + +const _modulePubspec = ''' +name: my_flutter_module +environment: + sdk: ^3.0.0 +flutter: + module: + androidPackage: com.example.my_flutter_module + iosBundleIdentifier: com.example.myFlutterModule +'''; + void main() { group(XcodeprojFlutterOverrideValidator, () { late Directory projectRoot; late ShorebirdEnv shorebirdEnv; late XcodeprojFlutterOverrideValidator validator; - void writePbxprojFile(String contents) { + void writePbxprojFile(String contents, {String iosDir = 'ios'}) { final xcodeprojDir = Directory( - p.join(projectRoot.path, 'ios', 'Runner.xcodeproj'), + p.join(projectRoot.path, iosDir, 'Runner.xcodeproj'), )..createSync(recursive: true); File( p.join(xcodeprojDir.path, 'project.pbxproj'), @@ -37,6 +56,10 @@ void main() { validator = XcodeprojFlutterOverrideValidator(); when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(projectRoot); + // Default to a non-module pubspec; module-specific tests override. + when( + () => shorebirdEnv.getPubspecYaml(), + ).thenReturn(Pubspec.parse(_appPubspec)); }); test('has a non-empty description', () { @@ -47,38 +70,6 @@ void main() { ); }); - group('canRunInCurrentContext', () { - test('returns false if no ios/Runner.xcodeproj directory exists', () { - final result = runWithOverrides( - () => validator.canRunInCurrentContext(), - ); - - expect(result, isFalse); - }); - - test('returns true if ios/Runner.xcodeproj directory exists', () { - Directory( - p.join(projectRoot.path, 'ios', 'Runner.xcodeproj'), - ).createSync(recursive: true); - - final result = runWithOverrides( - () => validator.canRunInCurrentContext(), - ); - - expect(result, isTrue); - }); - - test('returns false if project root is null', () { - when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(null); - - final result = runWithOverrides( - () => validator.canRunInCurrentContext(), - ); - - expect(result, isFalse); - }); - }); - group('validate', () { test('returns no issues if project root is null', () async { when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(null); @@ -88,21 +79,35 @@ void main() { expect(results, isEmpty); }); - test('returns error if project.pbxproj file does not exist', () async { - Directory( - p.join(projectRoot.path, 'ios', 'Runner.xcodeproj'), - ).createSync(recursive: true); + test( + 'returns no issues if ios/Runner.xcodeproj directory does not exist ' + '(e.g. Flutter module / no iOS platform)', + () async { + // No ios/Runner.xcodeproj created — simulates a Flutter module or + // an app without the iOS platform. The validator must silently + // skip rather than error, otherwise commands like + // `shorebird release ios-framework` are blocked. - final results = await runWithOverrides(validator.validate); + final results = await runWithOverrides(validator.validate); - expect(results, hasLength(1)); - expect(results.first.severity, ValidationIssueSeverity.error); - expect( - results.first.message, - startsWith('No project.pbxproj file found at'), - ); - expect(results.first.fix, isNull); - }); + expect(results, isEmpty); + }, + ); + + test( + 'returns no issues if project.pbxproj file does not exist', + () async { + // Edge case: the Runner.xcodeproj directory exists but its + // project.pbxproj file is missing. Treat as nothing-to-validate. + Directory( + p.join(projectRoot.path, 'ios', 'Runner.xcodeproj'), + ).createSync(recursive: true); + + final results = await runWithOverrides(validator.validate); + + expect(results, isEmpty); + }, + ); test( 'returns successful result if project.pbxproj has no FLUTTER_ ' @@ -387,6 +392,110 @@ void main() { expect(results, isEmpty); }); }); + + // Flutter modules (used for add-to-app) keep their generated Xcode + // project under `.ios/Runner.xcodeproj` rather than `ios/Runner.xcodeproj`. + // The validator must scan that path; otherwise commands like + // `shorebird release ios-framework` either skip validation entirely or + // (worse) hard-fail because they can't find an `ios/Runner.xcodeproj`. + group('Flutter module (.ios/)', () { + setUp(() { + when( + () => shorebirdEnv.getPubspecYaml(), + ).thenReturn(Pubspec.parse(_modulePubspec)); + }); + + test( + 'returns no issues if .ios/Runner.xcodeproj/project.pbxproj does ' + 'not exist (e.g. before flutter pub get)', + () async { + final results = await runWithOverrides(validator.validate); + + expect(results, isEmpty); + }, + ); + + test( + 'returns no issues if .ios/Runner.xcodeproj/project.pbxproj has ' + 'no FLUTTER_ assignments', + () async { + const pbxprojContent = r''' +// !$*UTF8*$! +{ + archiveVersion = 1; + buildSettings = { + PRODUCT_NAME = Runner; + }; +} +'''; + writePbxprojFile(pbxprojContent, iosDir: '.ios'); + + final results = await runWithOverrides(validator.validate); + + expect(results, isEmpty); + }, + ); + + test( + 'detects FLUTTER_ROOT in .ios/Runner.xcodeproj/project.pbxproj', + () async { + const pbxprojContent = r''' +// !$*UTF8*$! +{ + archiveVersion = 1; + buildSettings = { + FLUTTER_ROOT = /path/to/flutter; + }; +} +'''; + writePbxprojFile(pbxprojContent, iosDir: '.ios'); + + final results = await runWithOverrides(validator.validate); + + expect(results, hasLength(1)); + expect(results.first.severity, ValidationIssueSeverity.error); + expect( + results.first.message, + allOf( + contains(p.join('.ios', 'Runner.xcodeproj', 'project.pbxproj')), + contains('FLUTTER_ROOT'), + ), + ); + }, + ); + + test( + 'does not look at ios/ when project is a module', + () async { + // A module that for some reason also has an `ios/Runner.xcodeproj` + // (e.g. left behind from a previous `flutter create`) should still + // be checked at `.ios/`, not `ios/`. + const appPbxprojWithOverride = r''' +// !$*UTF8*$! +{ + archiveVersion = 1; + FLUTTER_ROOT = /should/not/be/scanned; +} +'''; + writePbxprojFile(appPbxprojWithOverride, iosDir: 'ios'); + // Module project has no overrides. + const modulePbxproj = r''' +// !$*UTF8*$! +{ + archiveVersion = 1; + buildSettings = { + PRODUCT_NAME = Runner; + }; +} +'''; + writePbxprojFile(modulePbxproj, iosDir: '.ios'); + + final results = await runWithOverrides(validator.validate); + + expect(results, isEmpty); + }, + ); + }); }); }); }