diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f77584dea..318631d40 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -43,6 +43,7 @@ jobs: - test/commands/create/flame_game/flame_game_test.dart - test/commands/create/flutter_package/flutter_pkg_test.dart - test/commands/create/flutter_plugin/flutter_plugin_test.dart + - test/commands/create/very_good_config/very_good_config_test.dart # E2E tests for the `packages check licenses` command - test/commands/packages/check/licenses/licenses_allowed_test.dart diff --git a/e2e/test/commands/create/very_good_config/fixture/very_good.yaml b/e2e/test/commands/create/very_good_config/fixture/very_good.yaml new file mode 100644 index 000000000..5510485a5 --- /dev/null +++ b/e2e/test/commands/create/very_good_config/fixture/very_good.yaml @@ -0,0 +1,3 @@ +create: + description: A project configured via very_good.yaml. + publishable: true diff --git a/e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml b/e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml new file mode 100644 index 000000000..149ff8996 --- /dev/null +++ b/e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml @@ -0,0 +1,3 @@ +- not +- a +- map diff --git a/e2e/test/commands/create/very_good_config/very_good_config_test.dart b/e2e/test/commands/create/very_good_config/very_good_config_test.dart new file mode 100644 index 000000000..050e24c15 --- /dev/null +++ b/e2e/test/commands/create/very_good_config/very_good_config_test.dart @@ -0,0 +1,85 @@ +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:universal_io/io.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + group('very_good.yaml', () { + test( + 'applies create defaults from very_good.yaml when flags are not passed', + timeout: const Timeout(Duration(minutes: 2)), + withRunner((commandRunner, logger, updater, logs, progressLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync( + 'very_good_config_create', + ); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final fixture = Directory( + path.join( + Directory.current.path, + 'test/commands/create/very_good_config/fixture', + ), + ); + + await copyDirectory(fixture, tempDirectory); + + final cwd = Directory.current; + Directory.current = tempDirectory; + addTearDown(() => Directory.current = cwd); + + final result = await commandRunner.run([ + 'create', + 'dart_package', + 'very_good_dart', + ]); + expect(result, equals(ExitCode.success.code)); + + final pubspec = File( + path.join(tempDirectory.path, 'very_good_dart', 'pubspec.yaml'), + ); + expect(pubspec.existsSync(), isTrue); + expect( + pubspec.readAsStringSync(), + contains('A project configured via very_good.yaml.'), + ); + }), + ); + + test( + 'fails with config exit code when very_good.yaml is malformed', + timeout: const Timeout(Duration(minutes: 2)), + withRunner((commandRunner, logger, updater, logs, progressLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync( + 'very_good_config_create_malformed', + ); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final fixture = Directory( + path.join( + Directory.current.path, + 'test/commands/create/very_good_config/malformed_fixture', + ), + ); + + await copyDirectory(fixture, tempDirectory); + + final cwd = Directory.current; + Directory.current = tempDirectory; + addTearDown(() => Directory.current = cwd); + + await expectLater( + commandRunner.run(['create', 'dart_package', 'very_good_dart']), + completion(equals(ExitCode.config.code)), + ); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }), + ); + }); +} diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart index 3f2e04225..9e8da3ea7 100644 --- a/lib/src/commands/create/commands/create_subcommand.dart +++ b/lib/src/commands/create/commands/create_subcommand.dart @@ -8,6 +8,7 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/commands/commands.dart'; import 'package:very_good_cli/src/commands/create/templates/templates.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; // A valid Dart identifier that can be used for a package, i.e. no // capital letters. @@ -118,6 +119,14 @@ abstract class CreateSubCommand extends Command { final Logger logger; final MasonGeneratorFromBundle _generatorFromBundle; + /// The resolved `very_good create` configuration read from the closest + /// `very_good.yaml` file. + /// + /// Values declared here are used as defaults for any option that was not + /// explicitly passed on the command line. It defaults to an empty + /// configuration until [run] loads it. + VeryGoodCreateConfig createConfig = const VeryGoodCreateConfig(); + /// [ArgResults] which can be overridden for testing. @visibleForTesting ArgResults? argResultOverrides; @@ -174,7 +183,11 @@ abstract class CreateSubCommand extends Command { } /// Gets the description for the project. - String get projectDescription => argResults['description'] as String? ?? ''; + String get projectDescription => argResults.resolve( + 'description', + createConfig.description, + fallbackValue: '', + ); /// Should return the desired template to be created during a command run. /// @@ -194,6 +207,10 @@ abstract class CreateSubCommand extends Command { @override Future run() async { + final config = VeryGoodConfig.load(Directory.current, logger: logger); + if (config == null) return ExitCode.config.code; + createConfig = config.create; + final template = this.template; final bundle = template.bundle; @@ -273,7 +290,11 @@ abstract class CreateSubCommand extends Command { mixin OrgName on CreateSubCommand { /// Gets the organization name. String get orgName { - final orgName = argResults['org-name'] as String? ?? _defaultOrgName; + final orgName = argResults.resolve( + 'org-name', + createConfig.orgName, + fallbackValue: _defaultOrgName, + ); _validateOrgName(orgName); return orgName; } @@ -317,10 +338,26 @@ mixin MultiTemplates on CreateSubCommand { @nonVirtual @override Template get template { - final templateName = - argResults['template'] as String? ?? defaultTemplateName; + final templateName = argResults.resolve( + 'template', + createConfig.template, + fallbackValue: defaultTemplateName, + ); - return templates.firstWhere((template) => template.name == templateName); + return templates.firstWhere( + (template) => template.name == templateName, + orElse: () { + // When the value came from `very_good.yaml` rather than the command + // line, point the user at the config key instead of the CLI option so + // they debug the right place. + final source = argResults.wasParsed('template') + ? 'option "--template"' + : 'the `create.template` key in `$veryGoodConfigFileName`'; + usageException( + '"$templateName" is not an allowed value for $source.', + ); + }, + ); } } @@ -331,7 +368,11 @@ mixin MultiTemplates on CreateSubCommand { /// to the brick generator. mixin Publishable on CreateSubCommand { /// Gets the publishable flag. - bool get publishable => argResults['publishable'] as bool? ?? false; + bool get publishable => argResults.resolve( + 'publishable', + createConfig.publishable, + fallbackValue: false, + ); } /// Mixin for [CreateSubCommand] subclasses that receives the workspace flag. @@ -340,5 +381,9 @@ mixin Publishable on CreateSubCommand { /// to the brick generator. mixin Workspace on CreateSubCommand { /// Gets the workspace flag. - bool get workspace => argResults['workspace'] as bool? ?? false; + bool get workspace => argResults.resolve( + 'workspace', + createConfig.workspace, + fallbackValue: false, + ); } diff --git a/lib/src/commands/create/commands/docs_site.dart b/lib/src/commands/create/commands/docs_site.dart index d05060785..a4653e366 100644 --- a/lib/src/commands/create/commands/docs_site.dart +++ b/lib/src/commands/create/commands/docs_site.dart @@ -1,5 +1,6 @@ import 'package:very_good_cli/src/commands/commands.dart'; import 'package:very_good_cli/src/commands/create/templates/templates.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; /// {@template very_good_create_docs_site} /// A [CreateSubCommand] for creating Dart command line interfaces. @@ -30,7 +31,11 @@ class CreateDocsSite extends CreateSubCommand with Publishable { Map getTemplateVars() { return { ...super.getTemplateVars(), - 'org_name': argResults['org-name'], + 'org_name': argResults.resolve( + 'org-name', + createConfig.orgName, + fallbackValue: _defaultOrgName, + ), }; } diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 4cea82b6c..5fe0cc6d0 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -283,7 +283,7 @@ class DartTestCommand extends Command { help: 'Whether to collect coverage from imported files only or all ' 'files.', - allowed: ['imports', 'all'], + allowed: collectCoverageFromAllowedValues, defaultsTo: 'imports', valueHelp: 'imports|all', ) @@ -369,16 +369,8 @@ This command should be run from the root of your Dart project.'''); return ExitCode.noInput.code; } - final VeryGoodConfig config; - try { - config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); - } on VeryGoodConfigParseException catch (e) { - _logger.err( - 'Could not read `$veryGoodConfigFileName`.\n' - '${e.message}', - ); - return ExitCode.config.code; - } + final config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final isDartInstalled = await _dartInstalled(logger: _logger); diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart index 0c4cdbf1d..07c361d82 100644 --- a/lib/src/commands/packages/commands/check/commands/licenses.dart +++ b/lib/src/commands/packages/commands/check/commands/licenses.dart @@ -157,12 +157,7 @@ class PackagesCheckLicensesCommand extends Command { ..addMultiOption( 'dependency-type', help: 'The type of dependencies to check licenses for.', - allowed: [ - 'direct-main', - 'direct-dev', - 'direct-overridden', - 'transitive', - ], + allowed: dependencyTypeAllowedValues, allowedHelp: { 'direct-main': 'Check for direct main dependencies.', 'direct-dev': 'Check for direct dev dependencies.', @@ -183,7 +178,7 @@ class PackagesCheckLicensesCommand extends Command { ..addOption( 'reporter', help: 'Lists all licenses.', - allowed: ['text', 'csv'], + allowed: reporterAllowedValues, allowedHelp: { 'text': 'Lists licenses without a specific format.', 'csv': 'Lists licenses in a CSV format.', @@ -211,16 +206,8 @@ class PackagesCheckLicensesCommand extends Command { final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.'; final targetPath = path.normalize(Directory(target).absolute.path); - final VeryGoodConfig config; - try { - config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); - } on VeryGoodConfigParseException catch (e) { - _logger.err( - 'Could not read `$veryGoodConfigFileName`.\n' - '${e.message}', - ); - return ExitCode.config.code; - } + final config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final options = PackagesCheckLicensesOptions.parse( _argResults, diff --git a/lib/src/commands/packages/commands/get.dart b/lib/src/commands/packages/commands/get.dart index f85acf4dc..8ab11d4b7 100644 --- a/lib/src/commands/packages/commands/get.dart +++ b/lib/src/commands/packages/commands/get.dart @@ -85,16 +85,8 @@ class PackagesGetCommand extends Command { final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.'; final targetPath = path.normalize(Directory(target).absolute.path); - final VeryGoodConfig config; - try { - config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); - } on VeryGoodConfigParseException catch (e) { - _logger.err( - 'Could not read `$veryGoodConfigFileName`.\n' - '${e.message}', - ); - return ExitCode.config.code; - } + final config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final options = PackagesGetOptions.parse(_argResults, config: config); diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index cfe0ce8ac..3e9dd091e 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -333,7 +333,7 @@ class TestCommand extends Command { help: 'Whether to collect coverage from imported files only or all ' 'files.', - allowed: ['imports', 'all'], + allowed: collectCoverageFromAllowedValues, defaultsTo: 'imports', valueHelp: 'imports|all', ) @@ -458,16 +458,8 @@ This command should be run from the root of your Flutter project.'''); return ExitCode.noInput.code; } - final VeryGoodConfig config; - try { - config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); - } on VeryGoodConfigParseException catch (e) { - _logger.err( - 'Could not read `$veryGoodConfigFileName`.\n' - '${e.message}', - ); - return ExitCode.config.code; - } + final config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final isFlutterInstalled = await _flutterInstalled(logger: _logger); diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart index 75f6be931..7d698ac5c 100644 --- a/lib/src/mcp/mcp_server.dart +++ b/lib/src/mcp/mcp_server.dart @@ -328,6 +328,20 @@ Only one value can be selected. ); } + /// The `create` subcommands that define the `--workspace` flag. + /// + /// `docs_site` is intentionally excluded because it does not mix in the + /// `Workspace` mixin and therefore rejects the flag. + static const _workspaceSubcommands = { + 'app_ui_package', + 'flame_game', + 'flutter_app', + 'flutter_package', + 'flutter_plugin', + 'dart_cli', + 'dart_package', + }; + List _parseCreate(Map args) { final subcommand = args['subcommand']! as String; final name = args['name']! as String; @@ -352,10 +366,12 @@ Only one value can be selected. if (args['publishable'] == true) { cliArgs.add('--publishable'); } - if (args['workspace'] == true) { - cliArgs.add('--workspace'); - } else if (args['workspace'] == false) { - cliArgs.add('--no-workspace'); + if (_workspaceSubcommands.contains(subcommand)) { + if (args['workspace'] == true) { + cliArgs.add('--workspace'); + } else if (args['workspace'] == false) { + cliArgs.add('--no-workspace'); + } } if (args['executable-name'] != null) { cliArgs.addAll(['--executable-name', args['executable-name']! as String]); diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index cd5973a7e..c3329c9e0 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -11,6 +11,7 @@ import 'package:args/args.dart'; import 'package:checked_yaml/checked_yaml.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as path; part 'very_good_config.g.dart'; @@ -71,6 +72,7 @@ class VeryGoodConfig extends Equatable { /// {@macro very_good_config} const VeryGoodConfig({ this.test = const VeryGoodTestConfig(), + this.create = const VeryGoodCreateConfig(), this.dart = const VeryGoodDartConfig(), this.packages = const VeryGoodPackagesConfig(), }); @@ -105,24 +107,25 @@ class VeryGoodConfig extends Equatable { /// Loads the closest [VeryGoodConfig] by searching [directory] and each of /// its ancestors, from the innermost directory outward. /// - /// [directory] is resolved to an absolute path before the walk, so a relative - /// [directory] is searched relative to the current working directory. - /// - /// This allows a single repository-wide `very_good.yaml` at the project root - /// to apply to commands run from any nested package directory. The first - /// configuration file encountered wins; ancestors are not merged. - /// /// Returns [VeryGoodConfig.empty] when no configuration file is found. - /// Throws a [VeryGoodConfigParseException] when the closest file exists but - /// cannot be parsed. - factory VeryGoodConfig.loadFromClosestAncestor(Directory directory) { - var current = directory.absolute; - while (true) { - final config = _loadFromDirectory(current); - if (config != null) return config; - final parent = current.parent; - if (parent.path == current.path) return VeryGoodConfig.empty; - current = parent; + /// + /// On a parse failure, logs a formatted error via [logger] and returns + /// `null`, signalling that the caller should exit with a config error. + static VeryGoodConfig? load(Directory directory, {required Logger logger}) { + try { + var current = directory.absolute; + while (true) { + final config = _loadFromDirectory(current); + if (config != null) return config; + final parent = current.parent; + if (parent.path == current.path) return VeryGoodConfig.empty; + current = parent; + } + } on VeryGoodConfigParseException catch (e) { + logger.err( + 'Could not read `$veryGoodConfigFileName`.\n${e.message}', + ); + return null; } } @@ -146,6 +149,9 @@ class VeryGoodConfig extends Equatable { /// Configuration values for the `very_good test` command. final VeryGoodTestConfig test; + /// Configuration values for the `very_good create` command. + final VeryGoodCreateConfig create; + /// Configuration values for the `very_good dart test` command. final VeryGoodDartConfig dart; @@ -153,7 +159,61 @@ class VeryGoodConfig extends Equatable { final VeryGoodPackagesConfig packages; @override - List get props => [test, dart, packages]; + List get props => [test, create, dart, packages]; +} + +/// {@template very_good_create_config} +/// Configuration values that customize the defaults of the +/// `very_good create` command and its subcommands. +/// +/// Any field that is left as `null` retains its CLI default. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodCreateConfig extends Equatable { + /// {@macro very_good_create_config} + const VeryGoodCreateConfig({ + this.description, + this.orgName, + this.publishable, + this.template, + this.workspace, + }); + + /// Creates a [VeryGoodCreateConfig] from a decoded YAML/JSON [json] map. + factory VeryGoodCreateConfig.fromJson(Map json) { + return _$VeryGoodCreateConfigFromJson(json); + } + + /// The description for the generated project. + final String? description; + + /// The organization for the generated project. + final String? orgName; + + /// Whether the generated project is intended to be published. + final bool? publishable; + + /// The template used to generate the project. + final String? template; + + /// Whether the generated project should resolve its dependencies from a + /// parent Pub workspace. + final bool? workspace; + + @override + List get props => [ + description, + orgName, + publishable, + template, + workspace, + ]; } /// {@template very_good_test_config} @@ -586,11 +646,6 @@ class VeryGoodPackagesCheckLicensesConfig extends Equatable { ]; } -// The coercers below intentionally validate more strictly than the CLI flag -// parser. A value such as `min_coverage: 150` is rejected here at config load -// time even though `--min-coverage 150` is accepted by the flag parser, so -// misconfigured `very_good.yaml` files fail fast with a clear message. - /// Coerces a `num` or `String` value into a `String`. /// /// Options are stored as strings to match the CLI's argument parsing (which @@ -643,25 +698,37 @@ String? _minCoverage(Object? value) { return asString; } +/// The values accepted by the `collect-coverage-from` option, shared between +/// the CLI argument parser and the `very_good.yaml` validator so they cannot +/// drift apart. +const collectCoverageFromAllowedValues = ['imports', 'all']; + +/// The dependency types accepted by `very_good packages check licenses`, shared +/// between the CLI argument parser and the `very_good.yaml` validator so they +/// cannot drift apart. +const dependencyTypeAllowedValues = [ + 'direct-main', + 'direct-dev', + 'direct-overridden', + 'transitive', +]; + +/// The values accepted by the license `reporter` option, shared between the +/// CLI argument parser and the `very_good.yaml` validator so they cannot drift +/// apart. +const reporterAllowedValues = ['text', 'csv']; + /// Validates and returns the `collect_coverage_from` value. /// /// Accepts only `imports` or `all`. String? _collectCoverageFrom(Object? value) { if (value == null) return null; - if (value != 'imports' && value != 'all') { + if (!collectCoverageFromAllowedValues.contains(value)) { throw FormatException('Expected `imports` or `all` but got `$value`.'); } return value as String; } -/// The dependency types accepted by `very_good packages check licenses`. -const _dependencyTypes = [ - 'direct-main', - 'direct-dev', - 'direct-overridden', - 'transitive', -]; - /// Validates and returns the `dependency_type` value. /// /// Accepts only the values allowed by the CLI option. @@ -669,9 +736,10 @@ List? _dependencyType(Object? value) { final values = _stringList(value); if (values == null) return null; for (final value in values) { - if (!_dependencyTypes.contains(value)) { + if (!dependencyTypeAllowedValues.contains(value)) { throw FormatException( - 'Expected one of ${_dependencyTypes.join(', ')} but got `$value`.', + 'Expected one of ${dependencyTypeAllowedValues.join(', ')} ' + 'but got `$value`.', ); } } @@ -683,7 +751,7 @@ List? _dependencyType(Object? value) { /// Accepts only `text` or `csv`. String? _reporter(Object? value) { if (value == null) return null; - if (value != 'text' && value != 'csv') { + if (!reporterAllowedValues.contains(value)) { throw FormatException('Expected `text` or `csv` but got `$value`.'); } return value as String; diff --git a/lib/src/very_good_config/very_good_config.g.dart b/lib/src/very_good_config/very_good_config.g.dart index f551f6e21..e463e5231 100644 --- a/lib/src/very_good_config/very_good_config.g.dart +++ b/lib/src/very_good_config/very_good_config.g.dart @@ -6,31 +6,62 @@ part of 'very_good_config.dart'; // JsonSerializableGenerator // ************************************************************************** -VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => - $checkedCreate('VeryGoodConfig', json, ($checkedConvert) { - $checkKeys(json, allowedKeys: const ['test', 'dart', 'packages']); - final val = VeryGoodConfig( - test: $checkedConvert( - 'test', - (v) => v == null - ? const VeryGoodTestConfig() - : VeryGoodTestConfig.fromJson(v as Map), - ), - dart: $checkedConvert( - 'dart', - (v) => v == null - ? const VeryGoodDartConfig() - : VeryGoodDartConfig.fromJson(v as Map), - ), - packages: $checkedConvert( - 'packages', - (v) => v == null - ? const VeryGoodPackagesConfig() - : VeryGoodPackagesConfig.fromJson(v as Map), - ), +VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => $checkedCreate( + 'VeryGoodConfig', + json, + ($checkedConvert) { + $checkKeys(json, allowedKeys: const ['test', 'create', 'dart', 'packages']); + final val = VeryGoodConfig( + test: $checkedConvert( + 'test', + (v) => v == null + ? const VeryGoodTestConfig() + : VeryGoodTestConfig.fromJson(v as Map), + ), + create: $checkedConvert( + 'create', + (v) => v == null + ? const VeryGoodCreateConfig() + : VeryGoodCreateConfig.fromJson(v as Map), + ), + dart: $checkedConvert( + 'dart', + (v) => v == null + ? const VeryGoodDartConfig() + : VeryGoodDartConfig.fromJson(v as Map), + ), + packages: $checkedConvert( + 'packages', + (v) => v == null + ? const VeryGoodPackagesConfig() + : VeryGoodPackagesConfig.fromJson(v as Map), + ), + ); + return val; + }, +); + +VeryGoodCreateConfig _$VeryGoodCreateConfigFromJson(Map json) => + $checkedCreate('VeryGoodCreateConfig', json, ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const [ + 'description', + 'org_name', + 'publishable', + 'template', + 'workspace', + ], + ); + final val = VeryGoodCreateConfig( + description: $checkedConvert('description', (v) => v as String?), + orgName: $checkedConvert('org_name', (v) => v as String?), + publishable: $checkedConvert('publishable', (v) => v as bool?), + template: $checkedConvert('template', (v) => v as String?), + workspace: $checkedConvert('workspace', (v) => v as bool?), ); return val; - }); + }, fieldKeyMap: const {'orgName': 'org_name'}); VeryGoodTestConfig _$VeryGoodTestConfigFromJson(Map json) => $checkedCreate( 'VeryGoodTestConfig', diff --git a/site/docs/commands/create.md b/site/docs/commands/create.md index ade0f0f5d..7b9bae7f8 100644 --- a/site/docs/commands/create.md +++ b/site/docs/commands/create.md @@ -68,6 +68,24 @@ You cannot combine `.` with `--output-directory`. Very Good CLI will exit with an error if you specify both. ::: +## Configuring defaults with `very_good.yaml` + +To avoid repeating the same flags every time you scaffold a new project, you may create a `very_good.yaml` file at the root of your project. The `create` section accepts the same names as the CLI flags in snake_case (e.g. `--org-name` becomes `org_name`). Values from `very_good.yaml` are used as defaults; anything you pass on the command line takes precedence. + +```yaml +# very_good.yaml +create: + description: A Very Good project. + org_name: com.very.good + publishable: true + template: core + workspace: true +``` + +With the file above, running `very_good create flutter_app my_app` behaves the same as running `very_good create flutter_app my_app --desc 'A Very Good project.' --org-name com.very.good --publishable --template core`. You can still override any of these values on the command line, for example `very_good create flutter_app my_app --org-name com.example` to use a different org name for a single run. + +The `very_good.yaml` file is looked up starting from the directory where the command runs and walking up through its ancestors. The closest file wins; configuration from ancestor directories is not merged. This lets a single `very_good.yaml` at the repository root apply to commands run from any nested package. + ## Available templates Each subcommand maps to a specific project template. For detailed usage options diff --git a/test/helpers/test_multi_template_commands.dart b/test/helpers/test_multi_template_commands.dart index 58d24fa3d..d3100bd31 100644 --- a/test/helpers/test_multi_template_commands.dart +++ b/test/helpers/test_multi_template_commands.dart @@ -31,6 +31,7 @@ Future testMultiTemplateCommand({ final argResults = _MockArgResults(); final command = multiTemplatesCommand..argResultOverrides = argResults; + when(() => argResults.wasParsed(any())).thenReturn(true); when(() => argResults['template'] as String?).thenReturn(templateName); when( () => argResults['output-directory'] as String?, diff --git a/test/src/commands/create/commands/app_ui_package_test.dart b/test/src/commands/create/commands/app_ui_package_test.dart index 968b7855e..388e3d079 100644 --- a/test/src/commands/create/commands/app_ui_package_test.dart +++ b/test/src/commands/create/commands/app_ui_package_test.dart @@ -165,6 +165,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateAppUiPackage( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/dart_cli_test.dart b/test/src/commands/create/commands/dart_cli_test.dart index 2dc3c8a6f..54b8b6fb0 100644 --- a/test/src/commands/create/commands/dart_cli_test.dart +++ b/test/src/commands/create/commands/dart_cli_test.dart @@ -167,6 +167,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateDartCLI( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/dart_package_test.dart b/test/src/commands/create/commands/dart_package_test.dart index b28bc5148..c282035e4 100644 --- a/test/src/commands/create/commands/dart_package_test.dart +++ b/test/src/commands/create/commands/dart_package_test.dart @@ -162,6 +162,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateDartPackage( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/docs_site_test.dart b/test/src/commands/create/commands/docs_site_test.dart index 3ddbb6f6a..2e27cd17a 100644 --- a/test/src/commands/create/commands/docs_site_test.dart +++ b/test/src/commands/create/commands/docs_site_test.dart @@ -165,6 +165,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateDocsSite( logger: logger, generatorFromBundle: (_) async => generator, @@ -210,6 +211,39 @@ void main() { () => logger.info('Created a Very Good documentation site! 🦄'), ).called(1); }); + + test('uses default org name when omitted', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final argResults = _MockArgResults(); + final command = CreateDocsSite( + logger: logger, + generatorFromBundle: (_) async => generator, + )..argResultOverrides = argResults; + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['output-directory'] as String?, + ).thenReturn(tempDirectory.path); + when(() => argResults.rest).thenReturn(['my_docs_site']); + when(() => argResults['org-name'] as String?).thenReturn(null); + + final result = await command.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => generator.generate( + any(), + vars: { + 'project_name': 'my_docs_site', + 'description': '', + 'publishable': false, + 'org_name': 'my-org', + }, + logger: logger, + ), + ).called(1); + }); }); }); } diff --git a/test/src/commands/create/commands/flame_game_test.dart b/test/src/commands/create/commands/flame_game_test.dart index 06bfd5410..23d515367 100644 --- a/test/src/commands/create/commands/flame_game_test.dart +++ b/test/src/commands/create/commands/flame_game_test.dart @@ -175,6 +175,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlameGame( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/flutter_package_test.dart b/test/src/commands/create/commands/flutter_package_test.dart index 854153018..af0ec7b32 100644 --- a/test/src/commands/create/commands/flutter_package_test.dart +++ b/test/src/commands/create/commands/flutter_package_test.dart @@ -165,6 +165,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlutterPackage( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/flutter_plugin_test.dart b/test/src/commands/create/commands/flutter_plugin_test.dart index 02314880d..2210084b2 100644 --- a/test/src/commands/create/commands/flutter_plugin_test.dart +++ b/test/src/commands/create/commands/flutter_plugin_test.dart @@ -193,6 +193,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlutterPlugin( logger: logger, generatorFromBundle: (_) async => generator, @@ -295,6 +296,7 @@ void main() { ).thenAnswer((_) async => successResult); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlutterPlugin( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index c81060a5b..955c07e90 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -9,9 +9,12 @@ import 'package:path/path.dart' as path; import 'package:test/test.dart'; import 'package:very_good_cli/src/commands/create/commands/create_subcommand.dart'; import 'package:very_good_cli/src/commands/create/templates/template.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; class _MockTemplate extends Mock implements Template {} +class _MockArgResults extends Mock implements ArgResults {} + class _MockLogger extends Mock implements Logger {} class _MockProgress extends Mock implements Progress {} @@ -1034,6 +1037,68 @@ Run "runner help" to see global options.'''; verifyNever(() => template2.onGenerateComplete(logger, any())); }); + test('falls back to the default template when unresolved', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['template'] as String?).thenReturn(null); + + final command = _TestCreateSubCommandMultiTemplate( + templates: templates, + logger: logger, + generatorFromBundle: null, + )..argResultOverrides = argResults; + + expect(command.template.name, equals('template1')); + }); + + test('selects the template from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['template'] as String?).thenReturn('template1'); + + final command = + _TestCreateSubCommandMultiTemplate( + templates: templates, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + template: 'template2', + ); + + expect(command.template.name, equals('template2')); + }); + + test( + 'throws UsageException when the config template is not allowed', + () async { + final cwd = Directory.current; + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); + + File( + path.join(tempDirectory.path, veryGoodConfigFileName), + ).writeAsStringSync('create:\n template: unknown'); + Directory.current = tempDirectory.path; + + await expectLater( + () => runner.run(['create_subcommand', 'test_project']), + throwsA( + isA().having( + (e) => e.message, + 'message', + '"unknown" is not an allowed value for the `create.template` ' + 'key in `$veryGoodConfigFileName`.', + ), + ), + ); + }, + ); + group('validates template name', () { test('throws UsageException when --template is invalid', () async { await expectLater( @@ -1061,6 +1126,267 @@ Run "runner help" to see global options.'''; }); }); + group('very_good.yaml configuration', () { + late Template template; + late _MockBundle bundle; + + setUp(() { + bundle = _MockBundle(); + when(() => bundle.name).thenReturn('test'); + when(() => bundle.description).thenReturn('Test bundle'); + when(() => bundle.version).thenReturn(''); + template = _MockTemplate(); + when(() => template.name).thenReturn('test'); + when(() => template.bundle).thenReturn(bundle); + when( + () => template.onGenerateComplete(any(), any()), + ).thenAnswer((_) async {}); + }); + + group('resolve', () { + test('applies config value when the arg was not parsed', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['description'] as String?, + ).thenReturn('A Very Good Project created by Very Good CLI.'); + + final command = + _TestCreateSubCommand( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + description: 'From config', + ); + + expect(command.projectDescription, equals('From config')); + }); + + test('prefers the CLI argument over the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.wasParsed('description')).thenReturn(true); + when( + () => argResults['description'] as String?, + ).thenReturn('From CLI'); + + final command = + _TestCreateSubCommand( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + description: 'From config', + ); + + expect(command.projectDescription, equals('From CLI')); + }); + + test('falls back to the CLI default when neither is set', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['description'] as String?).thenReturn(null); + + final command = _TestCreateSubCommand( + template: template, + logger: logger, + generatorFromBundle: null, + )..argResultOverrides = argResults; + + expect(command.projectDescription, isEmpty); + }); + + test('resolves the org name from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['org-name'] as String?).thenReturn(null); + + final command = + _TestCreateSubCommandWithOrgName( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + orgName: 'com.very.good', + ); + + expect(command.orgName, equals('com.very.good')); + }); + + test('resolves the publishable flag from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['publishable'] as bool?).thenReturn(null); + + final command = + _TestCreateSubCommandWithPublishable( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig(publishable: true); + + expect(command.publishable, isTrue); + }); + + test('resolves the workspace flag from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['workspace'] as bool?).thenReturn(null); + + final command = + _TestCreateSubCommandWithWorkspace( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig(workspace: true); + + expect(command.workspace, isTrue); + }); + + test('prefers the CLI workspace flag over the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.wasParsed('workspace')).thenReturn(true); + when(() => argResults['workspace'] as bool?).thenReturn(false); + + final command = + _TestCreateSubCommandWithWorkspace( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig(workspace: true); + + expect(command.workspace, isFalse); + }); + + test('falls back to false when neither workspace source is set', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['workspace'] as bool?).thenReturn(null); + + final command = _TestCreateSubCommandWithWorkspace( + template: template, + logger: logger, + generatorFromBundle: null, + )..argResultOverrides = argResults; + + expect(command.workspace, isFalse); + }); + }); + + group('run', () { + late Directory cwd; + late GeneratorHooks hooks; + late MasonGenerator generator; + late _TestCommandRunner runner; + + setUp(() { + cwd = Directory.current; + hooks = _MockGeneratorHooks(); + generator = _MockMasonGenerator(); + + when(() => generator.hooks).thenReturn(hooks); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + ), + ).thenAnswer((_) async {}); + when( + () => generator.generate( + any(), + vars: any(named: 'vars'), + logger: any(named: 'logger'), + ), + ).thenAnswer((_) async => generatedFiles); + when(() => generator.id).thenReturn('generator_id'); + when(() => generator.description).thenReturn('generator description'); + + final command = _TestCreateSubCommandWithOrgName( + template: template, + logger: logger, + generatorFromBundle: (_) async => generator, + ); + runner = _TestCommandRunner(command: command); + }); + + tearDown(() { + Directory.current = cwd; + }); + + test('applies config values loaded from very_good.yaml', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); + File( + path.join(tempDirectory.path, veryGoodConfigFileName), + ).writeAsStringSync('create:\n org_name: com.very.good'); + Directory.current = tempDirectory.path; + + final result = await runner.run(['create_subcommand', 'test_project']); + + expect(result, equals(ExitCode.success.code)); + verify( + () => generator.generate( + any(), + vars: any( + named: 'vars', + that: isA>().having( + (vars) => vars['org_name'], + 'org_name', + 'com.very.good', + ), + ), + logger: logger, + ), + ).called(1); + }); + + test( + 'fails with exit code ${ExitCode.config.code} ' + 'when very_good.yaml is malformed', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); + File( + path.join(tempDirectory.path, veryGoodConfigFileName), + ).writeAsStringSync('- not\n- a\n- map'); + Directory.current = tempDirectory.path; + + final result = await runner.run([ + 'create_subcommand', + 'test_project', + ]); + + expect(result, equals(ExitCode.config.code)); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }, + ); + }); + }); + group('Publishable', () { const expectedUsage = ''' Usage: very_good create create_subcommand [arguments] diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index d157d012b..d3a9562df 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -865,6 +865,11 @@ void main() { config: const VeryGoodConfig( dart: VeryGoodDartConfig( test: VeryGoodDartTestConfig( + concurrency: '8', + tags: 'unit', + optimization: false, + platform: 'chrome', + checkIgnore: false, minCoverage: '90', excludeCoverage: '**/*.g.dart', reportOn: ['lib/'], @@ -873,12 +878,46 @@ void main() { ), ), ); + expect(options.concurrency, equals('8')); + expect(options.tags, equals('unit')); + expect(options.optimizePerformance, isFalse); + expect(options.platform, equals('chrome')); + expect(options.checkIgnore, isFalse); expect(options.minCoverage, equals(90)); expect(options.excludeFromCoverage, equals('**/*.g.dart')); expect(options.reportOn, equals(['lib/'])); expect(options.fileReporter, equals('json:reports/tests.json')); }); + test('CLI arguments take precedence over config for all fields', () { + when(() => argResults.wasParsed(any())).thenReturn(true); + when(() => argResults['concurrency']).thenReturn('2'); + when(() => argResults['tags']).thenReturn('cli-tag'); + when(() => argResults['optimization']).thenReturn(true); + when(() => argResults['platform']).thenReturn('vm'); + when(() => argResults['check-ignore']).thenReturn(true); + + final options = DartTestOptions.parse( + argResults, + config: const VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig( + concurrency: '8', + tags: 'unit', + optimization: false, + platform: 'chrome', + checkIgnore: false, + ), + ), + ), + ); + expect(options.concurrency, equals('2')); + expect(options.tags, equals('cli-tag')); + expect(options.optimizePerformance, isTrue); + expect(options.platform, equals('vm')); + expect(options.checkIgnore, isTrue); + }); + test('CLI argument takes precedence over config value', () { when(() => argResults.wasParsed(any())).thenReturn(false); when(() => argResults.wasParsed('min-coverage')).thenReturn(true); diff --git a/test/src/commands/packages/commands/check/commands/licenses_test.dart b/test/src/commands/packages/commands/check/commands/licenses_test.dart index b3045e9f6..3aba31eba 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -6,6 +6,7 @@ import 'dart:io'; +import 'package:args/args.dart'; import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,9 +15,12 @@ import 'package:pana/src/license_detection/license_detector.dart' as detector; import 'package:path/path.dart' as path; import 'package:test/test.dart'; import 'package:very_good_cli/src/commands/packages/commands/check/commands/commands.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; import '../../../../../../helpers/helpers.dart'; +class _MockArgResults extends Mock implements ArgResults {} + class _MockProgress extends Mock implements Progress {} class _MockResult extends Mock implements detector.Result {} @@ -1776,6 +1780,153 @@ and limitations under the License.'''); ).called(1); }), ); + + test('applies config values when args were not parsed', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['ignore-retrieval-failures'], + ).thenReturn(false); + when( + () => argResults['dependency-type'], + ).thenReturn(['direct-main']); + when(() => argResults['allowed']).thenReturn([]); + when(() => argResults['forbidden']).thenReturn([]); + when(() => argResults['skip-packages']).thenReturn([]); + when(() => argResults['reporter']).thenReturn(null); + + final options = PackagesCheckLicensesOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + check: VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: true, + dependencyType: ['transitive'], + allowed: ['MIT'], + skipPackages: ['example'], + reporter: 'csv', + ), + ), + ), + ), + ); + + expect(options.ignoreRetrievalFailures, isTrue); + expect(options.dependencyTypes, equals({'transitive'})); + expect(options.allowedLicenses, equals(['MIT'])); + expect(options.skippedPackages, equals({'example'})); + expect(options.reporterOutputFormat, equals(ReporterOutputFormat.csv)); + }); + + test('resolves forbidden licenses from config', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['ignore-retrieval-failures'], + ).thenReturn(false); + when( + () => argResults['dependency-type'], + ).thenReturn(['direct-main']); + when(() => argResults['allowed']).thenReturn([]); + when(() => argResults['forbidden']).thenReturn([]); + when(() => argResults['skip-packages']).thenReturn([]); + when(() => argResults['reporter']).thenReturn(null); + + final options = PackagesCheckLicensesOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + check: VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig( + forbidden: ['GPL'], + ), + ), + ), + ), + ); + + expect(options.forbiddenLicenses, equals(['GPL'])); + }); + + test('CLI arguments take precedence over config values', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); + when( + () => argResults['ignore-retrieval-failures'], + ).thenReturn(false); + when( + () => argResults['dependency-type'], + ).thenReturn(['direct-dev']); + when(() => argResults['allowed']).thenReturn(['BSD']); + when(() => argResults['forbidden']).thenReturn([]); + when(() => argResults['skip-packages']).thenReturn([]); + when(() => argResults['reporter']).thenReturn('text'); + + final options = PackagesCheckLicensesOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + check: VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: true, + dependencyType: ['transitive'], + allowed: ['MIT'], + reporter: 'csv', + ), + ), + ), + ), + ); + + expect(options.ignoreRetrievalFailures, isFalse); + expect(options.dependencyTypes, equals({'direct-dev'})); + expect(options.allowedLicenses, equals(['BSD'])); + expect(options.reporterOutputFormat, equals(ReporterOutputFormat.text)); + }); + + test( + 'applies skip-packages value from config', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_validMultiplePubspecLockContent); + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync(''' +packages: + check: + licenses: + skip_packages: + - cli_completion +'''); + + when(() => logger.progress(any())).thenReturn(progress); + + when(() => packageConfig.packages).thenReturn({ + veryGoodTestRunnerConfigPackage, + cliCompletionConfigPackage, + }); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + final result = await commandRunner.run([ + ...commandArguments, + tempDirectory.path, + ]); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + expect(result, equals(ExitCode.success.code)); + }), + ); }); }); } diff --git a/test/src/mcp/mcp_server_test.dart b/test/src/mcp/mcp_server_test.dart index e0b872c75..6c36ce9a3 100644 --- a/test/src/mcp/mcp_server_test.dart +++ b/test/src/mcp/mcp_server_test.dart @@ -262,6 +262,30 @@ void main() { ); }); + test('does not forward workspace args to docs_site', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: { + 'subcommand': 'docs_site', + 'name': 'my_docs', + 'workspace': true, + }, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect( + capturedArgs, + equals(['create', 'docs_site', 'my_docs']), + ); + }); + test('handles command runner failure', () async { when( () => mockCommandRunner.run(any()), diff --git a/test/src/very_good_config/fixtures/all_create_options.yaml b/test/src/very_good_config/fixtures/all_create_options.yaml new file mode 100644 index 000000000..d0af1ba75 --- /dev/null +++ b/test/src/very_good_config/fixtures/all_create_options.yaml @@ -0,0 +1,6 @@ +create: + description: A configured project. + org_name: com.very.good + publishable: true + template: my-template + workspace: true diff --git a/test/src/very_good_config/very_good_config_test.dart b/test/src/very_good_config/very_good_config_test.dart index 60a48bdc5..b85408ece 100644 --- a/test/src/very_good_config/very_good_config_test.dart +++ b/test/src/very_good_config/very_good_config_test.dart @@ -1,14 +1,17 @@ -// Ensures we don't have to use const constructors -// and instances are created at runtime. +// Ensures instances are created at runtime. // ignore_for_file: prefer_const_constructors // ignore_for_file: prefer_const_literals_to_create_immutables import 'dart:io'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; +class _MockLogger extends Mock implements Logger {} + void main() { group(VeryGoodConfig, () { group('fromString', () { @@ -101,6 +104,51 @@ test: expect(config.test.collectCoverageFrom, equals('imports')); }); + test('parses all supported create options', () { + final fixture = File( + p.join( + 'test', + 'src', + 'very_good_config', + 'fixtures', + 'all_create_options.yaml', + ), + ); + final config = VeryGoodConfig.fromString(fixture.readAsStringSync()); + + expect(config.create.description, equals('A configured project.')); + expect(config.create.orgName, equals('com.very.good')); + expect(config.create.publishable, isTrue); + expect(config.create.template, equals('my-template')); + expect(config.create.workspace, isTrue); + }); + + test('defaults create to an empty config when omitted', () { + final config = VeryGoodConfig.fromString('test:\n coverage: true'); + expect(config.create, equals(const VeryGoodCreateConfig())); + }); + + test('throws when create section is not a map', () { + expect( + () => VeryGoodConfig.fromString('create: foo'), + throwsA(isA()), + ); + }); + + test('throws when a create bool option has wrong type', () { + expect( + () => VeryGoodConfig.fromString('create:\n publishable: yes-please'), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized create key is present', () { + expect( + () => VeryGoodConfig.fromString('create:\n org_nam: foo'), + throwsA(isA()), + ); + }); + test('throws when test section is not a map', () { expect( () => VeryGoodConfig.fromString('test: foo'), @@ -449,14 +497,16 @@ packages: }); }); - group('loadFromClosestAncestor', () { + group('load', () { late Directory tempDir; late Directory nestedDir; + late Logger logger; setUp(() { tempDir = Directory.systemTemp.createTempSync('very_good_config_'); nestedDir = Directory(p.join(tempDir.path, 'packages', 'foo')) ..createSync(recursive: true); + logger = _MockLogger(); }); tearDown(() { @@ -465,6 +515,20 @@ packages: } }); + test('returns the parsed config when very_good.yaml is valid', () { + File(p.join(tempDir.path, veryGoodConfigFileName)).writeAsStringSync(''' +packages: + get: + recursive: true +'''); + + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, isNotNull); + expect(config!.packages.get.recursive, isTrue); + verifyNever(() => logger.err(any())); + }); + test('reads config from the starting directory', () { File(p.join(nestedDir.path, veryGoodConfigFileName)).writeAsStringSync( ''' @@ -472,8 +536,10 @@ test: min_coverage: 80 ''', ); - final config = VeryGoodConfig.loadFromClosestAncestor(nestedDir); - expect(config.test.minCoverage, equals('80')); + + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config?.test.minCoverage, equals('80')); }); test('reads config from an ancestor directory', () { @@ -481,8 +547,10 @@ test: test: min_coverage: 90 '''); - final config = VeryGoodConfig.loadFromClosestAncestor(nestedDir); - expect(config.test.minCoverage, equals('90')); + + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config?.test.minCoverage, equals('90')); }); test('prefers the closest config over an ancestor', () { @@ -496,26 +564,54 @@ test: min_coverage: 80 ''', ); - final config = VeryGoodConfig.loadFromClosestAncestor(nestedDir); - expect(config.test.minCoverage, equals('80')); - }); - test('returns empty config when no file is found in any ancestor', () { - expect( - VeryGoodConfig.loadFromClosestAncestor(nestedDir), - equals(VeryGoodConfig.empty), - ); + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config?.test.minCoverage, equals('80')); }); - test('rethrows parse exception when the closest file is malformed', () { - File( - p.join(nestedDir.path, veryGoodConfigFileName), - ).writeAsStringSync('- not\n- a\n- map'); - expect( - () => VeryGoodConfig.loadFromClosestAncestor(nestedDir), - throwsA(isA()), - ); + test('returns an empty config when no very_good.yaml exists', () { + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, equals(VeryGoodConfig.empty)); + verifyNever(() => logger.err(any())); }); + + test( + 'logs an error and returns null when very_good.yaml is malformed', + () { + File( + p.join(tempDir.path, veryGoodConfigFileName), + ).writeAsStringSync('- not\n- a\n- map'); + + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, isNull); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }, + ); + + test( + 'logs an error and returns null when the closest file is malformed', + () { + File( + p.join(nestedDir.path, veryGoodConfigFileName), + ).writeAsStringSync('- not\n- a\n- map'); + + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config, isNull); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }, + ); }); test('supports value equality', () { @@ -530,6 +626,18 @@ test: equals(VeryGoodConfig(test: VeryGoodTestConfig(coverage: false))), ), ); + expect( + VeryGoodConfig(create: VeryGoodCreateConfig(publishable: true)), + equals(VeryGoodConfig(create: VeryGoodCreateConfig(publishable: true))), + ); + expect( + VeryGoodConfig(create: VeryGoodCreateConfig(publishable: true)), + isNot( + equals( + VeryGoodConfig(create: VeryGoodCreateConfig(publishable: false)), + ), + ), + ); expect( VeryGoodConfig( dart: VeryGoodDartConfig( @@ -606,6 +714,21 @@ test: }); }); + group(VeryGoodCreateConfig, () { + test('supports value equality', () { + expect( + VeryGoodCreateConfig(orgName: 'com.very.good', publishable: true), + equals( + VeryGoodCreateConfig(orgName: 'com.very.good', publishable: true), + ), + ); + expect( + VeryGoodCreateConfig(publishable: true), + isNot(equals(VeryGoodCreateConfig(publishable: false))), + ); + }); + }); + group(VeryGoodDartConfig, () { test('supports value equality', () { expect(VeryGoodDartConfig(), equals(VeryGoodDartConfig()));