From 22eb354c2a13c580983117315cb875fdfcc99715 Mon Sep 17 00:00:00 2001 From: Alexander Sandor <137198655+SandPod@users.noreply.github.com> Date: Mon, 6 May 2024 10:40:21 +0200 Subject: [PATCH] Initial commit --- .gitignore | 3 + CHANGELOG.md | 3 + LICENSE | 28 + README.md | 19 + analysis_options.yaml | 1 + example/main.dart | 19 + lib/analytics.dart | 3 + lib/better_command_runner.dart | 4 + lib/cli_tools.dart | 7 + lib/local_storage_manager.dart | 3 + lib/logger.dart | 6 + lib/package_version.dart | 4 + lib/src/analytics/analytics.dart | 81 +++ .../better_command_runner.dart | 181 +++++++ .../better_command_runner/exit_exception.dart | 28 + .../local_storage_manager.dart | 125 +++++ lib/src/logger/helpers/ansi_style.dart | 40 ++ lib/src/logger/helpers/progress.dart | 173 +++++++ lib/src/logger/logger.dart | 133 +++++ lib/src/logger/loggers/std_out_logger.dart | 334 ++++++++++++ lib/src/logger/loggers/void_logger.dart | 62 +++ lib/src/package_version/package_version.dart | 87 ++++ lib/src/package_version/pub_api_client.dart | 70 +++ pubspec.lock | 477 ++++++++++++++++++ pubspec.yaml | 28 + .../better_command_runner/analytics_test.dart | 183 +++++++ test/better_command_runner/command_test.dart | 117 +++++ .../exit_exceptions_test.dart | 72 +++ test/better_command_runner/logging_test.dart | 96 ++++ .../parse_log_level_test.dart | 148 ++++++ test/package_version_test.dart | 140 +++++ test/pub_api_client_test.dart | 139 +++++ 32 files changed, 2814 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 example/main.dart create mode 100644 lib/analytics.dart create mode 100644 lib/better_command_runner.dart create mode 100644 lib/cli_tools.dart create mode 100644 lib/local_storage_manager.dart create mode 100644 lib/logger.dart create mode 100644 lib/package_version.dart create mode 100644 lib/src/analytics/analytics.dart create mode 100644 lib/src/better_command_runner/better_command_runner.dart create mode 100644 lib/src/better_command_runner/exit_exception.dart create mode 100644 lib/src/local_storage_manager/local_storage_manager.dart create mode 100644 lib/src/logger/helpers/ansi_style.dart create mode 100644 lib/src/logger/helpers/progress.dart create mode 100644 lib/src/logger/logger.dart create mode 100644 lib/src/logger/loggers/std_out_logger.dart create mode 100644 lib/src/logger/loggers/void_logger.dart create mode 100644 lib/src/package_version/package_version.dart create mode 100644 lib/src/package_version/pub_api_client.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/better_command_runner/analytics_test.dart create mode 100644 test/better_command_runner/command_test.dart create mode 100644 test/better_command_runner/exit_exceptions_test.dart create mode 100644 test/better_command_runner/logging_test.dart create mode 100644 test/better_command_runner/parse_log_level_test.dart create mode 100644 test/package_version_test.dart create mode 100644 test/pub_api_client_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a85790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b78d64c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial version. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dcdffe5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Serverpod + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba3c5d2 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +![Serverpod banner](https://github.com/serverpod/serverpod/raw/main/misc/images/github-header.webp) + +# CLI Tools + +This package contains tools for building great command line interfaces. These tools were developed for the Serverpod CLI but can be used in any Dart project. + +## Contributing to the Project + +We are happy to accept contributions. To contribute, please do the following: + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a pull request +6. Discuss and modify the pull request as necessary +7. The pull request will be accepted and merged by the repository owner + +Tests are required to accept any pull requests. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..9d4376c --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:serverpod_lints/cli.yaml \ No newline at end of file diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 0000000..26b3157 --- /dev/null +++ b/example/main.dart @@ -0,0 +1,19 @@ +import 'package:cli_tools/cli_tools.dart'; + +void main() async { + /// Simple example of using the [StdOutLogger] class. + var logger = StdOutLogger(LogLevel.info); + + logger.info('An info message'); + logger.error('An error message'); + logger.debug( + 'A debug message that will not be shown because log level is info', + ); + await logger.progress( + 'A progress message', + () async => Future.delayed( + const Duration(seconds: 3), + () => true, + ), + ); +} diff --git a/lib/analytics.dart b/lib/analytics.dart new file mode 100644 index 0000000..095fb99 --- /dev/null +++ b/lib/analytics.dart @@ -0,0 +1,3 @@ +library analytics; + +export 'src/analytics/analytics.dart'; diff --git a/lib/better_command_runner.dart b/lib/better_command_runner.dart new file mode 100644 index 0000000..1b76e42 --- /dev/null +++ b/lib/better_command_runner.dart @@ -0,0 +1,4 @@ +library better_command_runner; + +export 'src/better_command_runner/better_command_runner.dart'; +export 'src/better_command_runner/exit_exception.dart'; diff --git a/lib/cli_tools.dart b/lib/cli_tools.dart new file mode 100644 index 0000000..7b0cda6 --- /dev/null +++ b/lib/cli_tools.dart @@ -0,0 +1,7 @@ +library cli_tools; + +export 'analytics.dart'; +export 'better_command_runner.dart'; +export 'local_storage_manager.dart'; +export 'logger.dart'; +export 'package_version.dart'; diff --git a/lib/local_storage_manager.dart b/lib/local_storage_manager.dart new file mode 100644 index 0000000..a0f5c17 --- /dev/null +++ b/lib/local_storage_manager.dart @@ -0,0 +1,3 @@ +library local_storage_manager; + +export 'src/local_storage_manager/local_storage_manager.dart'; diff --git a/lib/logger.dart b/lib/logger.dart new file mode 100644 index 0000000..df44c83 --- /dev/null +++ b/lib/logger.dart @@ -0,0 +1,6 @@ +library logger; + +export 'src/logger/logger.dart'; +export 'src/logger/loggers/std_out_logger.dart'; +export 'src/logger/loggers/void_logger.dart'; +export 'src/logger/helpers/ansi_style.dart'; diff --git a/lib/package_version.dart b/lib/package_version.dart new file mode 100644 index 0000000..431763a --- /dev/null +++ b/lib/package_version.dart @@ -0,0 +1,4 @@ +library package_version; + +export 'src/package_version/package_version.dart'; +export 'src/package_version/pub_api_client.dart'; diff --git a/lib/src/analytics/analytics.dart b/lib/src/analytics/analytics.dart new file mode 100644 index 0000000..d0806d9 --- /dev/null +++ b/lib/src/analytics/analytics.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ci/ci.dart' as ci; +import 'package:http/http.dart' as http; + +/// Interface for analytics services. +abstract interface class Analytics { + /// Clean up resources. + void cleanUp(); + + /// Track an event. + void track({ + required String event, + }); +} + +/// Analytics service for MixPanel. +class MixPanelAnalytics implements Analytics { + final String _uniqueUserId; + final String _endpoint = 'https://api.mixpanel.com/track'; + final String _projectToken; + final String _version; + + MixPanelAnalytics({ + required String uniqueUserId, + required String projectToken, + required String version, + }) : _uniqueUserId = uniqueUserId, + _projectToken = projectToken, + _version = version; + + @override + void cleanUp() {} + + @override + void track({ + required String event, + }) { + var payload = jsonEncode({ + 'event': event, + 'properties': { + 'distinct_id': _uniqueUserId, + 'token': _projectToken, + 'platform': _getPlatform(), + 'dart_version': Platform.version, + 'is_ci': ci.isCI, + 'version': _version, + } + }); + + _quietPost(payload); + } + + String _getPlatform() { + if (Platform.isMacOS) { + return 'MacOS'; + } else if (Platform.isWindows) { + return 'Windows'; + } else if (Platform.isLinux) { + return 'Linux'; + } else { + return 'Unknown'; + } + } + + Future _quietPost(String payload) async { + try { + await http.post( + Uri.parse(_endpoint), + body: 'data=$payload', + headers: { + 'Accept': 'text/plain', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ).timeout(const Duration(seconds: 2)); + } catch (e) { + return; + } + } +} diff --git a/lib/src/better_command_runner/better_command_runner.dart b/lib/src/better_command_runner/better_command_runner.dart new file mode 100644 index 0000000..16cf04a --- /dev/null +++ b/lib/src/better_command_runner/better_command_runner.dart @@ -0,0 +1,181 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:cli_tools/src/better_command_runner/exit_exception.dart'; + +/// A function type for executing code before running a command. +typedef OnBeforeRunCommand = Future Function( + BetterCommandRunner runner, +); + +/// A function type for passing log messages. +typedef PassMessage = void Function(String message); + +/// A function type for setting the log level. +/// The [logLevel] is the log level to set. +/// The [commandName] is the name of the command if custom rules for log +/// levels are needed. +typedef SetLogLevel = void Function({ + required CommandRunnerLogLevel parsedLogLevel, + String? commandName, +}); + +/// A function type for tracking events. +typedef OnAnalyticsEvent = void Function(String event); + +/// A custom implementation of [CommandRunner] with additional features. +/// +/// This class extends the [CommandRunner] class from the `args` package and adds +/// additional functionality such as logging, setting log levels, tracking events, +/// and handling analytics. +/// +/// The [BetterCommandRunner] class provides a more enhanced command line interface +/// for running commands and handling command line arguments. +class BetterCommandRunner extends CommandRunner { + final PassMessage? _logError; + final PassMessage? _logInfo; + final SetLogLevel? _setLogLevel; + final OnBeforeRunCommand? _onBeforeRunCommand; + OnAnalyticsEvent? _onAnalyticsEvent; + + final ArgParser _argParser; + + /// Creates a new instance of [BetterCommandRunner]. + /// + /// The [executableName] is the name of the executable for the command line interface. + /// The [description] is a description of the command line interface. + /// The [logError] function is used to pass error log messages. + /// The [logInfo] function is used to pass informational log messages. + /// The [setLogLevel] function is used to set the log level. + /// The [onBeforeRunCommand] function is executed before running a command. + /// The [onAnalyticsEvent] function is used to track events. + /// The [wrapTextColumn] is the column width for wrapping text in the command line interface. + BetterCommandRunner( + super.executableName, + super.description, { + PassMessage? logError, + PassMessage? logInfo, + SetLogLevel? setLogLevel, + OnBeforeRunCommand? onBeforeRunCommand, + OnAnalyticsEvent? onAnalyticsEvent, + int? wrapTextColumn, + }) : _logError = logError, + _logInfo = logInfo, + _onBeforeRunCommand = onBeforeRunCommand, + _setLogLevel = setLogLevel, + _onAnalyticsEvent = onAnalyticsEvent, + _argParser = ArgParser(usageLineLength: wrapTextColumn) { + argParser.addFlag( + BetterCommandRunnerFlags.quiet, + abbr: BetterCommandRunnerFlags.quietAbbr, + defaultsTo: false, + negatable: false, + help: 'Suppress all cli output. Is overridden by ' + ' -${BetterCommandRunnerFlags.verboseAbbr}, --${BetterCommandRunnerFlags.verbose}.', + ); + + argParser.addFlag( + BetterCommandRunnerFlags.verbose, + abbr: BetterCommandRunnerFlags.verboseAbbr, + defaultsTo: false, + negatable: false, + help: 'Prints additional information useful for development. ' + 'Overrides --${BetterCommandRunnerFlags.quietAbbr}, --${BetterCommandRunnerFlags.quiet}.', + ); + + if (_onAnalyticsEvent != null) { + argParser.addFlag( + BetterCommandRunnerFlags.analytics, + abbr: BetterCommandRunnerFlags.analyticsAbbr, + defaultsTo: true, + negatable: true, + help: 'Toggles if analytics data is sent. ', + ); + } + } + + @override + ArgParser get argParser => _argParser; + + /// Adds a list of commands to the command runner. + void addCommands(List commands) { + for (var command in commands) { + addCommand(command); + } + } + + /// Checks if analytics is enabled. + bool analyticsEnabled() => _onAnalyticsEvent != null; + + @override + ArgResults parse(Iterable args) { + try { + return super.parse(args); + } on UsageException catch (e) { + _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.invalid); + _logError?.call(e.toString()); + throw ExitException(ExitCodeType.commandNotFound); + } + } + + @override + void printUsage() { + _logInfo?.call(usage); + } + + @override + Future runCommand(ArgResults topLevelResults) async { + _setLogLevel?.call( + parsedLogLevel: _parseLogLevel(topLevelResults), + commandName: topLevelResults.command?.name, + ); + + if (argParser.options.containsKey(BetterCommandRunnerFlags.analytics) && + !topLevelResults[BetterCommandRunnerFlags.analytics]) { + _onAnalyticsEvent = null; + } + + await _onBeforeRunCommand?.call(this); + + try { + await super.runCommand(topLevelResults); + if (topLevelResults.command == null) { + _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.help); + } else { + _onAnalyticsEvent?.call(topLevelResults.command!.name!); + } + } on UsageException catch (e) { + _logError?.call(e.toString()); + _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.invalid); + throw ExitException(ExitCodeType.commandNotFound); + } + } + + CommandRunnerLogLevel _parseLogLevel(ArgResults topLevelResults) { + if (topLevelResults[BetterCommandRunnerFlags.verbose]) { + return CommandRunnerLogLevel.verbose; + } else if (topLevelResults[BetterCommandRunnerFlags.quiet]) { + return CommandRunnerLogLevel.quiet; + } + + return CommandRunnerLogLevel.normal; + } +} + +/// Constants for the command runner flags. +abstract class BetterCommandRunnerFlags { + static const quiet = 'quiet'; + static const quietAbbr = 'q'; + static const verbose = 'verbose'; + static const verboseAbbr = 'v'; + static const analytics = 'analytics'; + static const analyticsAbbr = 'a'; +} + +/// Constants for the command runner analytics events. +abstract class BetterCommandRunnerAnalyticsEvents { + static const help = 'help'; + static const invalid = 'invalid'; +} + +/// An enum for the command runner log levels. +enum CommandRunnerLogLevel { quiet, verbose, normal } diff --git a/lib/src/better_command_runner/exit_exception.dart b/lib/src/better_command_runner/exit_exception.dart new file mode 100644 index 0000000..abf9cb7 --- /dev/null +++ b/lib/src/better_command_runner/exit_exception.dart @@ -0,0 +1,28 @@ +enum ExitCodeType { + /// General errors - This code is often used to indicate generic or + /// unspecified errors. + general(1), + + /// Command invoked cannot execute - The specified command was found but + /// couldn't be executed. + commandInvokedCannotExecute(126), + + /// Command not found - The specified command was not found or couldn't be + /// located. + commandNotFound(127); + + const ExitCodeType(this.exitCode); + final int exitCode; +} + +/// An exception that can be thrown to exit the command with a specific exit +class ExitException implements Exception { + /// Creates an instance of [ExitException]. + ExitException([this.exitCodeType = ExitCodeType.general]); + + /// The type of exit code to use. + final ExitCodeType exitCodeType; + + /// The exit code to use. + int get exitCode => exitCodeType.exitCode; +} diff --git a/lib/src/local_storage_manager/local_storage_manager.dart b/lib/src/local_storage_manager/local_storage_manager.dart new file mode 100644 index 0000000..58741f3 --- /dev/null +++ b/lib/src/local_storage_manager/local_storage_manager.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// An abstract class that provides methods for storing, fetching and removing +/// json files from local storage. +abstract base class LocalStorageManager { + /// Fetches the home directory of the current user. + static Directory get homeDirectory { + var envVars = Platform.environment; + + if (Platform.isWindows) { + return Directory(envVars['UserProfile']!); + } else if (Platform.isLinux || Platform.isMacOS) { + return Directory(envVars['HOME']!); + } + throw (Exception('Unsupported platform.')); + } + + /// Removes a file from the local storage. + /// If the file does not exist, nothing will happen. + /// + /// [fileName] The name of the file to remove. + /// [localStoragePath] The path to the local storage directory. + /// [onError] A function that will be called if an error occurs. If not + /// provided an exception will be thrown. + static Future removeFile({ + required String fileName, + required String localStoragePath, + Function(Object e)? onError, + }) async { + var file = File(p.join(localStoragePath, fileName)); + + if (!file.existsSync()) return; + + try { + await file.delete(); + } catch (e) { + if (onError != null) { + onError(e); + } else { + throw Exception('Failed to remove file. error: $e'); + } + } + } + + /// Stores a json file in the local storage. + /// If the file already exists it will be overwritten. + /// + /// [fileName] The name of the file to store. + /// [json] The json data to store. + /// [localStoragePath] The path to the local storage directory. + /// [onError] A function that will be called if an error occurs. If not + /// provided an exception will be thrown. + static Future storeJsonFile({ + required String fileName, + required Map json, + required String localStoragePath, + void Function(Object e)? onError, + }) async { + var file = File(p.join(localStoragePath, fileName)); + + try { + if (!file.existsSync()) { + file.createSync(recursive: true); + } + + var jsonString = const JsonEncoder.withIndent(' ').convert(json); + + file.writeAsStringSync(jsonString); + } catch (e) { + if (onError != null) { + onError(e); + } else { + throw Exception('Failed to store json file. error: $e'); + } + } + } + + /// Tries to fetch and deserialize a json file from the local storage. + /// If the file does not exist or if an error occurs during reading or + /// deserialization, null will be returned. + /// + /// [fileName] The name of the file to fetch. + /// [localStoragePath] The path to the local storage directory. + /// [fromJson] A function that is used to deserialize the json data. + /// [onReadError] A function that will be called if an error occurs when + /// reading the file. If not provided, an exception will be thrown. + /// [onDeserializationError] A function that will be called if an error occurs + /// when deserializing the json data. If not provided, an exception will be + /// thrown. + static Future tryFetchAndDeserializeJsonFile({ + required String fileName, + required String localStoragePath, + required T Function(Map json) fromJson, + T? Function(Object e, File file)? onReadError, + T? Function(Object e, File file)? onDeserializationError, + }) async { + var file = File(p.join(localStoragePath, fileName)); + + if (!file.existsSync()) return null; + + dynamic json; + try { + json = jsonDecode(file.readAsStringSync()); + } catch (e) { + if (onReadError != null) { + return onReadError(e, file); + } else { + throw Exception('Failed to read json file. error: $e'); + } + } + + try { + return fromJson(json); + } catch (e) { + if (onDeserializationError != null) { + return onDeserializationError(e, file); + } else { + throw Exception('Failed to deserialize json file. error: $e'); + } + } + } +} diff --git a/lib/src/logger/helpers/ansi_style.dart b/lib/src/logger/helpers/ansi_style.dart new file mode 100644 index 0000000..f7d7dd5 --- /dev/null +++ b/lib/src/logger/helpers/ansi_style.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +bool? _ansiSupportedRef; + +/// Returns true if the terminal supports ANSI escape codes. +bool get ansiSupported { + return _ansiSupportedRef ??= stdout.hasTerminal && stdout.supportsAnsiEscapes; +} + +/// Standard ANSI escape code for customizing terminal text output. +/// +/// [Source](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors) +enum AnsiStyle { + terminalDefault('\x1B[39m'), + red('\x1B[31m'), + yellow('\x1B[33m'), + blue('\x1B[34m'), + cyan('\x1B[36m'), + lightGreen('\x1B[92m'), + darkGray('\x1B[90m'), + bold('\x1B[1m'), + italic('\x1B[3m'), + _reset('\x1B[0m'); + + /// Creates a new instance of [AnsiStyle]. + const AnsiStyle(this.ansiCode); + + /// The ANSI escape code for the style. + final String ansiCode; + + /// Wraps text with ansi escape code for style if stdout has terminal and + /// supports ansi escapes. + String wrap(String text) { + if (!ansiSupported) { + return text; + } + + return '$ansiCode$text${AnsiStyle._reset.ansiCode}'; + } +} diff --git a/lib/src/logger/helpers/progress.dart b/lib/src/logger/helpers/progress.dart new file mode 100644 index 0000000..8b5a4f5 --- /dev/null +++ b/lib/src/logger/helpers/progress.dart @@ -0,0 +1,173 @@ +// Derived from mason_logger: https://github.com/felangel/mason/blob/master/packages/mason_logger/lib/src/progress.dart Licenced under the MIT Licence. + +// The MIT License (MIT) +// Copyright (c) 2023 Felix Angelov +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, +// and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. + +import 'dart:async'; +import 'dart:io'; + +import 'package:cli_tools/src/logger/helpers/ansi_style.dart'; + +/// {@template progress_options} +/// An object containing configuration for a [Progress] instance. +/// {@endtemplate} +class ProgressOptions { + /// {@macro progress_options} + const ProgressOptions({this.animation = const ProgressAnimation()}); + + /// The progress animation configuration. + final ProgressAnimation animation; +} + +/// {@template progress_animation} +/// An object which contains configuration for the animation +/// of a [Progress] instance. +/// {@endtemplate} +class ProgressAnimation { + /// {@macro progress_animation} + const ProgressAnimation({this.frames = _defaultFrames}); + + static const _defaultFrames = [ + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏' + ]; + + /// The list of animation frames. + final List frames; +} + +/// {@template progress} +/// A class that can be used to display progress information to the user. +/// {@endtemplate} +class Progress { + /// {@macro progress} + Progress( + this._message, + this._stdout, { + ProgressOptions options = const ProgressOptions(), + }) : _stopwatch = Stopwatch(), + _options = options { + _stopwatch + ..reset() + ..start(); + + // The animation is only shown when it would be meaningful. + // Do not animate if the stdio type is not a terminal. + // Do not animate if the log level is lower than info since other logs + // might interrupt the animation + if (!_stdout.hasTerminal) { + var frames = _options.animation.frames; + var char = frames.isEmpty ? '' : frames.first; + var prefix = char.isEmpty ? char : '${AnsiStyle.lightGreen.wrap(char)} '; + _write('$prefix$_message...\n'); + return; + } + + _timer = Timer.periodic(const Duration(milliseconds: 80), _onTick); + _onTick(_timer); + } + + final ProgressOptions _options; + + final Stdout _stdout; + + final Stopwatch _stopwatch; + + Timer? _timer; + + String _message; + + int _index = 0; + + /// End the progress and mark it as a successful completion. + /// + /// See also: + /// + /// * [fail], to end the progress and mark it as failed. + /// * [cancel], to cancel the progress entirely and remove the written line. + void complete([String? update]) { + _stopwatch.stop(); + _write( + '''$_clearLine${AnsiStyle.lightGreen.wrap('✓')} ${update ?? _message} $_time\n''', + ); + _timer?.cancel(); + } + + /// End the progress and mark it as failed. + /// + /// See also: + /// + /// * [complete], to end the progress and mark it as a successful completion. + /// * [cancel], to cancel the progress entirely and remove the written line. + void fail([String? update]) { + _timer?.cancel(); + _write( + '$_clearLine${AnsiStyle.red.wrap('✗')} ${update ?? _message} $_time\n'); + _stopwatch.stop(); + } + + /// Update the progress message. + void update(String update) { + if (_timer != null) _write(_clearLine); + _message = update; + _onTick(_timer); + } + + /// Cancel the progress and remove the written line. + void cancel() { + _timer?.cancel(); + _write(_clearLine); + _stopwatch.stop(); + } + + // Stops timer from calling [_onTick(...)]. + void stopAnimation() { + _timer?.cancel(); + } + + String get _clearLine { + return '\u001b[2K' // clear current line + '\r'; // bring cursor to the start of the current line + } + + void _onTick(Timer? _) { + _index++; + var frames = _options.animation.frames; + var char = frames.isEmpty ? '' : frames[_index % frames.length]; + var prefix = char.isEmpty ? char : '${AnsiStyle.lightGreen.wrap(char)} '; + + _write('$_clearLine$prefix$_message... $_time'); + } + + void _write(String object) { + _stdout.write(object); + } + + String get _time { + var elapsedTime = _stopwatch.elapsed.inMilliseconds; + var displayInMilliseconds = elapsedTime < 100; + var time = displayInMilliseconds ? elapsedTime : elapsedTime / 1000; + var formattedTime = + displayInMilliseconds ? '${time}ms' : '${time.toStringAsFixed(1)}s'; + return AnsiStyle.darkGray.wrap('($formattedTime)'); + } +} diff --git a/lib/src/logger/logger.dart b/lib/src/logger/logger.dart new file mode 100644 index 0000000..c168a3e --- /dev/null +++ b/lib/src/logger/logger.dart @@ -0,0 +1,133 @@ +/// Serverpods internal logger interface. +/// All logging output should go through this interface. +/// The purpose is to simplify implementing and switching out concrete logger +/// implementations. +abstract class Logger { + LogLevel logLevel; + + /// If defined, defines what column width text should be wrapped. + int? get wrapTextColumn; + + Logger(this.logLevel); + + /// Display debug [message] to the user. + /// Commands should use this for information that is important for + /// debugging purposes. + void debug( + String message, { + bool newParagraph, + LogType type, + }); + + /// Display a normal [message] to the user. + /// Command should use this as the standard communication channel for + /// success, progress or information messages. + void info( + String message, { + bool newParagraph, + LogType type, + }); + + /// Display a warning [message] to the user. + /// Commands should use this if they have important but not critical + /// information for the user. + void warning( + String message, { + bool newParagraph, + LogType type, + }); + + /// Display an error [message] to the user. + /// Commands should use this if they want to inform a user that an error + /// has occurred. + void error( + String message, { + bool newParagraph, + StackTrace? stackTrace, + LogType type, + }); + + /// Display a progress message on [LogLevel.info] while running [runner] + /// function. + /// + /// Uses return value from [runner] to print set progress success status. + /// Returns return value from [runner]. + Future progress( + String message, + Future Function() runner, { + bool newParagraph, + }); + + /// Directly write a [message] to the output. + /// Generally the other methods should be used instead of this. But this + /// method can be used for more direct control of the output. + /// + /// If [newParagraph] is set to true, output is written as a new paragraph. + /// [LogLevel] can be set to control the log level of the message. + void write( + String message, + LogLevel logLevel, { + bool newParagraph = false, + bool newLine = true, + }); + + /// Returns a [Future] that completes once all logging is complete. + Future flush(); +} + +enum LogLevel { + debug('debug'), + info('info'), + warning('warning'), + error('error'), + nothing('nothing'); + + const LogLevel(this.name); + final String name; +} + +enum TextLogStyle { + init, + normal, + hint, + header, + bullet, + command, + success, +} + +abstract class LogType { + const LogType(); +} + +/// Does not apply any formatting to the log before logging. +/// Assumes log is formatted with end line symbol. +class RawLogType extends LogType { + const RawLogType(); +} + +/// Box style console formatting. +/// If [title] is set the box will have a title row. +class BoxLogType extends LogType { + final String? title; + const BoxLogType({ + this.title, + bool newParagraph = true, + }); +} + +/// Abstract style console formatting. +/// Enables more precise settings for log message. +class TextLogType extends LogType { + static const init = TextLogType(style: TextLogStyle.init); + static const normal = TextLogType(style: TextLogStyle.normal); + static const hint = TextLogType(style: TextLogStyle.hint); + static const header = TextLogType(style: TextLogStyle.header); + static const bullet = TextLogType(style: TextLogStyle.bullet); + static const command = TextLogType(style: TextLogStyle.command); + static const success = TextLogType(style: TextLogStyle.success); + + final TextLogStyle style; + + const TextLogType({required this.style}); +} diff --git a/lib/src/logger/loggers/std_out_logger.dart b/lib/src/logger/loggers/std_out_logger.dart new file mode 100644 index 0000000..0ea4532 --- /dev/null +++ b/lib/src/logger/loggers/std_out_logger.dart @@ -0,0 +1,334 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:cli_tools/src/logger/logger.dart'; +import 'package:cli_tools/src/logger/helpers/ansi_style.dart'; +import 'package:cli_tools/src/logger/helpers/progress.dart'; +import 'package:super_string/super_string.dart'; + +/// Logger that logs using the [Stdout] library. +/// Errors and Warnings are printed on [stderr] and other messages are logged +/// on [stdout]. +class StdOutLogger extends Logger { + static const int _defaultColumnWrap = 80; + + Progress? trackedAnimationInProgress; + + final Map? _replacements; + + StdOutLogger(super.logLevel, {Map? replacements}) + : _replacements = replacements; + + @override + int? get wrapTextColumn => stdout.hasTerminal ? stdout.terminalColumns : null; + + @override + void debug( + String message, { + bool newParagraph = false, + LogType type = TextLogType.normal, + }) { + if (ansiSupported) { + _log( + AnsiStyle.darkGray.wrap(message), + LogLevel.debug, + newParagraph, + type, + ); + } else { + _log( + message, + LogLevel.debug, + newParagraph, + type, + prefix: 'DEBUG: ', + ); + } + } + + @override + void info( + String message, { + bool newParagraph = false, + LogType type = TextLogType.normal, + }) { + _log(message, LogLevel.info, newParagraph, type); + } + + @override + void warning( + String message, { + bool newParagraph = false, + LogType type = TextLogType.normal, + }) { + if (ansiSupported) { + _log( + AnsiStyle.yellow.wrap(message), + LogLevel.warning, + newParagraph, + type, + ); + } else { + _log( + message, + LogLevel.warning, + newParagraph, + type, + prefix: 'WARNING: ', + ); + } + } + + @override + void error( + String message, { + bool newParagraph = false, + StackTrace? stackTrace, + LogType type = TextLogType.normal, + }) { + if (ansiSupported) { + _log( + AnsiStyle.red.wrap(message), + LogLevel.error, + newParagraph, + type, + ); + } else { + _log( + message, + LogLevel.error, + newParagraph, + type, + prefix: 'ERROR: ', + ); + } + + if (stackTrace != null) { + _log( + AnsiStyle.red.wrap(stackTrace.toString()), + LogLevel.error, + newParagraph, + type, + ); + } + } + + @override + Future progress( + String message, + Future Function() runner, { + bool newParagraph = false, + }) async { + if (logLevel.index > LogLevel.info.index) { + return await runner(); + } + + _stopAnimationInProgress(); + + // Write an empty line before the progress message if a new paragraph is + // requested. + if (newParagraph) { + write( + '', + LogLevel.info, + newParagraph: false, + newLine: true, + ); + } + + var progress = Progress(message, stdout); + trackedAnimationInProgress = progress; + bool success = await runner(); + trackedAnimationInProgress = null; + success ? progress.complete() : progress.fail(); + return success; + } + + @override + Future flush() async { + await stderr.flush(); + await stdout.flush(); + } + + bool shouldLog(LogLevel logLevel) { + return logLevel.index >= this.logLevel.index; + } + + void _log( + String message, + LogLevel logLevel, + bool newParagraph, + LogType type, { + String prefix = '', + }) { + if (message == '') return; + if (!shouldLog(logLevel)) return; + + if (type is BoxLogType) { + message = _formatAsBox( + wrapColumn: wrapTextColumn ?? _defaultColumnWrap, + message: message, + title: type.title, + ); + } else if (type is TextLogType) { + switch (type.style) { + case TextLogStyle.command: + message = ' ${AnsiStyle.cyan.wrap('\$')} $message'; + break; + case TextLogStyle.bullet: + message = ' • $message'; + break; + case TextLogStyle.normal: + message = '$prefix$message'; + break; + case TextLogStyle.init: + message = AnsiStyle.cyan.wrap(AnsiStyle.bold.wrap(message)); + break; + case TextLogStyle.header: + message = AnsiStyle.bold.wrap(message); + break; + case TextLogStyle.success: + message = + '✅ ${AnsiStyle.lightGreen.wrap(AnsiStyle.bold.wrap(message))}\n'; + break; + case TextLogStyle.hint: + message = AnsiStyle.darkGray.wrap(AnsiStyle.italic.wrap(message)); + break; + } + + message = _wrapText(message, wrapTextColumn ?? _defaultColumnWrap); + } + + write( + message, + logLevel, + newParagraph: newParagraph, + newLine: type is! RawLogType, + ); + } + + @override + void write( + String message, + LogLevel logLevel, { + newParagraph = false, + newLine = true, + }) { + message = switch (_replacements) { + null => message, + Map replacements => replacements.entries.fold( + message, + (String acc, entry) => acc.replaceAll(entry.key, entry.value), + ), + }; + + _stopAnimationInProgress(); + if (logLevel.index >= LogLevel.warning.index) { + stderr.write('${newParagraph ? '\n' : ''}$message${newLine ? '\n' : ''}'); + } else { + stdout.write('${newParagraph ? '\n' : ''}$message${newLine ? '\n' : ''}'); + } + } + + void _stopAnimationInProgress() { + if (trackedAnimationInProgress != null) { + trackedAnimationInProgress?.stopAnimation(); + // Since animation modifies the current line we add a new line so that + // the next print doesn't end up on the same line. + stdout.write('\n'); + } + + trackedAnimationInProgress = null; + } +} + +/// wrap text based on column width +String _wrapText(String text, int columnWidth) { + var textLines = text.split('\n'); + List outLines = []; + for (var line in textLines) { + var leadingTrimChar = _tryGetLeadingTrimmableChar(line); + // wordWrap(...) uses trim as part of its implementation which removes all + // leading trimmable characters. + // In order to preserve them we temporarily replace the first char with a + // non trimmable character. + if (leadingTrimChar != null) { + line = '@${line.substring(1)}'; + } + + var wrappedLine = line.wordWrap(width: columnWidth); + + if (leadingTrimChar != null) { + wrappedLine = '$leadingTrimChar${wrappedLine.substring(1)}'; + } + outLines.add(wrappedLine); + } + + return outLines.join('\n'); +} + +String? _tryGetLeadingTrimmableChar(String text) { + if (text.isNotEmpty && text.first.trim().isEmpty) { + return text.first; + } + + return null; +} + +/// Wraps the message in a box. +/// +/// Example output: +/// +/// ┌─ [title] ─┐ +/// │ [message] │ +/// └───────────┘ +/// +/// When [title] is provided, the box will have a title above it. +String _formatAsBox({ + required String message, + String? title, + required int wrapColumn, +}) { + const int kPaddingLeftRight = 1; + const int kEdges = 2; + + var maxTextWidthPerLine = wrapColumn - kEdges - kPaddingLeftRight * 2; + var lines = _wrapText(message, maxTextWidthPerLine).split('\n'); + var lineWidth = lines.map((String line) => line.length).toList(); + var maxColumnSize = + lineWidth.reduce((int currLen, int maxLen) => math.max(currLen, maxLen)); + var textWidth = math.min(maxColumnSize, maxTextWidthPerLine); + var textWithPaddingWidth = textWidth + kPaddingLeftRight * 2; + + var buffer = StringBuffer(); + + // Write `┌─ [title] ─┐`. + buffer.write('┌'); + buffer.write('─'); + if (title == null) { + buffer.write('─' * (textWithPaddingWidth - 1)); + } else { + buffer.write(' $title '); + buffer.write('─' * (textWithPaddingWidth - title.length - 3)); + } + buffer.write('┐'); + buffer.write('\n'); + + // Write `│ [message] │`. + for (int lineIdx = 0; lineIdx < lines.length; lineIdx++) { + buffer.write('│'); + buffer.write(' ' * kPaddingLeftRight); + buffer.write(lines[lineIdx]); + var remainingSpacesToEnd = textWidth - lineWidth[lineIdx]; + buffer.write(' ' * (remainingSpacesToEnd + kPaddingLeftRight)); + buffer.write('│'); + buffer.write('\n'); + } + + // Write `└───────────┘`. + buffer.write('└'); + buffer.write('─' * textWithPaddingWidth); + buffer.write('┘'); + + return buffer.toString(); +} diff --git a/lib/src/logger/loggers/void_logger.dart b/lib/src/logger/loggers/void_logger.dart new file mode 100644 index 0000000..afeaf03 --- /dev/null +++ b/lib/src/logger/loggers/void_logger.dart @@ -0,0 +1,62 @@ +import 'package:cli_tools/src/logger/logger.dart'; + +/// Logger that logs no output. +/// +/// Intended to be used for testing to silence any printed output. +class VoidLogger extends Logger { + VoidLogger() : super(LogLevel.debug); + + @override + int? get wrapTextColumn => null; + + @override + void debug( + String message, { + bool newParagraph = false, + LogType type = const RawLogType(), + }) {} + + @override + void info( + String message, { + bool newParagraph = false, + LogType type = const RawLogType(), + }) {} + + @override + void warning( + String message, { + bool newParagraph = false, + LogType type = const RawLogType(), + }) {} + + @override + void error( + String message, { + bool newParagraph = false, + StackTrace? stackTrace, + LogType type = const RawLogType(), + }) {} + + @override + Future flush() { + return Future(() => {}); + } + + @override + Future progress( + String message, + Future Function() runner, { + bool newParagraph = true, + }) async { + return await runner(); + } + + @override + void write( + String message, + LogLevel logLevel, { + bool newParagraph = false, + bool newLine = true, + }) {} +} diff --git a/lib/src/package_version/package_version.dart b/lib/src/package_version/package_version.dart new file mode 100644 index 0000000..0f0a9b1 --- /dev/null +++ b/lib/src/package_version/package_version.dart @@ -0,0 +1,87 @@ +import 'package:pub_semver/pub_semver.dart'; + +/// Constants for the package version. +abstract class PackageVersionConstants { + static const badConnectionRetryTimeout = Duration(hours: 1); + static const localStorageValidityTime = Duration(days: 1); +} + +/// A class that provides methods to fetch, store and validate the latest +/// package version with reasonable caching. +abstract class PackageVersion { + /// Fetches the latest package version. + /// If the stored version exists and is valid, it will be returned. + /// Otherwise, the latest version will be fetched. + /// If the fetch is successful the version will be stored and returned with + /// a validity time of [PackageVersionConstants.localStorageValidityTime]. + /// + /// If the fetch is unsuccessful, a timeout for + /// [PackageVersionConstants.badConnectionRetryTimeout] is enforced before + /// attempting to fetch the latest version again. + static Future fetchLatestPackageVersion({ + required Future Function(PackageVersionData versionArtefact) + storePackageVersionData, + required Future Function() loadPackageVersionData, + required Future Function() fetchLatestPackageVersion, + }) async { + var storedVersionData = await loadPackageVersionData(); + + if (storedVersionData != null && _validVersion(storedVersionData)) { + return storedVersionData.version; + } + + var latestPackageVersion = await fetchLatestPackageVersion(); + + await _storePubDevVersion( + latestPackageVersion, + storePackageVersionData: storePackageVersionData, + ); + + return latestPackageVersion; + } + + static bool _validVersion(PackageVersionData versionData) { + return versionData.validUntil.isAfter(DateTime.now()); + } + + static Future _storePubDevVersion( + Version? version, { + required Future Function(PackageVersionData versionArtefact) + storePackageVersionData, + }) async { + PackageVersionData versionArtefact; + if (version != null) { + versionArtefact = PackageVersionData( + version, + DateTime.now().add(PackageVersionConstants.localStorageValidityTime), + ); + } else { + versionArtefact = PackageVersionData( + Version.none, + DateTime.now().add(PackageVersionConstants.badConnectionRetryTimeout), + ); + } + + await storePackageVersionData(versionArtefact); + } +} + +/// A class that holds the package version and the validity time that can be +/// stored and retrieved from a local storage. +class PackageVersionData { + Version version; + DateTime validUntil; + + PackageVersionData(this.version, this.validUntil); + + factory PackageVersionData.fromJson(Map json) => + PackageVersionData( + Version.parse(json['version']), + DateTime.fromMillisecondsSinceEpoch(json['valid_until']), + ); + + Map toJson() => { + 'version': version.toString(), + 'valid_until': validUntil.millisecondsSinceEpoch + }; +} diff --git a/lib/src/package_version/pub_api_client.dart b/lib/src/package_version/pub_api_client.dart new file mode 100644 index 0000000..e4f518f --- /dev/null +++ b/lib/src/package_version/pub_api_client.dart @@ -0,0 +1,70 @@ +import 'package:http/http.dart' as http; +import 'package:pub_api_client/pub_api_client.dart'; +import 'package:pub_semver/pub_semver.dart'; + +/// A client for the pub.dev API. +class PubApiClient { + final PubClient _pubClient; + final Duration _requestTimeout; + final void Function(String error)? _onError; + + PubApiClient({ + void Function(String error)? onError, + http.Client? httpClient, + requestTimeout = const Duration(seconds: 2), + }) : _pubClient = PubClient(client: httpClient), + _requestTimeout = requestTimeout, + _onError = onError; + + /// Tries to fetch the latest stable version, version does not include '-' or '+', + /// for the package named [packageName]. + /// + /// If it fails [Null] is returned. + Future tryFetchLatestStableVersion(String packageName) async { + String? latestStableVersion; + try { + var packageVersions = await _pubClient + .packageVersions(packageName) + .timeout(_requestTimeout); + latestStableVersion = _tryGetLatestStableVersion(packageVersions); + } catch (e) { + _onError?.call('Failed to fetch latest version for $packageName.'); + _logPubClientException(e); + return null; + } + + if (latestStableVersion == null) return null; + + try { + return Version.parse(latestStableVersion); + } catch (e) { + _onError + ?.call('Failed to parse version for $packageName: ${e.toString()}'); + return null; + } + } + + String? _tryGetLatestStableVersion(List packageVersions) { + for (var version in packageVersions) { + if (!version.contains('-') && !version.contains('+')) { + return version; + } + } + + return null; + } + + /// Required because of an issue with the pub_api_client package. + /// Issue: https://github.com/leoafarias/pub_api_client/issues/35 + void _logPubClientException(Object exception) { + try { + _onError?.call(exception.toString()); + } catch (_) { + _onError?.call(exception.runtimeType.toString()); + } + } + + void close() { + _pubClient.close(); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..c87a649 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,477 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + args: + dependency: "direct main" + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + ci: + dependency: "direct main" + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + url: "https://pub.dev" + source: hosted + version: "1.7.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + dart_mappable: + dependency: transitive + description: + name: dart_mappable + sha256: "47269caf2060533c29b823ff7fa9706502355ffcb61e7f2a374e3a0fb2f2c3f0" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_lints: + dependency: transitive + description: + name: flutter_lints + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + http: + dependency: "direct main" + description: + name: http + sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba + url: "https://pub.dev" + source: hosted + version: "1.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + meta: + dependency: transitive + description: + name: meta + sha256: "25dfcaf170a0190f47ca6355bdd4552cb8924b430512ff0cafb8db9bd41fe33b" + url: "https://pub.dev" + source: hosted + version: "1.14.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + oauth2: + dependency: transitive + description: + name: oauth2 + sha256: c4013ef62be37744efdc0861878fd9e9285f34db1f9e331cc34100d7674feb42 + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_api_client: + dependency: "direct main" + description: + name: pub_api_client + sha256: cc3d2c93df3823553de6a3e7d3ac09a3f43f8c271af4f43c2795266090ac9625 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + pub_semver: + dependency: "direct main" + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec: + dependency: transitive + description: + name: pubspec + sha256: f534a50a2b4d48dc3bc0ec147c8bd7c304280fff23b153f3f11803c4d49d927e + url: "https://pub.dev" + source: hosted + version: "2.3.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" + serverpod_lints: + dependency: "direct dev" + description: + name: serverpod_lints + sha256: "6f326b85f0cc8e80d9f802c753075c037218bf6bbe5f0e9da9bea6546aa718f7" + url: "https://pub.dev" + source: hosted + version: "1.2.6" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + super_string: + dependency: "direct main" + description: + name: super_string + sha256: ba41acf9fbb318b3fc0d57c9235779100394d85d83f45ab533615df1f3146ea7 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: d72b538180efcf8413cd2e4e6fcc7ae99c7712e0909eb9223f9da6e6d0ef715f + url: "https://pub.dev" + source: hosted + version: "1.25.4" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2419f20b0c8677b2d67c8ac4d1ac7372d862dc6c460cdbb052b40155408cd794" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4d070a6bc36c1c4e89f20d353bfd71dc30cdf2bd0e14349090af360a029ab292" + url: "https://pub.dev" + source: hosted + version: "0.6.2" + type_plus: + dependency: transitive + description: + name: type_plus + sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: a2662fb1f114f4296cf3f5a50786a2d888268d7776cf681aa17d660ffa23b246 + url: "https://pub.dev" + source: hosted + version: "14.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "4188706108906f002b3a293509234588823c8c979dc83304e229ff400c996b05" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "939ab60734a4f8fa95feacb55804fa278de28bdeef38e616dc08e44a84adea23" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.2.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..170d8e4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,28 @@ +name: cli_tools +version: 0.0.1 +description: A collection of tools for building great command-line interfaces. +repository: https://github.com/serverpod/cli_tools +homepage: https://serverpod.dev +issue_tracker: https://github.com/serverpod/cli_tools/issues + +topics: + - cli + - tools + - command-line + - dart + +environment: + sdk: ^3.1.0 + +dependencies: + args: ^2.5.0 + ci: ^0.1.0 + http: ^1.2.0 + path: ^1.9.0 + pub_api_client: ^2.7.0 + pub_semver: ^2.1.4 + super_string: ^1.0.3 + +dev_dependencies: + test: ^1.24.0 + serverpod_lints: ^1.2.6 \ No newline at end of file diff --git a/test/better_command_runner/analytics_test.dart b/test/better_command_runner/analytics_test.dart new file mode 100644 index 0000000..afee6f2 --- /dev/null +++ b/test/better_command_runner/analytics_test.dart @@ -0,0 +1,183 @@ +import 'package:args/command_runner.dart'; +import 'package:cli_tools/better_command_runner.dart'; +import 'package:test/test.dart'; + +class MockCommand extends Command { + static String commandName = 'mock-command'; + + @override + String get description => 'Mock command used for testing'; + + @override + void run() {} + + @override + String get name => commandName; + + MockCommand() { + argParser.addOption( + 'name', + defaultsTo: 'serverpod', + allowed: ['serverpod'], + ); + } +} + +void main() { + late BetterCommandRunner runner; + group('Given runner with null onAnalyticsEvent callback', () { + var runner = BetterCommandRunner( + 'test', + 'this is a test cli', + onAnalyticsEvent: null, + ); + + test('when checking if analytics is enabled then false is returned.', () { + expect(runner.analyticsEnabled(), isFalse); + }); + + test('when checking available flags then analytics flag is not present.', + () { + expect(runner.argParser.options.keys, isNot(contains('analytics'))); + }); + }); + + group('Given runner with onAnalyticsEvent callback defined', () { + var runner = BetterCommandRunner( + 'test', + 'this is a test cli', + onAnalyticsEvent: (event) {}, + ); + + test('when checking if analytics is enabled then true is returned.', () { + expect(runner.analyticsEnabled(), isTrue); + }); + + test('when checking available flags then analytics is defined.', () { + expect(runner.argParser.options.keys, contains('analytics')); + }); + }); + + group('Given runner with analytics enabled', () { + List events = []; + setUp(() { + runner = BetterCommandRunner( + 'test', + 'this is a test cli', + onAnalyticsEvent: (event) => events.add(event), + ); + assert(runner.analyticsEnabled()); + }); + + tearDown(() { + events = []; + }); + + test( + 'when running command with no-analytics flag then analytics is disabled.', + () async { + var args = ['--no-${BetterCommandRunnerFlags.analytics}']; + await runner.run(args); + + expect(runner.analyticsEnabled(), isFalse); + }); + + test('when running invalid command then "invalid" analytics event is sent.', + () async { + var args = ['this could be a command argument']; + + try { + await runner.run(args); + } catch (_) { + // Ignore any exception + } + + expect(events, hasLength(1)); + expect(events.first, equals('invalid')); + }); + + test( + 'when running with unknown command then "invalid" analytics event is sent.', + () async { + var args = ['--unknown-command']; + + try { + await runner.run(args); + } catch (_) { + // Ignore any exception + } + + expect(events, hasLength(1)); + expect(events.first, equals('invalid')); + }); + + test('when running with no command then "help" analytics event is sent.', + () async { + await runner.run([]); + + expect(events, hasLength(1)); + expect(events.first, equals('help')); + }); + + test( + 'when running with only registered flag then "help" analytics event is sent.', + () async { + await runner.run(['--${BetterCommandRunnerFlags.analytics}']); + + expect(events, hasLength(1)); + expect(events.first, equals('help')); + }); + }); + + group('Given runner with registered command and analytics enabled', () { + List events = []; + setUp(() { + runner = BetterCommandRunner( + 'test', + 'this is a test cli', + onAnalyticsEvent: (event) => events.add(event), + )..addCommand(MockCommand()); + assert(runner.analyticsEnabled()); + }); + + tearDown(() { + events = []; + }); + + test('when running with registered command then command name is sent,', + () async { + var args = [MockCommand.commandName]; + + await runner.run(args); + + expect(events, hasLength(1)); + expect(events.first, equals(MockCommand.commandName)); + }); + + test( + 'when running with registered command and option then command name is sent,', + () async { + var args = [MockCommand.commandName, '--name', 'serverpod']; + + await runner.run(args); + + expect(events, hasLength(1)); + expect(events.first, equals(MockCommand.commandName)); + }); + + test( + 'when running with registered command but invalid option then "invalid" analytics event is sent,', + () async { + var args = [MockCommand.commandName, '--name', 'invalid']; + + try { + await runner.run(args); + } catch (_) { + // Ignore any exception + } + + expect(events, hasLength(1)); + expect(events.first, equals('invalid')); + }); + }); +} diff --git a/test/better_command_runner/command_test.dart b/test/better_command_runner/command_test.dart new file mode 100644 index 0000000..9cf1630 --- /dev/null +++ b/test/better_command_runner/command_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:cli_tools/better_command_runner.dart'; +import 'package:test/test.dart'; + +class MockCommand extends Command { + void Function()? onRun; + static String commandName = 'mock-command'; + List trackedOptions = []; + int numberOfRuns = 0; + + @override + String get description => 'Mock command used for testing'; + + @override + void run() { + onRun?.call(); + trackedOptions.add(argResults!['name']); + numberOfRuns++; + } + + @override + String get name => commandName; + + MockCommand({this.onRun}) { + argParser.addOption( + 'name', + defaultsTo: 'serverpod', + allowed: ['serverpod', 'stockholm'], + ); + } +} + +void main() { + late BetterCommandRunner runner; + late MockCommand mockCommand; + group('Given runner with registered command', () { + setUp(() { + mockCommand = MockCommand(); + runner = BetterCommandRunner( + 'test', + 'this is a test cli', + )..addCommand(mockCommand); + }); + + group('when running registered command with global flag', () { + var args = [ + '--${BetterCommandRunnerFlags.quiet}', + MockCommand.commandName + ]; + setUp(() async => await runner.run(args)); + + test('then command is run once', () { + expect(mockCommand.numberOfRuns, equals(1)); + }); + }); + + group('when running registered command without option', () { + var args = [MockCommand.commandName]; + setUp(() async => await runner.run(args)); + + test('then command is run once', () { + expect(mockCommand.numberOfRuns, equals(1)); + }); + + test('then default option is used in command', () { + expect(mockCommand.trackedOptions, hasLength(1)); + expect(mockCommand.trackedOptions.first, equals('serverpod')); + }); + }); + + group('when running registered command and valid option', () { + var args = [MockCommand.commandName, '--name', 'stockholm']; + setUp(() async => await runner.run(args)); + + test('then command is run once', () { + expect(mockCommand.numberOfRuns, equals(1)); + }); + + test('then provided option is used in command', () { + expect(mockCommand.trackedOptions, hasLength(1)); + expect(mockCommand.trackedOptions.first, equals('stockholm')); + }); + }); + + test( + 'when running registered command with invalid option then command is never run.', + () async { + var args = [MockCommand.commandName, '--name', 'invalid']; + + try { + await runner.run(args); + } catch (_) { + // ignore any exceptions. + } + expect(mockCommand.numberOfRuns, equals(0)); + }); + }); + + test( + 'Given runner with registered command and onBeforeRunCommand callback then onBeforeRunCommand is called before running command', + () async { + List calls = []; + mockCommand = MockCommand(onRun: () => calls.add('command')); + runner = BetterCommandRunner( + 'test', + 'this is a test cli', + onBeforeRunCommand: (_) => Future(() => calls.add('callback')), + )..addCommand(mockCommand); + + var args = [MockCommand.commandName]; + + await runner.run(args); + expect(calls, equals(['callback', 'command'])); + }); +} diff --git a/test/better_command_runner/exit_exceptions_test.dart b/test/better_command_runner/exit_exceptions_test.dart new file mode 100644 index 0000000..d4510af --- /dev/null +++ b/test/better_command_runner/exit_exceptions_test.dart @@ -0,0 +1,72 @@ +import 'package:args/command_runner.dart'; +import 'package:cli_tools/better_command_runner.dart'; +import 'package:test/test.dart'; + +class MockCommand extends Command { + static String commandName = 'mock-command'; + + @override + String get description => 'Mock command used for testing'; + + @override + void run() {} + + MockCommand() { + argParser.addOption( + 'name', + // To make an option truly mandatory, you need to set mandatory to true. + // and also define a callback. + mandatory: true, + callback: (name) {}, + allowed: ['serverpod'], + ); + } + + @override + String get name => commandName; +} + +void main() { + group('Given runner with registered command', () { + var runner = BetterCommandRunner( + 'test', + 'this is a test cli', + )..addCommand(MockCommand()); + + test( + 'when running with unknown command then ExitException with command not found exit code is thrown.', + () async { + var args = ['unknown-command']; + + await expectLater( + () => runner.run(args), + throwsA(predicate( + (e) => e.exitCodeType == ExitCodeType.commandNotFound)), + ); + }); + + test( + 'when running with invalid command then ExitException with command not found exit code is thrown.', + () async { + List args = ['this it not a valid command']; + + await expectLater( + () => runner.run(args), + throwsA(predicate( + (e) => e.exitCodeType == ExitCodeType.commandNotFound)), + ); + }); + + test( + 'when running command without mandatory option then ExitException with command not found exit code is thrown.', + () async { + List args = [MockCommand.commandName]; + + await expectLater( + () => runner.run(args), + throwsA(predicate( + (e) => e.exitCodeType == ExitCodeType.commandNotFound)), + ); + }); + }); +} diff --git a/test/better_command_runner/logging_test.dart b/test/better_command_runner/logging_test.dart new file mode 100644 index 0000000..f96aa52 --- /dev/null +++ b/test/better_command_runner/logging_test.dart @@ -0,0 +1,96 @@ +import 'package:args/command_runner.dart'; +import 'package:cli_tools/better_command_runner.dart'; +import 'package:test/test.dart'; + +class MockCommand extends Command { + static String commandName = 'mock-command'; + + @override + String get description => 'Mock command used for testing'; + + @override + void run() {} + + MockCommand() { + argParser.addOption( + 'name', + // To make an option truly mandatory, you need to set mandatory to true. + // and also define a callback. + mandatory: true, + callback: (name) {}, + allowed: ['serverpod'], + ); + } + + @override + String get name => commandName; +} + +void main() { + group('Given runner with registered command and logging monitor', () { + var errors = []; + var infos = []; + var runner = BetterCommandRunner( + 'test', + 'this is a test cli', + logError: (message) => errors.add(message), + logInfo: (message) => infos.add(message), + )..addCommand(MockCommand()); + tearDown(() { + errors.clear(); + infos.clear(); + }); + + group('when running with no command', () { + setUp(() async => await runner.run([])); + + test('then usage message is logged to info.', () async { + expect(infos, hasLength(1)); + expect(infos.first, runner.usage); + }); + + test('then no message is logged to error.', () async { + expect(errors, hasLength(0)); + }); + }); + + group('when running with invalid command', () { + setUp(() async { + try { + await runner.run(['this it not a valid command']); + } catch (e) { + // Ignore the exception. + } + }); + + test('then no message is logged to info.', () async { + expect(infos, hasLength(0)); + }); + + test('then could not find message is logged to error.', () async { + expect(errors, hasLength(1)); + expect(errors.first, contains('Could not find')); + }); + }); + + group('when running command without mandatory option', () { + setUp(() async { + try { + await runner.run([MockCommand.commandName]); + } catch (e) { + // Ignore the exception. + } + }); + + test('then no message is logged to info.', () async { + expect(infos, hasLength(0)); + }); + + test('then option name is mandatory message is logged to error.', + () async { + expect(errors, hasLength(1)); + expect(errors.first, contains('Option name is mandatory')); + }); + }); + }); +} diff --git a/test/better_command_runner/parse_log_level_test.dart b/test/better_command_runner/parse_log_level_test.dart new file mode 100644 index 0000000..587c90e --- /dev/null +++ b/test/better_command_runner/parse_log_level_test.dart @@ -0,0 +1,148 @@ +import 'package:args/command_runner.dart'; +import 'package:cli_tools/better_command_runner.dart'; +import 'package:test/test.dart'; + +class MockCommand extends Command { + static String commandName = 'mock-command'; + + @override + String get description => 'Mock command used for testing'; + + @override + void run() {} + + @override + String get name => commandName; +} + +void main() { + CommandRunnerLogLevel? logLevel; + String? parsedCommandName; + + tearDown(() { + parsedCommandName = null; + logLevel = null; + }); + group('Given runner with setLogLevel callback', () { + var runner = BetterCommandRunner( + 'test', + 'this is a test cli', + setLogLevel: ({ + required CommandRunnerLogLevel parsedLogLevel, + String? commandName, + }) { + logLevel = parsedLogLevel; + parsedCommandName = commandName; + }, + ); + + group('when no flags are provided', () { + setUp(() async => await runner.run([])); + test('then parsed log level is normal.', () { + expect(logLevel, CommandRunnerLogLevel.normal); + }); + + test('then no command passed to setLogLevel.', () { + expect(parsedCommandName, null); + }); + }); + + group('when only quiet flag is provided', () { + var args = ['--${BetterCommandRunnerFlags.quiet}']; + + setUp(() async => await runner.run(args)); + test('then parsed log level is quiet.', () { + expect(logLevel, CommandRunnerLogLevel.quiet); + }); + + test('then no command passed to setLogLevel.', () { + expect(parsedCommandName, null); + }); + }); + + group('when only verbose flag is provided', () { + var args = ['--${BetterCommandRunnerFlags.verbose}']; + + setUp(() async => await runner.run(args)); + test('then parsed log level is verbose.', () { + expect(logLevel, CommandRunnerLogLevel.verbose); + }); + + test('then no command passed to setLogLevel.', () { + expect(parsedCommandName, null); + }); + }); + + group('when both quiet and verbose flags are provided', () { + var args = [ + '--${BetterCommandRunnerFlags.quiet}', + '--${BetterCommandRunnerFlags.verbose}', + ]; + + setUp(() async => await runner.run(args)); + test('then parsed log level is verbose.', () { + expect(logLevel, CommandRunnerLogLevel.verbose); + }); + + test('then no command passed to setLogLevel.', () { + expect(parsedCommandName, null); + }); + }); + }); + + group('Given runner with setLogLevel callback and registered command', () { + var runner = BetterCommandRunner( + 'test', + 'this is a test cli', + setLogLevel: ({ + required CommandRunnerLogLevel parsedLogLevel, + String? commandName, + }) { + logLevel = parsedLogLevel; + parsedCommandName = commandName; + }, + )..addCommand(MockCommand()); + + test( + 'when running with registered command then command name is passed to setLogLevel callback.', + () async { + var args = [MockCommand.commandName]; + + await runner.run(args); + + expect(parsedCommandName, MockCommand.commandName); + }); + + group('when verbose flag is passed before registered command', () { + var args = [ + '--${BetterCommandRunnerFlags.verbose}', + MockCommand.commandName, + ]; + setUp(() async => await runner.run(args)); + + test('then verbose log level is passed to setLogLevel callback.', () { + expect(logLevel, CommandRunnerLogLevel.verbose); + }); + + test('then command name is passed to setLogLevel callback.', () { + expect(parsedCommandName, MockCommand.commandName); + }); + }); + + group('when verbose flag is passed after registered command', () { + var args = [ + MockCommand.commandName, + '--${BetterCommandRunnerFlags.verbose}', + ]; + setUp(() async => await runner.run(args)); + + test('then verbose log level is passed to setLogLevel callback.', () { + expect(logLevel, CommandRunnerLogLevel.verbose); + }); + + test('then command name is passed to setLogLevel callback.', () { + expect(parsedCommandName, MockCommand.commandName); + }); + }); + }); +} diff --git a/test/package_version_test.dart b/test/package_version_test.dart new file mode 100644 index 0000000..18038a8 --- /dev/null +++ b/test/package_version_test.dart @@ -0,0 +1,140 @@ +import 'package:cli_tools/package_version.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + var versionForTest = Version(1, 1, 0); + + group('Given version is returned from load', () { + test( + 'when fetched with "valid until" time in the future then version is returned.', + () async { + var packageVersionData = PackageVersionData( + versionForTest, + DateTime.now().add(const Duration(hours: 1)), + ); + + var fetchedVersion = await PackageVersion.fetchLatestPackageVersion( + storePackageVersionData: (PackageVersionData _) async => (), + loadPackageVersionData: () async => packageVersionData, + fetchLatestPackageVersion: () async => null, + ); + + expect(fetchedVersion, isNotNull); + expect(fetchedVersion, versionForTest); + }); + + group('with "valid until" already passed', () { + test( + 'when successful in fetching latest version from fetch then new version is stored and returned.', + () async { + PackageVersionData? storedPackageVersion; + PackageVersionData packageVersionData = PackageVersionData( + versionForTest, + DateTime.now().subtract( + const Duration(hours: 1), + ), + ); + var pubDevVersion = versionForTest.nextMajor; + + var fetchedVersion = await PackageVersion.fetchLatestPackageVersion( + storePackageVersionData: + (PackageVersionData versionDataToStore) async => + (storedPackageVersion = versionDataToStore), + loadPackageVersionData: () async => packageVersionData, + fetchLatestPackageVersion: () async => pubDevVersion, + ); + + expect(fetchedVersion, isNotNull); + expect(fetchedVersion, pubDevVersion); + expect(storedPackageVersion, isNotNull); + expect(storedPackageVersion?.version, pubDevVersion); + var timeDifferent = storedPackageVersion?.validUntil.difference( + DateTime.now() + .add(PackageVersionConstants.localStorageValidityTime)); + expect( + timeDifferent, + lessThan(const Duration(minutes: 1)), + reason: 'Successfully stored version should have a valid until time ' + 'close to the current time plus the validity time.', + ); + }); + + test('when failing to fetch latest then null is returned.', () async { + PackageVersionData? storedPackageVersion; + var version = await PackageVersion.fetchLatestPackageVersion( + storePackageVersionData: + (PackageVersionData packageVersionData) async => + (storedPackageVersion = packageVersionData), + loadPackageVersionData: () async => null, + fetchLatestPackageVersion: () async => null, + ); + + expect(version, isNull); + expect(storedPackageVersion, isNotNull); + var timeDifferent = storedPackageVersion?.validUntil.difference( + DateTime.now() + .add(PackageVersionConstants.badConnectionRetryTimeout)); + expect( + timeDifferent, + lessThan(const Duration(minutes: 1)), + reason: 'Failed fetch stored version should have a valid until time ' + 'close to the current time plus the bad connection retry timeout.', + ); + }); + }); + }); + + group('Given no version is returned from load', () { + test( + 'when successful in fetching latest version then version is stored and returned.', + () async { + PackageVersionData? storedPackageVersion; + var version = await PackageVersion.fetchLatestPackageVersion( + storePackageVersionData: + (PackageVersionData packageVersionData) async => + (storedPackageVersion = packageVersionData), + loadPackageVersionData: () async => null, + fetchLatestPackageVersion: () async => versionForTest, + ); + + expect(version, isNotNull); + expect(version, versionForTest); + expect(storedPackageVersion, isNotNull); + expect(storedPackageVersion?.version, versionForTest); + var timeDifferent = storedPackageVersion?.validUntil.difference( + DateTime.now().add(PackageVersionConstants.localStorageValidityTime)); + expect( + timeDifferent, + lessThan(const Duration(minutes: 1)), + reason: 'Successfully stored version should have a valid until time ' + 'close to the current time plus the validity time.', + ); + }); + + test( + 'when failing to fetch latest then timeout is stored and null is returned.', + () async { + PackageVersionData? storedPackageVersion; + var version = await PackageVersion.fetchLatestPackageVersion( + storePackageVersionData: + (PackageVersionData packageVersionData) async => + (storedPackageVersion = packageVersionData), + loadPackageVersionData: () async => null, + fetchLatestPackageVersion: () async => null, + ); + + expect(version, isNull); + expect(storedPackageVersion, isNotNull); + var timeDifferent = storedPackageVersion?.validUntil.difference( + DateTime.now() + .add(PackageVersionConstants.badConnectionRetryTimeout)); + expect( + timeDifferent, + lessThan(const Duration(minutes: 1)), + reason: 'Failed fetch stored version should have a valid until time ' + 'close to the current time plus the bad connection retry timeout.', + ); + }); + }); +} diff --git a/test/pub_api_client_test.dart b/test/pub_api_client_test.dart new file mode 100644 index 0000000..5a7fb5a --- /dev/null +++ b/test/pub_api_client_test.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:cli_tools/cli_tools.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:cli_tools/src/package_version/pub_api_client.dart'; +import 'package:test/test.dart'; + +MockClient createMockClient({ + required String body, + required int status, + Duration responseDelay = const Duration(seconds: 0), +}) { + return MockClient((request) { + if (request.method != 'GET') throw NoSuchMethodError; + return Future(() async { + await Future.delayed(responseDelay); + return http.Response(body, status); + }); + }); +} + +abstract class PubApiClientTestConstants { + static const String testPackageName = 'serverpod_cli'; +} + +void main() { + test( + 'Empty body and not found status response when fetching latest version then does not throw.', + () async { + // Issue: https://github.com/leoafarias/pub_api_client/issues/35 + var httpClient = createMockClient( + body: '', + status: HttpStatus.notFound, + ); + var pubApiClient = PubApiClient(httpClient: httpClient); + + var version = await pubApiClient + .tryFetchLatestStableVersion(PubApiClientTestConstants.testPackageName); + + expect(version, isNull); + }); + + test( + 'Empty body and not found status response when fetching latest version then returns null.', + () async { + // Issue: https://github.com/leoafarias/pub_api_client/issues/35 + var httpClient = createMockClient( + body: '', + status: HttpStatus.notFound, + ); + var pubApiClient = PubApiClient(httpClient: httpClient); + + var version = await pubApiClient + .tryFetchLatestStableVersion(PubApiClientTestConstants.testPackageName); + + expect(version, isNull); + }); + + test('Timeout is reached when fetching latest version then returns null.', + () async { + var timeout = const Duration(milliseconds: 1); + var httpClient = createMockClient( + body: '', + status: HttpStatus.ok, + responseDelay: + timeout * 10, // Messaged is delayed longer than the timeout + ); + var pubApiClient = + PubApiClient(httpClient: httpClient, requestTimeout: timeout); + + var version = await pubApiClient + .tryFetchLatestStableVersion(PubApiClientTestConstants.testPackageName); + + expect(version, isNull); + }); + + test( + 'Non stable version before stable version when fetching latest then returns first stable', + () async { + var expectedVersion = Version(1, 2, 3); + var httpClient = createMockClient( + body: ''' +{ + "name": "${PubApiClientTestConstants.testPackageName}", + "versions": ["1.2.5-b", "1.2.4+a", "${expectedVersion.toString()}"] +} +''', + status: HttpStatus.ok, + ); + var pubApiClient = PubApiClient(httpClient: httpClient); + + var version = await pubApiClient + .tryFetchLatestStableVersion(PubApiClientTestConstants.testPackageName); + + expect(version, isNotNull); + expect(version, expectedVersion); + }); + + test('Only non stable versions when fetching latest then returns null', + () async { + var httpClient = createMockClient( + body: ''' +{ + "name": "${PubApiClientTestConstants.testPackageName}", + "versions": ["1.2.5-b", "1.2.4+a"] +} +''', + status: HttpStatus.ok, + ); + var pubApiClient = PubApiClient(httpClient: httpClient); + + var version = await pubApiClient + .tryFetchLatestStableVersion(PubApiClientTestConstants.testPackageName); + + expect(version, isNull); + }); + + test( + 'Invalid version format when fetching latest from pub.dev then returns null', + () async { + var httpClient = createMockClient( + body: ''' +{ + "name": "${PubApiClientTestConstants.testPackageName}", + "versions": ["invalid_format"] +} +''', + status: HttpStatus.ok, + ); + var pubApiClient = PubApiClient(httpClient: httpClient); + + var version = await pubApiClient + .tryFetchLatestStableVersion(PubApiClientTestConstants.testPackageName); + + expect(version, isNull); + }); +}