diff --git a/brick/hooks/lib/src/cli/cli.dart b/brick/hooks/lib/src/cli/cli.dart new file mode 100644 index 0000000..a309500 --- /dev/null +++ b/brick/hooks/lib/src/cli/cli.dart @@ -0,0 +1,8 @@ +/// Collection of command line interfaces (CLIs). +/// +/// This library abstracts some CLIs to facilitate interacting with them. +library cli; + +export 'command_line.dart'; +export 'dart_cli.dart'; +export 'very_good_cli.dart'; diff --git a/brick/hooks/lib/src/cli/command_line.dart b/brick/hooks/lib/src/cli/command_line.dart new file mode 100644 index 0000000..5156c78 --- /dev/null +++ b/brick/hooks/lib/src/cli/command_line.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:mason/mason.dart'; +import 'package:meta/meta.dart'; + +/// [Process.run] function signature. +typedef RunProcess = Future Function( + String executable, + List arguments, { + String? workingDirectory, + bool runInShell, +}); + +/// Starts a process and runs it non-interactively to completion. +/// +/// Used for overriding the default [Process.run] implementation +/// during testing. +@visibleForTesting +RunProcess? runProcess; + +/// Abstraction for running commands via command-line. +abstract class CommandLine { + /// Runs the specified [cmd] with the provided [args]. + /// + /// Throws a [ProcessException] if the process fails. + static Future run( + String cmd, + List args, { + required Logger logger, + bool throwOnError = true, + String? workingDirectory, + }) async { + logger.detail('Running: $cmd with $args'); + final runner = runProcess ?? Process.run; + final result = await runner( + cmd, + args, + workingDirectory: workingDirectory, + runInShell: true, + ); + logger + ..detail('stdout:\n${result.stdout}') + ..detail('stderr:\n${result.stderr}'); + + if (throwOnError) { + _throwIfProcessFailed(result, cmd, args); + } + return result; + } + + static void _throwIfProcessFailed( + ProcessResult pr, + String process, + List args, + ) { + if (pr.exitCode != 0) { + final values = { + 'Standard out': pr.stdout.toString().trim(), + 'Standard error': pr.stderr.toString().trim(), + }..removeWhere((k, v) => v.isEmpty); + + var message = 'Unknown error'; + if (values.isNotEmpty) { + message = values.entries.map((e) => '${e.key}:\n${e.value}').join('\n'); + } + + throw ProcessException(process, args, message, pr.exitCode); + } + } +} diff --git a/brick/hooks/lib/src/cli/dart_cli.dart b/brick/hooks/lib/src/cli/dart_cli.dart new file mode 100644 index 0000000..03e0d6a --- /dev/null +++ b/brick/hooks/lib/src/cli/dart_cli.dart @@ -0,0 +1,62 @@ +import 'package:mason/mason.dart'; +import 'package:very_good_flutter_plugin_hooks/src/cli/cli.dart'; + +/// A wrapper around the Dart Command Line Interface (CLI). +/// +/// The Dart CLI is part of the Dart SDK. +/// +/// See also: +/// +/// * [The Dart command-line tool documentation](https://dart.dev/tools/dart-tool) +/// * [The Dart command-line source code](https://github.com/dart-lang/sdk/tree/main/pkg/dartdev) +class DartCli { + const DartCli._(); + + /// A singleton instance of [DartCli]. + static const instance = DartCli._(); + + static const _executableName = 'dart'; + + /// Determine whether dart is installed. + Future isInstalled({required Logger logger}) async { + try { + await CommandLine.run( + _executableName, + ['--version'], + logger: logger, + ); + return true; + } catch (_) { + return false; + } + } + + /// Idiomatically format Dart source code. + Future format({ + required Logger logger, + String cwd = '.', + }) async { + await CommandLine.run( + _executableName, + ['format'], + workingDirectory: cwd, + logger: logger, + ); + } + + /// Apply automated fixes to Dart source code. + /// + /// Enabling [apply] applies the proposed changes. + Future fix({ + required Logger logger, + bool apply = false, + String cwd = '.', + }) async { + await CommandLine.run( + _executableName, + ['fix', if (apply) '--apply'], + workingDirectory: cwd, + logger: logger, + ); + } +} diff --git a/brick/hooks/lib/src/cli/very_good_cli.dart b/brick/hooks/lib/src/cli/very_good_cli.dart new file mode 100644 index 0000000..d4e4282 --- /dev/null +++ b/brick/hooks/lib/src/cli/very_good_cli.dart @@ -0,0 +1,47 @@ +import 'package:mason/mason.dart'; +import 'package:very_good_flutter_plugin_hooks/src/cli/cli.dart'; + +/// A wrapper around the Very Good Command Line Interface (CLI). +/// +/// See also: +/// +/// * [The Very Good CLI documentation](https://cli.vgv.dev/) +class VeryGoodCli { + const VeryGoodCli._(); + + /// A singleton instance of [VeryGoodCli]. + static const instance = VeryGoodCli._(); + + static const _executableName = 'very_good'; + + /// Determine whether dart is installed. + Future isInstalled({required Logger logger}) async { + try { + await CommandLine.run( + _executableName, + ['--version'], + logger: logger, + ); + return true; + } catch (_) { + return false; + } + } + + /// Get packages in a Dart or Flutter project. + /// + /// Enabling [recursive] installs dependencies recursively for all nested + /// packages. + Future packagesGet({ + required Logger logger, + String cwd = '.', + bool recursive = false, + }) async { + await CommandLine.run( + _executableName, + ['packages', 'get', if (recursive) '--recursive'], + workingDirectory: cwd, + logger: logger, + ); + } +} diff --git a/brick/hooks/lib/very_good_flutter_plugin_hooks.dart b/brick/hooks/lib/very_good_flutter_plugin_hooks.dart new file mode 100644 index 0000000..eff7fa1 --- /dev/null +++ b/brick/hooks/lib/very_good_flutter_plugin_hooks.dart @@ -0,0 +1,2 @@ +/// Mason hooks for the Very Good Flutter Plugin brick. +library very_good_flutter_plugin_hooks; diff --git a/brick/hooks/pubspec.yaml b/brick/hooks/pubspec.yaml index 6aeda48..1be07df 100644 --- a/brick/hooks/pubspec.yaml +++ b/brick/hooks/pubspec.yaml @@ -1,4 +1,4 @@ -name: very_good_flutter_plugins_hooks +name: very_good_flutter_plugin_hooks environment: sdk: ">=3.1.0 <4.0.0" diff --git a/brick/hooks/test/cli/command_line_test.dart b/brick/hooks/test/cli/command_line_test.dart new file mode 100644 index 0000000..0c98fce --- /dev/null +++ b/brick/hooks/test/cli/command_line_test.dart @@ -0,0 +1,237 @@ +import 'dart:io'; + +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; +import 'package:very_good_flutter_plugin_hooks/src/cli/cli.dart'; + +class _TestProcess { + Future run( + String command, + List args, { + bool runInShell = false, + String? workingDirectory, + }) { + throw UnimplementedError(); + } +} + +class _MockProcess extends Mock implements _TestProcess {} + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('$CommandLine', () { + final processResult = ProcessResult(42, ExitCode.success.code, '', ''); + late _TestProcess process; + late Logger logger; + + setUp(() { + logger = _MockLogger(); + process = _MockProcess(); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + runProcess = process.run; + }); + + tearDown(() { + runProcess = null; + }); + + group('logs', () { + test('when started running', () { + CommandLine.run( + 'foo', + ['bar'], + logger: logger, + ); + verify( + () => logger.detail('Running: foo with [bar]'), + ).called(1); + }); + + test('stdout when finished running', () async { + const stdout = 'hello world!'; + final processResult = ProcessResult( + 42, + ExitCode.success.code, + stdout, + '', + ); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + await CommandLine.run( + 'foo', + ['bar'], + logger: logger, + ); + + verify(() => logger.detail('stdout:\n$stdout')).called(1); + }); + + test('stderr when finished running', () async { + const stderr = 'hello world!'; + final processResult = ProcessResult( + 42, + ExitCode.success.code, + '', + stderr, + ); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + await CommandLine.run( + 'foo', + ['bar'], + logger: logger, + ); + + verify(() => logger.detail('stderr:\n$stderr')).called(1); + }); + }); + + group('throws ProcessException', () { + test('when exit code is non-zero', () async { + final processResult = ProcessResult( + 42, + ExitCode.software.code, + '', + '', + ); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + const executable = 'foo'; + const arguments = ['bar']; + await expectLater( + CommandLine.run( + executable, + arguments, + logger: logger, + ), + throwsA( + isA() + .having( + (exception) => exception.executable, + 'executable', + equals(executable), + ) + .having( + (exception) => exception.errorCode, + 'errorCode', + equals(processResult.exitCode), + ) + .having( + (exception) => exception.arguments, + 'arguments', + equals(arguments), + ), + ), + ); + }); + + test( + 'with "Unknown error" message if stderr and stdout are empty', + () { + final processResult = ProcessResult( + 42, + ExitCode.software.code, + '', + '', + ); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + const message = 'Unknown error'; + expect( + () => CommandLine.run( + 'foo', + ['bar'], + logger: logger, + ), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + equals(message), + ), + ), + ); + }, + ); + + test( + 'with stdout and stderr message if stderr and stdout are non-empty', + () { + const stdout = 'hello stdout!'; + const stderr = 'hello stderr!'; + final processResult = ProcessResult( + 42, + ExitCode.software.code, + stdout, + stderr, + ); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + const message = ''' +Standard out: +hello stdout! +Standard error: +hello stderr!'''; + expect( + () => CommandLine.run( + 'foo', + ['bar'], + logger: logger, + ), + throwsA( + isA().having( + (exception) => exception.message, + 'message', + equals(message), + ), + ), + ); + }, + ); + }); + }); +} diff --git a/brick/hooks/test/cli/dart_cli_test.dart b/brick/hooks/test/cli/dart_cli_test.dart new file mode 100644 index 0000000..7743467 --- /dev/null +++ b/brick/hooks/test/cli/dart_cli_test.dart @@ -0,0 +1,163 @@ +import 'dart:io'; + +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; +import 'package:very_good_start_hooks/cli/cli.dart'; + +class _TestProcess { + Future run( + String command, + List args, { + bool runInShell = false, + String? workingDirectory, + }) { + throw UnimplementedError(); + } +} + +class _MockProcess extends Mock implements _TestProcess {} + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('$DartCli', () { + final processResult = ProcessResult(42, ExitCode.success.code, '', ''); + late _TestProcess process; + late Logger logger; + + setUp(() { + logger = _MockLogger(); + + process = _MockProcess(); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + runProcess = process.run; + }); + + tearDown(() { + runProcess = null; + }); + + group('isInstalled', () { + test('returns true when dart is installed', () async { + await expectLater( + DartCli.instance.isInstalled(logger: logger), + completion(isTrue), + ); + }); + + test('returns false when dart is not installed', () async { + final processResult = ProcessResult( + 42, + ExitCode.software.code, + '', + '', + ); + + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + await expectLater( + DartCli.instance.isInstalled(logger: logger), + completion(isFalse), + ); + }); + }); + + group('format', () { + test('completes normally', () async { + await expectLater( + DartCli.instance.format(logger: logger), + completes, + ); + }); + + test('calls with given working directory', () { + DartCli.instance.format(logger: logger, cwd: 'foo'); + + verify( + () => process.run( + 'dart', + ['format'], + runInShell: true, + workingDirectory: 'foo', + ), + ).called(1); + }); + + test('calls `dart format .`', () { + DartCli.instance.format(logger: logger); + + verify( + () => process.run( + 'dart', + ['format'], + runInShell: true, + workingDirectory: '.', + ), + ).called(1); + }); + }); + + group('fix', () { + test('completes normally', () async { + await expectLater( + DartCli.instance.fix(logger: logger), + completes, + ); + }); + + test('calls with given working directory', () { + DartCli.instance.fix(logger: logger, cwd: 'foo'); + + verify( + () => process.run( + 'dart', + ['fix'], + runInShell: true, + workingDirectory: 'foo', + ), + ).called(1); + }); + + test('calls `dart fix .`', () { + DartCli.instance.fix(logger: logger); + + verify( + () => process.run( + 'dart', + ['fix'], + runInShell: true, + workingDirectory: '.', + ), + ).called(1); + }); + + test('calls `dart fix --apply .` when apply is true', () { + DartCli.instance.fix(logger: logger, apply: true); + + verify( + () => process.run( + 'dart', + ['fix', '--apply'], + runInShell: true, + workingDirectory: '.', + ), + ).called(1); + }); + }); + }); +} diff --git a/brick/hooks/test/cli/very_good_cli_test.dart b/brick/hooks/test/cli/very_good_cli_test.dart new file mode 100644 index 0000000..b4e94c9 --- /dev/null +++ b/brick/hooks/test/cli/very_good_cli_test.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:mason/mason.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; +import 'package:very_good_flutter_plugin_hooks/src/cli/cli.dart'; + +class _TestProcess { + Future run( + String command, + List args, { + bool runInShell = false, + String? workingDirectory, + }) { + throw UnimplementedError(); + } +} + +class _MockProcess extends Mock implements _TestProcess {} + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('$VeryGoodCli', () { + final processResult = ProcessResult(42, ExitCode.success.code, '', ''); + late _TestProcess process; + late Logger logger; + + setUp(() { + logger = _MockLogger(); + + process = _MockProcess(); + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + runProcess = process.run; + }); + + tearDown(() { + runProcess = null; + }); + + group('isInstalled', () { + test('returns true when dart is installed', () async { + await expectLater( + VeryGoodCli.instance.isInstalled(logger: logger), + completion(isTrue), + ); + }); + + test('returns false when dart is not installed', () async { + final processResult = ProcessResult( + 42, + ExitCode.software.code, + '', + '', + ); + + when( + () => process.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => processResult); + + await expectLater( + VeryGoodCli.instance.isInstalled(logger: logger), + completion(isFalse), + ); + }); + }); + + group('packagesGet', () { + test('completes normally', () async { + await expectLater( + VeryGoodCli.instance.packagesGet(logger: logger), + completes, + ); + }); + + test('calls `very_good packages get .`', () { + VeryGoodCli.instance.packagesGet(logger: logger); + + verify( + () => process.run( + 'very_good', + ['packages', 'get'], + runInShell: true, + workingDirectory: '.', + ), + ).called(1); + }); + + test('calls with given working directory', () { + VeryGoodCli.instance.packagesGet(logger: logger, cwd: 'foo'); + + verify( + () => process.run( + 'very_good', + ['packages', 'get'], + runInShell: true, + workingDirectory: 'foo', + ), + ).called(1); + }); + + test( + '''calls `very_good packages get --recursive .` when recursive is true''', + () { + VeryGoodCli.instance.packagesGet(logger: logger, recursive: true); + + verify( + () => process.run( + 'very_good', + ['packages', 'get', '--recursive'], + runInShell: true, + workingDirectory: '.', + ), + ).called(1); + }); + }); + }); +} diff --git a/brick/hooks/test/post_gen_test.dart b/brick/hooks/test/post_gen_test.dart new file mode 100644 index 0000000..b506987 --- /dev/null +++ b/brick/hooks/test/post_gen_test.dart @@ -0,0 +1,3 @@ +import 'package:mason/mason.dart'; + +void run(HookContext context) {}