fix(cli): xcodeproj_flutter_override_validator should scan .ios/ for Flutter modules (#3719)
This commit is contained in:
+28
-41
@@ -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
|
||||
|
||||
+156
-47
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user