fix(cli): xcodeproj_flutter_override_validator should scan .ios/ for Flutter modules (#3719)

This commit is contained in:
Brandon DeRosier
2026-04-28 06:08:54 -07:00
committed by GitHub
parent 291dc389d5
commit cfc18286f8
2 changed files with 184 additions and 88 deletions
@@ -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<List<ValidationIssue>> 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
@@ -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);
},
);
});
});
});
}