From 9b42b5f8535288ebe89f6218f5e7ada3ee6d91ec Mon Sep 17 00:00:00 2001 From: Gary Qian Date: Mon, 10 Oct 2022 10:55:01 -0700 Subject: [PATCH] [flutter_migrate] base boilerplate files (#2694) --- .../flutter_migrate/analysis_options.yaml | 7 + .../flutter_migrate/lib/src/base/command.dart | 74 + .../flutter_migrate/lib/src/base/common.dart | 287 ++++ .../flutter_migrate/lib/src/base/context.dart | 199 +++ .../lib/src/base/file_system.dart | 178 +++ packages/flutter_migrate/lib/src/base/io.dart | 340 ++++ .../flutter_migrate/lib/src/base/logger.dart | 1395 +++++++++++++++++ .../flutter_migrate/lib/src/base/project.dart | 91 ++ .../flutter_migrate/lib/src/base/signals.dart | 154 ++ .../lib/src/base/terminal.dart | 418 +++++ packages/flutter_migrate/pubspec.yaml | 15 + .../test/base/context_test.dart | 296 ++++ .../test/base/file_system_test.dart | 357 +++++ .../flutter_migrate/test/base/io_test.dart | 86 + .../test/base/logger_test.dart | 679 ++++++++ .../test/base/signals_test.dart | 181 +++ .../test/base/terminal_test.dart | 331 ++++ packages/flutter_migrate/test/src/common.dart | 200 +++ .../flutter_migrate/test/src/context.dart | 125 ++ packages/flutter_migrate/test/src/fakes.dart | 285 ++++ packages/flutter_migrate/test/src/io.dart | 138 ++ .../test/src/test_flutter_command_runner.dart | 40 + .../flutter_migrate/test/src/test_utils.dart | 62 + script/configs/custom_analysis.yaml | 5 +- 24 files changed, 5942 insertions(+), 1 deletion(-) create mode 100644 packages/flutter_migrate/analysis_options.yaml create mode 100644 packages/flutter_migrate/lib/src/base/command.dart create mode 100644 packages/flutter_migrate/lib/src/base/common.dart create mode 100644 packages/flutter_migrate/lib/src/base/context.dart create mode 100644 packages/flutter_migrate/lib/src/base/file_system.dart create mode 100644 packages/flutter_migrate/lib/src/base/io.dart create mode 100644 packages/flutter_migrate/lib/src/base/logger.dart create mode 100644 packages/flutter_migrate/lib/src/base/project.dart create mode 100644 packages/flutter_migrate/lib/src/base/signals.dart create mode 100644 packages/flutter_migrate/lib/src/base/terminal.dart create mode 100644 packages/flutter_migrate/test/base/context_test.dart create mode 100644 packages/flutter_migrate/test/base/file_system_test.dart create mode 100644 packages/flutter_migrate/test/base/io_test.dart create mode 100644 packages/flutter_migrate/test/base/logger_test.dart create mode 100644 packages/flutter_migrate/test/base/signals_test.dart create mode 100644 packages/flutter_migrate/test/base/terminal_test.dart create mode 100644 packages/flutter_migrate/test/src/common.dart create mode 100644 packages/flutter_migrate/test/src/context.dart create mode 100644 packages/flutter_migrate/test/src/fakes.dart create mode 100644 packages/flutter_migrate/test/src/io.dart create mode 100644 packages/flutter_migrate/test/src/test_flutter_command_runner.dart create mode 100644 packages/flutter_migrate/test/src/test_utils.dart diff --git a/packages/flutter_migrate/analysis_options.yaml b/packages/flutter_migrate/analysis_options.yaml new file mode 100644 index 0000000000..9d4b82234d --- /dev/null +++ b/packages/flutter_migrate/analysis_options.yaml @@ -0,0 +1,7 @@ +# Specify analysis options. + +include: ../../analysis_options.yaml + +linter: + rules: + public_member_api_docs: false # Standalone executable, no public API diff --git a/packages/flutter_migrate/lib/src/base/command.dart b/packages/flutter_migrate/lib/src/base/command.dart new file mode 100644 index 0000000000..18604d73a6 --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/command.dart @@ -0,0 +1,74 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:args/command_runner.dart'; + +enum ExitStatus { + success, + warning, + fail, + killed, +} + +class CommandResult { + const CommandResult(this.exitStatus); + + /// A command that succeeded. It is used to log the result of a command invocation. + factory CommandResult.success() { + return const CommandResult(ExitStatus.success); + } + + /// A command that exited with a warning. It is used to log the result of a command invocation. + factory CommandResult.warning() { + return const CommandResult(ExitStatus.warning); + } + + /// A command that failed. It is used to log the result of a command invocation. + factory CommandResult.fail() { + return const CommandResult(ExitStatus.fail); + } + + final ExitStatus exitStatus; + + @override + String toString() { + switch (exitStatus) { + case ExitStatus.success: + return 'success'; + case ExitStatus.warning: + return 'warning'; + case ExitStatus.fail: + return 'fail'; + case ExitStatus.killed: + return 'killed'; + } + } +} + +abstract class MigrateCommand extends Command { + @override + Future run() async { + await runCommand(); + } + + Future runCommand(); + + /// Gets the parsed command-line option named [name] as a `bool?`. + bool? boolArg(String name) { + if (!argParser.options.containsKey(name)) { + return null; + } + return argResults == null ? null : argResults![name] as bool; + } + + String? stringArg(String name) { + if (!argParser.options.containsKey(name)) { + return null; + } + return argResults == null ? null : argResults![name] as String?; + } + + /// Gets the parsed command-line option named [name] as an `int`. + int? intArg(String name) => argResults?[name] as int?; +} diff --git a/packages/flutter_migrate/lib/src/base/common.dart b/packages/flutter_migrate/lib/src/base/common.dart new file mode 100644 index 0000000000..70804f56dd --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/common.dart @@ -0,0 +1,287 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'file_system.dart'; + +/// Throw a specialized exception for expected situations +/// where the tool should exit with a clear message to the user +/// and no stack trace unless the --verbose option is specified. +/// For example: network errors. +Never throwToolExit(String? message, {int? exitCode}) { + throw ToolExit(message, exitCode: exitCode); +} + +/// Specialized exception for expected situations +/// where the tool should exit with a clear message to the user +/// and no stack trace unless the --verbose option is specified. +/// For example: network errors. +class ToolExit implements Exception { + ToolExit(this.message, {this.exitCode}); + + final String? message; + final int? exitCode; + + @override + String toString() => 'Error: $message'; +} + +/// Return the name of an enum item. +String getEnumName(dynamic enumItem) { + final String name = '$enumItem'; + final int index = name.indexOf('.'); + return index == -1 ? name : name.substring(index + 1); +} + +/// Runs [fn] with special handling of asynchronous errors. +/// +/// If the execution of [fn] does not throw a synchronous exception, and if the +/// [Future] returned by [fn] is completed with a value, then the [Future] +/// returned by [asyncGuard] is completed with that value if it has not already +/// been completed with an error. +/// +/// If the execution of [fn] throws a synchronous exception, and no [onError] +/// callback is provided, then the [Future] returned by [asyncGuard] is +/// completed with an error whose object and stack trace are given by the +/// synchronous exception. If an [onError] callback is provided, then the +/// [Future] returned by [asyncGuard] is completed with its result when passed +/// the error object and stack trace. +/// +/// If the execution of [fn] results in an asynchronous exception that would +/// otherwise be unhandled, and no [onError] callback is provided, then the +/// [Future] returned by [asyncGuard] is completed with an error whose object +/// and stack trace are given by the asynchronous exception. If an [onError] +/// callback is provided, then the [Future] returned by [asyncGuard] is +/// completed with its result when passed the error object and stack trace. +/// +/// After the returned [Future] is completed, whether it be with a value or an +/// error, all further errors resulting from the execution of [fn] are ignored. +/// +/// Rationale: +/// +/// Consider the following snippet: +/// ``` +/// try { +/// await foo(); +/// ... +/// } catch (e) { +/// ... +/// } +/// ``` +/// If the [Future] returned by `foo` is completed with an error, that error is +/// handled by the catch block. However, if `foo` spawns an asynchronous +/// operation whose errors are unhandled, those errors will not be caught by +/// the catch block, and will instead propagate to the containing [Zone]. This +/// behavior is non-intuitive to programmers expecting the `catch` to catch all +/// the errors resulting from the code under the `try`. +/// +/// As such, it would be convenient if the `try {} catch {}` here could handle +/// not only errors completing the awaited [Future]s it contains, but also +/// any otherwise unhandled asynchronous errors occurring as a result of awaited +/// expressions. This is how `await` is often assumed to work, which leads to +/// unexpected unhandled exceptions. +/// +/// [asyncGuard] is intended to wrap awaited expressions occurring in a `try` +/// block. The behavior described above gives the behavior that users +/// intuitively expect from `await`. Consider the snippet: +/// ``` +/// try { +/// await asyncGuard(() async { +/// var c = Completer(); +/// c.completeError('Error'); +/// }); +/// } catch (e) { +/// // e is 'Error'; +/// } +/// ``` +/// Without the [asyncGuard] the error 'Error' would be propagated to the +/// error handler of the containing [Zone]. With the [asyncGuard], the error +/// 'Error' is instead caught by the `catch`. +/// +/// [asyncGuard] also accepts an [onError] callback for situations in which +/// completing the returned [Future] with an error is not appropriate. +/// For example, it is not always possible to immediately await the returned +/// [Future]. In these cases, an [onError] callback is needed to prevent an +/// error from propagating to the containing [Zone]. +/// +/// [onError] must have type `FutureOr Function(Object error)` or +/// `FutureOr Function(Object error, StackTrace stackTrace)` otherwise an +/// [ArgumentError] will be thrown synchronously. +Future asyncGuard( + Future Function() fn, { + Function? onError, +}) { + if (onError != null && + onError is! _UnaryOnError && + onError is! _BinaryOnError) { + throw ArgumentError('onError must be a unary function accepting an Object, ' + 'or a binary function accepting an Object and ' + 'StackTrace. onError must return a T'); + } + final Completer completer = Completer(); + + void handleError(Object e, StackTrace s) { + if (completer.isCompleted) { + return; + } + if (onError == null) { + completer.completeError(e, s); + return; + } + if (onError is _BinaryOnError) { + completer.complete(onError(e, s)); + } else if (onError is _UnaryOnError) { + completer.complete(onError(e)); + } + } + + runZoned(() async { + try { + final T result = await fn(); + if (!completer.isCompleted) { + completer.complete(result); + } + // This catches all exceptions so that they can be propagated to the + // caller-supplied error handling or the completer. + } catch (e, s) { + // ignore: avoid_catches_without_on_clauses, forwards to Future + handleError(e, s); + } + // ignore: deprecated_member_use + }, onError: (Object e, StackTrace s) { + handleError(e, s); + }); + + return completer.future; +} + +typedef _UnaryOnError = FutureOr Function(Object error); +typedef _BinaryOnError = FutureOr Function( + Object error, StackTrace stackTrace); + +/// Whether the test is running in a web browser compiled to JavaScript. +/// +/// See also: +/// +/// * [kIsWeb], the equivalent constant in the `foundation` library. +const bool isBrowser = identical(0, 0.0); + +/// Whether the test is running on the Windows operating system. +/// +/// This does not include tests compiled to JavaScript running in a browser on +/// the Windows operating system. +/// +/// See also: +/// +/// * [isBrowser], which reports true for tests running in browsers. +bool get isWindows { + if (isBrowser) { + return false; + } + return Platform.isWindows; +} + +/// Whether the test is running on the macOS operating system. +/// +/// This does not include tests compiled to JavaScript running in a browser on +/// the macOS operating system. +/// +/// See also: +/// +/// * [isBrowser], which reports true for tests running in browsers. +bool get isMacOS { + if (isBrowser) { + return false; + } + return Platform.isMacOS; +} + +/// Whether the test is running on the Linux operating system. +/// +/// This does not include tests compiled to JavaScript running in a browser on +/// the Linux operating system. +/// +/// See also: +/// +/// * [isBrowser], which reports true for tests running in browsers. +bool get isLinux { + if (isBrowser) { + return false; + } + return Platform.isLinux; +} + +String? flutterRoot; + +/// Determine the absolute and normalized path for the root of the current +/// Flutter checkout. +/// +/// This method has a series of fallbacks for determining the repo location. The +/// first success will immediately return the root without further checks. +/// +/// The order of these tests is: +/// 1. FLUTTER_ROOT environment variable contains the path. +/// 2. Platform script is a data URI scheme, returning `../..` to support +/// tests run from `packages/flutter_tools`. +/// 3. Platform script is package URI scheme, returning the grandparent directory +/// of the package config file location from `packages/flutter_tools/.packages`. +/// 4. Platform script file path is the snapshot path generated by `bin/flutter`, +/// returning the grandparent directory from `bin/cache`. +/// 5. Platform script file name is the entrypoint in `packages/flutter_tools/bin/flutter_tools.dart`, +/// returning the 4th parent directory. +/// 6. The current directory +/// +/// If an exception is thrown during any of these checks, an error message is +/// printed and `.` is returned by default (6). +String defaultFlutterRoot({ + required FileSystem fileSystem, +}) { + const String kFlutterRootEnvironmentVariableName = + 'FLUTTER_ROOT'; // should point to //flutter/ (root of flutter/flutter repo) + const String kSnapshotFileName = + 'flutter_tools.snapshot'; // in //flutter/bin/cache/ + const String kFlutterToolsScriptFileName = + 'flutter_tools.dart'; // in //flutter/packages/flutter_tools/bin/ + String normalize(String path) { + return fileSystem.path.normalize(fileSystem.path.absolute(path)); + } + + if (Platform.environment.containsKey(kFlutterRootEnvironmentVariableName)) { + return normalize( + Platform.environment[kFlutterRootEnvironmentVariableName]!); + } + try { + if (Platform.script.scheme == 'data') { + return normalize('../..'); // The tool is running as a test. + } + final String Function(String) dirname = fileSystem.path.dirname; + + if (Platform.script.scheme == 'package') { + final String packageConfigPath = + Uri.parse(Platform.packageConfig!).toFilePath( + windows: isWindows, + ); + return normalize(dirname(dirname(dirname(packageConfigPath)))); + } + + if (Platform.script.scheme == 'file') { + final String script = Platform.script.toFilePath( + windows: isWindows, + ); + if (fileSystem.path.basename(script) == kSnapshotFileName) { + return normalize(dirname(dirname(fileSystem.path.dirname(script)))); + } + if (fileSystem.path.basename(script) == kFlutterToolsScriptFileName) { + return normalize(dirname(dirname(dirname(dirname(script))))); + } + } + } on Exception catch (error) { + // There is currently no logger attached since this is computed at startup. + // ignore: avoid_print + print('$error'); + } + return normalize('.'); +} diff --git a/packages/flutter_migrate/lib/src/base/context.dart b/packages/flutter_migrate/lib/src/base/context.dart new file mode 100644 index 0000000000..ea1dd4d83d --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/context.dart @@ -0,0 +1,199 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; + +import 'package:meta/meta.dart'; + +/// Generates an [AppContext] value. +/// +/// Generators are allowed to return `null`, in which case the context will +/// store the `null` value as the value for that type. +typedef Generator = dynamic Function(); + +/// An exception thrown by [AppContext] when you try to get a [Type] value from +/// the context, and the instantiation of the value results in a dependency +/// cycle. +class ContextDependencyCycleException implements Exception { + ContextDependencyCycleException._(this.cycle); + + /// The dependency cycle (last item depends on first item). + final List cycle; + + @override + String toString() => 'Dependency cycle detected: ${cycle.join(' -> ')}'; +} + +/// The Zone key used to look up the [AppContext]. +@visibleForTesting +const Object contextKey = _Key.key; + +/// The current [AppContext], as determined by the [Zone] hierarchy. +/// +/// This will be the first context found as we scan up the zone hierarchy, or +/// the "root" context if a context cannot be found in the hierarchy. The root +/// context will not have any values associated with it. +/// +/// This is guaranteed to never return `null`. +AppContext get context => + Zone.current[contextKey] as AppContext? ?? AppContext._root; + +/// A lookup table (mapping types to values) and an implied scope, in which +/// code is run. +/// +/// [AppContext] is used to define a singleton injection context for code that +/// is run within it. Each time you call [run], a child context (and a new +/// scope) is created. +/// +/// Child contexts are created and run using zones. To read more about how +/// zones work, see https://api.dart.dev/stable/dart-async/Zone-class.html. +class AppContext { + AppContext._( + this._parent, + this.name, [ + this._overrides = const {}, + this._fallbacks = const {}, + ]); + + final String? name; + final AppContext? _parent; + final Map _overrides; + final Map _fallbacks; + final Map _values = {}; + + List? _reentrantChecks; + + /// Bootstrap context. + static final AppContext _root = AppContext._(null, 'ROOT'); + + dynamic _boxNull(dynamic value) => value ?? _BoxedNull.instance; + + dynamic _unboxNull(dynamic value) => + value == _BoxedNull.instance ? null : value; + + /// Returns the generated value for [type] if such a generator exists. + /// + /// If [generators] does not contain a mapping for the specified [type], this + /// returns `null`. + /// + /// If a generator existed and generated a `null` value, this will return a + /// boxed value indicating null. + /// + /// If a value for [type] has already been generated by this context, the + /// existing value will be returned, and the generator will not be invoked. + /// + /// If the generator ends up triggering a reentrant call, it signals a + /// dependency cycle, and a [ContextDependencyCycleException] will be thrown. + dynamic _generateIfNecessary(Type type, Map generators) { + if (!generators.containsKey(type)) { + return null; + } + + return _values.putIfAbsent(type, () { + _reentrantChecks ??= []; + + final int index = _reentrantChecks!.indexOf(type); + if (index >= 0) { + // We're already in the process of trying to generate this type. + throw ContextDependencyCycleException._( + UnmodifiableListView(_reentrantChecks!.sublist(index))); + } + + _reentrantChecks!.add(type); + try { + return _boxNull(generators[type]!()); + } finally { + _reentrantChecks!.removeLast(); + if (_reentrantChecks!.isEmpty) { + _reentrantChecks = null; + } + } + }); + } + + /// Gets the value associated with the specified [type], or `null` if no + /// such value has been associated. + T? get() { + dynamic value = _generateIfNecessary(T, _overrides); + if (value == null && _parent != null) { + value = _parent!.get(); + } + return _unboxNull(value ?? _generateIfNecessary(T, _fallbacks)) as T?; + } + + /// Runs [body] in a child context and returns the value returned by [body]. + /// + /// If [overrides] is specified, the child context will return corresponding + /// values when consulted via [operator[]]. + /// + /// If [fallbacks] is specified, the child context will return corresponding + /// values when consulted via [operator[]] only if its parent context didn't + /// return such a value. + /// + /// If [name] is specified, the child context will be assigned the given + /// name. This is useful for debugging purposes and is analogous to naming a + /// thread in Java. + Future run({ + required FutureOr Function() body, + String? name, + Map? overrides, + Map? fallbacks, + ZoneSpecification? zoneSpecification, + }) async { + final AppContext child = AppContext._( + this, + name, + Map.unmodifiable(overrides ?? const {}), + Map.unmodifiable(fallbacks ?? const {}), + ); + return runZoned>( + () async => await body(), + zoneValues: <_Key, AppContext>{_Key.key: child}, + zoneSpecification: zoneSpecification, + ); + } + + @override + String toString() { + final StringBuffer buf = StringBuffer(); + String indent = ''; + AppContext? ctx = this; + while (ctx != null) { + buf.write('AppContext'); + if (ctx.name != null) { + buf.write('[${ctx.name}]'); + } + if (ctx._overrides.isNotEmpty) { + buf.write('\n$indent overrides: [${ctx._overrides.keys.join(', ')}]'); + } + if (ctx._fallbacks.isNotEmpty) { + buf.write('\n$indent fallbacks: [${ctx._fallbacks.keys.join(', ')}]'); + } + if (ctx._parent != null) { + buf.write('\n$indent parent: '); + } + ctx = ctx._parent; + indent += ' '; + } + return buf.toString(); + } +} + +/// Private key used to store the [AppContext] in the [Zone]. +class _Key { + const _Key(); + + static const _Key key = _Key(); + + @override + String toString() => 'context'; +} + +/// Private object that denotes a generated `null` value. +class _BoxedNull { + const _BoxedNull(); + + static const _BoxedNull instance = _BoxedNull(); +} diff --git a/packages/flutter_migrate/lib/src/base/file_system.dart b/packages/flutter_migrate/lib/src/base/file_system.dart new file mode 100644 index 0000000000..ee5eaf8b9f --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/file_system.dart @@ -0,0 +1,178 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:file/file.dart'; +import 'package:file/local.dart' as local_fs; +import 'package:meta/meta.dart'; + +import 'common.dart'; +import 'io.dart'; +import 'logger.dart'; +import 'signals.dart'; + +// package:file/local.dart must not be exported. This exposes LocalFileSystem, +// which we override to ensure that temporary directories are cleaned up when +// the tool is killed by a signal. +export 'package:file/file.dart'; + +/// Exception indicating that a file that was expected to exist was not found. +class FileNotFoundException implements IOException { + const FileNotFoundException(this.path); + + final String path; + + @override + String toString() => 'File not found: $path'; +} + +/// Return a relative path if [fullPath] is contained by the cwd, else return an +/// absolute path. +String getDisplayPath(String fullPath, FileSystem fileSystem) { + final String cwd = + fileSystem.currentDirectory.path + fileSystem.path.separator; + return fullPath.startsWith(cwd) ? fullPath.substring(cwd.length) : fullPath; +} + +/// This class extends [local_fs.LocalFileSystem] in order to clean up +/// directories and files that the tool creates under the system temporary +/// directory when the tool exits either normally or when killed by a signal. +class LocalFileSystem extends local_fs.LocalFileSystem { + LocalFileSystem(this._signals, this._fatalSignals, this.shutdownHooks); + + @visibleForTesting + LocalFileSystem.test({ + required Signals signals, + List fatalSignals = Signals.defaultExitSignals, + }) : this(signals, fatalSignals, ShutdownHooks()); + + Directory? _systemTemp; + final Map _signalTokens = {}; + + final ShutdownHooks shutdownHooks; + + Future dispose() async { + _tryToDeleteTemp(); + for (final MapEntry signalToken + in _signalTokens.entries) { + await _signals.removeHandler(signalToken.key, signalToken.value); + } + _signalTokens.clear(); + } + + final Signals _signals; + final List _fatalSignals; + + void _tryToDeleteTemp() { + try { + if (_systemTemp?.existsSync() ?? false) { + _systemTemp?.deleteSync(recursive: true); + } + } on FileSystemException { + // ignore + } + _systemTemp = null; + } + + // This getter returns a fresh entry under /tmp, like + // /tmp/flutter_tools.abcxyz, then the rest of the tool creates /tmp entries + // under that, like /tmp/flutter_tools.abcxyz/flutter_build_stuff.123456. + // Right before exiting because of a signal or otherwise, we delete + // /tmp/flutter_tools.abcxyz, not the whole of /tmp. + @override + Directory get systemTempDirectory { + if (_systemTemp == null) { + if (!superSystemTempDirectory.existsSync()) { + throwToolExit( + 'Your system temp directory (${superSystemTempDirectory.path}) does not exist. ' + 'Did you set an invalid override in your environment? See issue https://github.com/flutter/flutter/issues/74042 for more context.'); + } + _systemTemp = superSystemTempDirectory.createTempSync('flutter_tools.') + ..createSync(recursive: true); + // Make sure that the temporary directory is cleaned up if the tool is + // killed by a signal. + for (final ProcessSignal signal in _fatalSignals) { + final Object token = _signals.addHandler( + signal, + (ProcessSignal _) { + _tryToDeleteTemp(); + }, + ); + _signalTokens[signal] = token; + } + // Make sure that the temporary directory is cleaned up when the tool + // exits normally. + shutdownHooks.addShutdownHook( + _tryToDeleteTemp, + ); + } + return _systemTemp!; + } + + // This only exist because the memory file system does not support a systemTemp that does not exists #74042 + @visibleForTesting + Directory get superSystemTempDirectory => super.systemTempDirectory; +} + +/// A function that will be run before the VM exits. +typedef ShutdownHook = FutureOr Function(); + +abstract class ShutdownHooks { + factory ShutdownHooks() => _DefaultShutdownHooks(); + + /// Registers a [ShutdownHook] to be executed before the VM exits. + void addShutdownHook(ShutdownHook shutdownHook); + + @visibleForTesting + List get registeredHooks; + + /// Runs all registered shutdown hooks and returns a future that completes when + /// all such hooks have finished. + /// + /// Shutdown hooks will be run in groups by their [ShutdownStage]. All shutdown + /// hooks within a given stage will be started in parallel and will be + /// guaranteed to run to completion before shutdown hooks in the next stage are + /// started. + /// + /// This class is constructed before the [Logger], so it cannot be direct + /// injected in the constructor. + Future runShutdownHooks(Logger logger); +} + +class _DefaultShutdownHooks implements ShutdownHooks { + _DefaultShutdownHooks(); + + @override + final List registeredHooks = []; + + bool _shutdownHooksRunning = false; + + @override + void addShutdownHook(ShutdownHook shutdownHook) { + assert(!_shutdownHooksRunning); + registeredHooks.add(shutdownHook); + } + + @override + Future runShutdownHooks(Logger logger) async { + logger.printTrace( + 'Running ${registeredHooks.length} shutdown hook${registeredHooks.length == 1 ? '' : 's'}', + ); + _shutdownHooksRunning = true; + try { + final List> futures = >[]; + for (final ShutdownHook shutdownHook in registeredHooks) { + final FutureOr result = shutdownHook(); + if (result is Future) { + futures.add(result); + } + } + await Future.wait(futures); + } finally { + _shutdownHooksRunning = false; + } + logger.printTrace('Shutdown hooks complete'); + } +} diff --git a/packages/flutter_migrate/lib/src/base/io.dart b/packages/flutter_migrate/lib/src/base/io.dart new file mode 100644 index 0000000000..8ae28134f6 --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/io.dart @@ -0,0 +1,340 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This file serves as the single point of entry into the `dart:io` APIs +/// within Flutter tools. +/// +/// In order to make Flutter tools more testable, we use the `FileSystem` APIs +/// in `package:file` rather than using the `dart:io` file APIs directly (see +/// `file_system.dart`). Doing so allows us to swap out local file system +/// access with mockable (or in-memory) file systems, making our tests hermetic +/// vis-a-vis file system access. +/// +/// We also use `package:platform` to provide an abstraction away from the +/// static methods in the `dart:io` `Platform` class (see `platform.dart`). As +/// such, do not export Platform from this file! +/// +/// To ensure that all file system and platform API access within Flutter tools +/// goes through the proper APIs, we forbid direct imports of `dart:io` (via a +/// test), forcing all callers to instead import this file, which exports the +/// blessed subset of `dart:io` that is legal to use in Flutter tools. +/// +/// Because of the nature of this file, it is important that **platform and file +/// APIs not be exported from `dart:io` in this file**! Moreover, be careful +/// about any additional exports that you add to this file, as doing so will +/// increase the API surface that we have to test in Flutter tools, and the APIs +/// in `dart:io` can sometimes be hard to use in tests. + +// We allow `print()` in this file as a fallback for writing to the terminal via +// regular stdout/stderr/stdio paths. Everything else in the flutter_tools +// library should route terminal I/O through the [Stdio] class defined below. +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:io' as io + show + IOSink, + Process, + ProcessSignal, + Stdin, + StdinException, + Stdout, + StdoutException, + stderr, + stdin, + stdout; + +import 'package:meta/meta.dart'; + +import 'common.dart'; + +export 'dart:io' + show + BytesBuilder, + CompressionOptions, + // Directory, NO! Use `file_system.dart` + // File, NO! Use `file_system.dart` + // FileSystemEntity, NO! Use `file_system.dart` + GZipCodec, + HandshakeException, + HttpClient, + HttpClientRequest, + HttpClientResponse, + HttpClientResponseCompressionState, + HttpException, + HttpHeaders, + HttpRequest, + HttpResponse, + HttpServer, + HttpStatus, + IOException, + IOSink, + InternetAddress, + InternetAddressType, + // Link NO! Use `file_system.dart` + // NetworkInterface NO! Use `io.dart` + OSError, + Platform, + Process, + ProcessException, + // ProcessInfo, NO! use `io.dart` + ProcessResult, + // ProcessSignal NO! Use [ProcessSignal] below. + ProcessStartMode, + // RandomAccessFile NO! Use `file_system.dart` + ServerSocket, + SignalException, + Socket, + SocketException, + Stdin, + StdinException, + Stdout, + WebSocket, + WebSocketException, + WebSocketTransformer, + ZLibEncoder, + exitCode, + gzip, + pid, + // stderr, NO! Use `io.dart` + // stdin, NO! Use `io.dart` + // stdout, NO! Use `io.dart` + systemEncoding; + +/// A class that wraps stdout, stderr, and stdin, and exposes the allowed +/// operations. +/// +/// In particular, there are three ways that writing to stdout and stderr +/// can fail. A call to stdout.write() can fail: +/// * by throwing a regular synchronous exception, +/// * by throwing an exception asynchronously, and +/// * by completing the Future stdout.done with an error. +/// +/// This class enapsulates all three so that we don't have to worry about it +/// anywhere else. +class Stdio { + Stdio(); + + /// Tests can provide overrides to use instead of the stdout and stderr from + /// dart:io. + @visibleForTesting + Stdio.test({ + required io.Stdout stdout, + required io.IOSink stderr, + }) : _stdoutOverride = stdout, + _stderrOverride = stderr; + + io.Stdout? _stdoutOverride; + io.IOSink? _stderrOverride; + + // These flags exist to remember when the done Futures on stdout and stderr + // complete to avoid trying to write to a closed stream sink, which would + // generate a [StateError]. + bool _stdoutDone = false; + bool _stderrDone = false; + + Stream> get stdin => io.stdin; + + io.Stdout get stdout { + if (_stdout != null) { + return _stdout!; + } + _stdout = _stdoutOverride ?? io.stdout; + _stdout!.done.then( + (void _) { + _stdoutDone = true; + }, + onError: (Object err, StackTrace st) { + _stdoutDone = true; + }, + ); + return _stdout!; + } + + io.Stdout? _stdout; + + @visibleForTesting + io.IOSink get stderr { + if (_stderr != null) { + return _stderr!; + } + _stderr = _stderrOverride ?? io.stderr; + _stderr!.done.then( + (void _) { + _stderrDone = true; + }, + onError: (Object err, StackTrace st) { + _stderrDone = true; + }, + ); + return _stderr!; + } + + io.IOSink? _stderr; + + bool get hasTerminal => io.stdout.hasTerminal; + + static bool? _stdinHasTerminal; + + /// Determines whether there is a terminal attached. + /// + /// [io.Stdin.hasTerminal] only covers a subset of cases. In this check the + /// echoMode is toggled on and off to catch cases where the tool running in + /// a docker container thinks there is an attached terminal. This can cause + /// runtime errors such as "inappropriate ioctl for device" if not handled. + bool get stdinHasTerminal { + if (_stdinHasTerminal != null) { + return _stdinHasTerminal!; + } + if (stdin is! io.Stdin) { + return _stdinHasTerminal = false; + } + final io.Stdin ioStdin = stdin as io.Stdin; + if (!ioStdin.hasTerminal) { + return _stdinHasTerminal = false; + } + try { + final bool currentEchoMode = ioStdin.echoMode; + ioStdin.echoMode = !currentEchoMode; + ioStdin.echoMode = currentEchoMode; + } on io.StdinException { + return _stdinHasTerminal = false; + } + return _stdinHasTerminal = true; + } + + int? get terminalColumns => hasTerminal ? stdout.terminalColumns : null; + int? get terminalLines => hasTerminal ? stdout.terminalLines : null; + bool get supportsAnsiEscapes => hasTerminal && stdout.supportsAnsiEscapes; + + /// Writes [message] to [stderr], falling back on [fallback] if the write + /// throws any exception. The default fallback calls [print] on [message]. + void stderrWrite( + String message, { + void Function(String, dynamic, StackTrace)? fallback, + }) { + if (!_stderrDone) { + _stdioWrite(stderr, message, fallback: fallback); + return; + } + fallback == null + ? print(message) + : fallback( + message, + const io.StdoutException('stderr is done'), + StackTrace.current, + ); + } + + /// Writes [message] to [stdout], falling back on [fallback] if the write + /// throws any exception. The default fallback calls [print] on [message]. + void stdoutWrite( + String message, { + void Function(String, dynamic, StackTrace)? fallback, + }) { + if (!_stdoutDone) { + _stdioWrite(stdout, message, fallback: fallback); + return; + } + fallback == null + ? print(message) + : fallback( + message, + const io.StdoutException('stdout is done'), + StackTrace.current, + ); + } + + // Helper for [stderrWrite] and [stdoutWrite]. + void _stdioWrite( + io.IOSink sink, + String message, { + void Function(String, dynamic, StackTrace)? fallback, + }) { + asyncGuard(() async { + sink.write(message); + }, onError: (Object error, StackTrace stackTrace) { + if (fallback == null) { + print(message); + } else { + fallback(message, error, stackTrace); + } + }); + } + + /// Adds [stream] to [stdout]. + Future addStdoutStream(Stream> stream) => + stdout.addStream(stream); + + /// Adds [stream] to [stderr]. + Future addStderrStream(Stream> stream) => + stderr.addStream(stream); +} + +/// A portable version of [io.ProcessSignal]. +/// +/// Listening on signals that don't exist on the current platform is just a +/// no-op. This is in contrast to [io.ProcessSignal], where listening to +/// non-existent signals throws an exception. +/// +/// This class does NOT implement io.ProcessSignal, because that class uses +/// private fields. This means it cannot be used with, e.g., [Process.killPid]. +/// Alternative implementations of the relevant methods that take +/// [ProcessSignal] instances are available on this class (e.g. "send"). +class ProcessSignal { + @visibleForTesting + const ProcessSignal(this._delegate); + + static const ProcessSignal sigwinch = + PosixProcessSignal(io.ProcessSignal.sigwinch); + static const ProcessSignal sigterm = + PosixProcessSignal(io.ProcessSignal.sigterm); + static const ProcessSignal sigusr1 = + PosixProcessSignal(io.ProcessSignal.sigusr1); + static const ProcessSignal sigusr2 = + PosixProcessSignal(io.ProcessSignal.sigusr2); + static const ProcessSignal sigint = ProcessSignal(io.ProcessSignal.sigint); + static const ProcessSignal sigkill = ProcessSignal(io.ProcessSignal.sigkill); + + final io.ProcessSignal _delegate; + + Stream watch() { + return _delegate + .watch() + .map((io.ProcessSignal signal) => this); + } + + /// Sends the signal to the given process (identified by pid). + /// + /// Returns true if the signal was delivered, false otherwise. + /// + /// On Windows, this can only be used with [ProcessSignal.sigterm], which + /// terminates the process. + /// + /// This is implemented by sending the signal using [Process.killPid]. + bool send(int pid) { + assert(!isWindows || this == ProcessSignal.sigterm); + return io.Process.killPid(pid, _delegate); + } + + @override + String toString() => _delegate.toString(); +} + +/// A [ProcessSignal] that is only available on Posix platforms. +/// +/// Listening to a [_PosixProcessSignal] is a no-op on Windows. +@visibleForTesting +class PosixProcessSignal extends ProcessSignal { + const PosixProcessSignal(super.wrappedSignal); + + @override + Stream watch() { + // This uses the real platform since it invokes dart:io functionality directly. + if (isWindows) { + return const Stream.empty(); + } + return super.watch(); + } +} diff --git a/packages/flutter_migrate/lib/src/base/logger.dart b/packages/flutter_migrate/lib/src/base/logger.dart new file mode 100644 index 0000000000..c84f098af7 --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/logger.dart @@ -0,0 +1,1395 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:math'; + +import 'package:intl/intl.dart'; +import 'package:meta/meta.dart'; + +import 'common.dart'; +import 'io.dart'; +import 'terminal.dart' show OutputPreferences, Terminal, TerminalColor; + +const int kDefaultStatusPadding = 59; +final NumberFormat kSecondsFormat = NumberFormat('0.0'); +final NumberFormat kMillisecondsFormat = NumberFormat.decimalPattern(); + +/// Smallest column that will be used for text wrapping. If the requested column +/// width is smaller than this, then this is what will be used. +const int kMinColumnWidth = 10; + +/// A factory for generating [Stopwatch] instances for [Status] instances. +class StopwatchFactory { + /// const constructor so that subclasses may be const. + const StopwatchFactory(); + + /// Create a new [Stopwatch] instance. + /// + /// The optional [name] parameter is useful in tests when there are multiple + /// instances being created. + Stopwatch createStopwatch([String name = '']) => Stopwatch(); +} + +typedef VoidCallback = void Function(); + +abstract class Logger { + /// Whether or not this logger should print [printTrace] messages. + bool get isVerbose => false; + + /// If true, silences the logger output. + bool quiet = false; + + /// If true, this logger supports color output. + bool get supportsColor; + + /// If true, this logger is connected to a terminal. + bool get hasTerminal; + + /// If true, then [printError] has been called at least once for this logger + /// since the last time it was set to false. + bool hadErrorOutput = false; + + /// If true, then [printWarning] has been called at least once for this logger + /// since the last time it was reset to false. + bool hadWarningOutput = false; + + /// Causes [checkForFatalLogs] to call [throwToolExit] when it is called if + /// [hadWarningOutput] is true. + bool fatalWarnings = false; + + /// Returns the terminal attached to this logger. + Terminal get terminal; + + /// Display an error `message` to the user. Commands should use this if they + /// fail in some way. Errors are typically followed shortly by a call to + /// [throwToolExit] to terminate the run. + /// + /// The `message` argument is printed to the stderr in [TerminalColor.red] by + /// default. + /// + /// The `stackTrace` argument is the stack trace that will be printed if + /// supplied. + /// + /// The `emphasis` argument will cause the output message be printed in bold text. + /// + /// The `color` argument will print the message in the supplied color instead + /// of the default of red. Colors will not be printed if the output terminal + /// doesn't support them. + /// + /// The `indent` argument specifies the number of spaces to indent the overall + /// message. If wrapping is enabled in [outputPreferences], then the wrapped + /// lines will be indented as well. + /// + /// If `hangingIndent` is specified, then any wrapped lines will be indented + /// by this much more than the first line, if wrapping is enabled in + /// [outputPreferences]. + /// + /// If `wrap` is specified, then it overrides the + /// `outputPreferences.wrapText` setting. + void printError( + String message, { + StackTrace? stackTrace, + bool? emphasis, + TerminalColor? color, + int? indent, + int? hangingIndent, + bool? wrap, + }); + + /// Display a warning `message` to the user. Commands should use this if they + /// important information to convey to the user that is not fatal. + /// + /// The `message` argument is printed to the stderr in [TerminalColor.cyan] by + /// default. + /// + /// The `emphasis` argument will cause the output message be printed in bold text. + /// + /// The `color` argument will print the message in the supplied color instead + /// of the default of cyan. Colors will not be printed if the output terminal + /// doesn't support them. + /// + /// The `indent` argument specifies the number of spaces to indent the overall + /// message. If wrapping is enabled in [outputPreferences], then the wrapped + /// lines will be indented as well. + /// + /// If `hangingIndent` is specified, then any wrapped lines will be indented + /// by this much more than the first line, if wrapping is enabled in + /// [outputPreferences]. + /// + /// If `wrap` is specified, then it overrides the + /// `outputPreferences.wrapText` setting. + void printWarning( + String message, { + bool? emphasis, + TerminalColor? color, + int? indent, + int? hangingIndent, + bool? wrap, + }); + + /// Display normal output of the command. This should be used for things like + /// progress messages, success messages, or just normal command output. + /// + /// The `message` argument is printed to the stdout. + /// + /// The `stackTrace` argument is the stack trace that will be printed if + /// supplied. + /// + /// If the `emphasis` argument is true, it will cause the output message be + /// printed in bold text. Defaults to false. + /// + /// The `color` argument will print the message in the supplied color instead + /// of the default of red. Colors will not be printed if the output terminal + /// doesn't support them. + /// + /// If `newline` is true, then a newline will be added after printing the + /// status. Defaults to true. + /// + /// The `indent` argument specifies the number of spaces to indent the overall + /// message. If wrapping is enabled in [outputPreferences], then the wrapped + /// lines will be indented as well. + /// + /// If `hangingIndent` is specified, then any wrapped lines will be indented + /// by this much more than the first line, if wrapping is enabled in + /// [outputPreferences]. + /// + /// If `wrap` is specified, then it overrides the + /// `outputPreferences.wrapText` setting. + void printStatus( + String message, { + bool? emphasis, + TerminalColor? color, + bool? newline, + int? indent, + int? hangingIndent, + bool? wrap, + }); + + /// Display the [message] inside a box. + /// + /// For example, this is the generated output: + /// + /// ┌─ [title] ─┐ + /// │ [message] │ + /// └───────────┘ + /// + /// If a terminal is attached, the lines in [message] are automatically wrapped based on + /// the available columns. + /// + /// Use this utility only to highlight a message in the logs. + /// + /// This is particularly useful when the message can be easily missed because of clutter + /// generated by other commands invoked by the tool. + /// + /// One common use case is to provide actionable steps in a Flutter app when a Gradle + /// error is printed. + /// + /// In the future, this output can be integrated with an IDE like VS Code to display a + /// notification, and allow the user to trigger an action. e.g. run a migration. + void printBox( + String message, { + String? title, + }); + + /// Use this for verbose tracing output. Users can turn this output on in order + /// to help diagnose issues with the toolchain or with their setup. + void printTrace(String message); + + /// Start an indeterminate progress display. + /// + /// The `message` argument is the message to display to the user. + /// + /// The `progressId` argument provides an ID that can be used to identify + /// this type of progress (e.g. `hot.reload`, `hot.restart`). + /// + /// The `progressIndicatorPadding` can optionally be used to specify the width + /// of the space into which the `message` is placed before the progress + /// indicator, if any. It is ignored if the message is longer. + Status startProgress( + String message, { + String? progressId, + int progressIndicatorPadding = kDefaultStatusPadding, + }); + + /// A [SilentStatus] or an [AnonymousSpinnerStatus] (depending on whether the + /// terminal is fancy enough), already started. + Status startSpinner({ + VoidCallback? onFinish, + Duration? timeout, + SlowWarningCallback? slowWarningCallback, + }); + + /// Clears all output. + void clear(); + + /// If [fatalWarnings] is set, causes the logger to check if + /// [hadWarningOutput] is true, and then to call [throwToolExit] if so. + /// + /// The [fatalWarnings] flag can be set from the command line with the + /// "--fatal-warnings" option on commands that support it. + void checkForFatalLogs() { + if (fatalWarnings && (hadWarningOutput || hadErrorOutput)) { + throwToolExit( + 'Logger received ${hadErrorOutput ? 'error' : 'warning'} output ' + 'during the run, and "--fatal-warnings" is enabled.'); + } + } +} + +class StdoutLogger extends Logger { + StdoutLogger({ + required this.terminal, + required Stdio stdio, + required OutputPreferences outputPreferences, + StopwatchFactory stopwatchFactory = const StopwatchFactory(), + }) : _stdio = stdio, + _outputPreferences = outputPreferences, + _stopwatchFactory = stopwatchFactory; + + @override + final Terminal terminal; + final OutputPreferences _outputPreferences; + final Stdio _stdio; + final StopwatchFactory _stopwatchFactory; + + Status? _status; + + @override + bool get isVerbose => false; + + @override + bool get supportsColor => terminal.supportsColor; + + @override + bool get hasTerminal => _stdio.stdinHasTerminal; + + @override + void printError( + String message, { + StackTrace? stackTrace, + bool? emphasis, + TerminalColor? color, + int? indent, + int? hangingIndent, + bool? wrap, + }) { + hadErrorOutput = true; + _status?.pause(); + message = wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + ); + if (emphasis ?? false) { + message = terminal.bolden(message); + } + message = terminal.color(message, color ?? TerminalColor.red); + writeToStdErr('$message\n'); + if (stackTrace != null) { + writeToStdErr('$stackTrace\n'); + } + _status?.resume(); + } + + @override + void printWarning( + String message, { + bool? emphasis, + TerminalColor? color, + int? indent, + int? hangingIndent, + bool? wrap, + }) { + hadWarningOutput = true; + _status?.pause(); + message = wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + ); + if (emphasis ?? false) { + message = terminal.bolden(message); + } + message = terminal.color(message, color ?? TerminalColor.cyan); + writeToStdErr('$message\n'); + _status?.resume(); + } + + @override + void printStatus( + String message, { + bool? emphasis, + TerminalColor? color, + bool? newline, + int? indent, + int? hangingIndent, + bool? wrap, + }) { + _status?.pause(); + message = wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + ); + if (emphasis ?? false) { + message = terminal.bolden(message); + } + if (color != null) { + message = terminal.color(message, color); + } + if (newline ?? true) { + message = '$message\n'; + } + writeToStdOut(message); + _status?.resume(); + } + + @override + void printBox( + String message, { + String? title, + }) { + _status?.pause(); + _generateBox( + title: title, + message: message, + wrapColumn: _outputPreferences.wrapColumn, + terminal: terminal, + write: writeToStdOut, + ); + _status?.resume(); + } + + @protected + void writeToStdOut(String message) => _stdio.stdoutWrite(message); + + @protected + void writeToStdErr(String message) => _stdio.stderrWrite(message); + + @override + void printTrace(String message) {} + + @override + Status startProgress( + String message, { + String? progressId, + int progressIndicatorPadding = kDefaultStatusPadding, + }) { + if (_status != null) { + // Ignore nested progresses; return a no-op status object. + return SilentStatus( + stopwatch: _stopwatchFactory.createStopwatch(), + )..start(); + } + if (supportsColor) { + _status = SpinnerStatus( + message: message, + padding: progressIndicatorPadding, + onFinish: _clearStatus, + stdio: _stdio, + stopwatch: _stopwatchFactory.createStopwatch(), + terminal: terminal, + )..start(); + } else { + _status = SummaryStatus( + message: message, + padding: progressIndicatorPadding, + onFinish: _clearStatus, + stdio: _stdio, + stopwatch: _stopwatchFactory.createStopwatch(), + )..start(); + } + return _status!; + } + + @override + Status startSpinner({ + VoidCallback? onFinish, + Duration? timeout, + SlowWarningCallback? slowWarningCallback, + }) { + if (_status != null || !supportsColor) { + return SilentStatus( + onFinish: onFinish, + stopwatch: _stopwatchFactory.createStopwatch(), + )..start(); + } + _status = AnonymousSpinnerStatus( + onFinish: () { + if (onFinish != null) { + onFinish(); + } + _clearStatus(); + }, + stdio: _stdio, + stopwatch: _stopwatchFactory.createStopwatch(), + terminal: terminal, + timeout: timeout, + slowWarningCallback: slowWarningCallback, + )..start(); + return _status!; + } + + void _clearStatus() { + _status = null; + } + + @override + void clear() { + _status?.pause(); + writeToStdOut('${terminal.clearScreen()}\n'); + _status?.resume(); + } +} + +/// A [StdoutLogger] which replaces Unicode characters that cannot be printed to +/// the Windows console with alternative symbols. +/// +/// By default, Windows uses either "Consolas" or "Lucida Console" as fonts to +/// render text in the console. Both fonts only have a limited character set. +/// Unicode characters, that are not available in either of the two default +/// fonts, should be replaced by this class with printable symbols. Otherwise, +/// they will show up as the unrepresentable character symbol '�'. +class WindowsStdoutLogger extends StdoutLogger { + WindowsStdoutLogger({ + required super.terminal, + required super.stdio, + required super.outputPreferences, + super.stopwatchFactory, + }); + + @override + void writeToStdOut(String message) { + final String windowsMessage = terminal.supportsEmoji + ? message + : message + .replaceAll('🔥', '') + .replaceAll('🖼️', '') + .replaceAll('✗', 'X') + .replaceAll('✓', '√') + .replaceAll('🔨', '') + .replaceAll('💪', '') + .replaceAll('⚠️', '!') + .replaceAll('✏️', ''); + _stdio.stdoutWrite(windowsMessage); + } +} + +typedef _Writter = void Function(String message); + +/// Wraps the message in a box, and writes the bytes by calling [write]. +/// +/// Example output: +/// +/// ┌─ [title] ─┐ +/// │ [message] │ +/// └───────────┘ +/// +/// When [title] is provided, the box will have a title above it. +/// +/// The box width never exceeds [wrapColumn]. +/// +/// If [wrapColumn] is not provided, the default value is 100. +void _generateBox({ + required String message, + required int wrapColumn, + required _Writter write, + required Terminal terminal, + String? title, +}) { + const int kPaddingLeftRight = 1; + const int kEdges = 2; + + final int maxTextWidthPerLine = wrapColumn - kEdges - kPaddingLeftRight * 2; + final List lines = + wrapText(message, shouldWrap: true, columnWidth: maxTextWidthPerLine) + .split('\n'); + final List lineWidth = + lines.map((String line) => _getColumnSize(line)).toList(); + final int maxColumnSize = + lineWidth.reduce((int currLen, int maxLen) => max(currLen, maxLen)); + final int textWidth = min(maxColumnSize, maxTextWidthPerLine); + final int textWithPaddingWidth = textWidth + kPaddingLeftRight * 2; + + write('\n'); + + // Write `┌─ [title] ─┐`. + write('┌'); + write('─'); + if (title == null) { + write('─' * (textWithPaddingWidth - 1)); + } else { + write(' ${terminal.bolden(title)} '); + write('─' * (textWithPaddingWidth - title.length - 3)); + } + write('┐'); + write('\n'); + + // Write `│ [message] │`. + for (int lineIdx = 0; lineIdx < lines.length; lineIdx++) { + write('│'); + write(' ' * kPaddingLeftRight); + write(lines[lineIdx]); + final int remainingSpacesToEnd = textWidth - lineWidth[lineIdx]; + write(' ' * (remainingSpacesToEnd + kPaddingLeftRight)); + write('│'); + write('\n'); + } + + // Write `└───────────┘`. + write('└'); + write('─' * textWithPaddingWidth); + write('┘'); + write('\n'); +} + +final RegExp _ansiEscapePattern = + RegExp('\x1B\\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]'); + +int _getColumnSize(String line) { + // Remove ANSI escape characters from the string. + return line.replaceAll(_ansiEscapePattern, '').length; +} + +class BufferLogger extends Logger { + BufferLogger({ + required this.terminal, + required OutputPreferences outputPreferences, + StopwatchFactory stopwatchFactory = const StopwatchFactory(), + bool verbose = false, + }) : _outputPreferences = outputPreferences, + _stopwatchFactory = stopwatchFactory, + _verbose = verbose; + + /// Create a [BufferLogger] with test preferences. + BufferLogger.test({ + Terminal? terminal, + OutputPreferences? outputPreferences, + bool verbose = false, + }) : terminal = terminal ?? Terminal.test(), + _outputPreferences = outputPreferences ?? OutputPreferences.test(), + _stopwatchFactory = const StopwatchFactory(), + _verbose = verbose; + + final OutputPreferences _outputPreferences; + + @override + final Terminal terminal; + + final StopwatchFactory _stopwatchFactory; + + final bool _verbose; + + @override + bool get isVerbose => _verbose; + + @override + bool get supportsColor => terminal.supportsColor; + + final StringBuffer _error = StringBuffer(); + final StringBuffer _warning = StringBuffer(); + final StringBuffer _status = StringBuffer(); + final StringBuffer _trace = StringBuffer(); + final StringBuffer _events = StringBuffer(); + + String get errorText => _error.toString(); + String get warningText => _warning.toString(); + String get statusText => _status.toString(); + String get traceText => _trace.toString(); + String get eventText => _events.toString(); + + @override + bool get hasTerminal => false; + + @override + void printError( + String message, { + StackTrace? stackTrace, + bool? emphasis, + TerminalColor? color, + int? indent, + int? hangingIndent, + bool? wrap, + }) { + hadErrorOutput = true; + _error.writeln(terminal.color( + wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + ), + color ?? TerminalColor.red, + )); + } + + @override + void printWarning( + String message, { + bool? emphasis, + TerminalColor? color, + int? indent, + int? hangingIndent, + bool? wrap, + }) { + hadWarningOutput = true; + _warning.writeln(terminal.color( + wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + ), + color ?? TerminalColor.cyan, + )); + } + + @override + void printStatus( + String message, { + bool? emphasis, + TerminalColor? color, + bool? newline, + int? indent, + int? hangingIndent, + bool? wrap, + }) { + if (newline ?? true) { + _status.writeln(wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + )); + } else { + _status.write(wrapText( + message, + indent: indent, + hangingIndent: hangingIndent, + shouldWrap: wrap ?? _outputPreferences.wrapText, + columnWidth: _outputPreferences.wrapColumn, + )); + } + } + + @override + void printBox( + String message, { + String? title, + }) { + _generateBox( + title: title, + message: message, + wrapColumn: _outputPreferences.wrapColumn, + terminal: terminal, + write: _status.write, + ); + } + + @override + void printTrace(String message) => _trace.writeln(message); + + @override + Status startProgress( + String message, { + String? progressId, + int progressIndicatorPadding = kDefaultStatusPadding, + }) { + assert(progressIndicatorPadding != null); + printStatus(message); + return SilentStatus( + stopwatch: _stopwatchFactory.createStopwatch(), + )..start(); + } + + @override + Status startSpinner({ + VoidCallback? onFinish, + Duration? timeout, + SlowWarningCallback? slowWarningCallback, + }) { + return SilentStatus( + stopwatch: _stopwatchFactory.createStopwatch(), + onFinish: onFinish, + )..start(); + } + + @override + void clear() { + _error.clear(); + _status.clear(); + _trace.clear(); + _events.clear(); + } +} + +typedef SlowWarningCallback = String Function(); + +/// A [Status] class begins when start is called, and may produce progress +/// information asynchronously. +/// +/// The [SilentStatus] class never has any output. +/// +/// The [SpinnerStatus] subclass shows a message with a spinner, and replaces it +/// with timing information when stopped. When canceled, the information isn't +/// shown. In either case, a newline is printed. +/// +/// The [AnonymousSpinnerStatus] subclass just shows a spinner. +/// +/// The [SummaryStatus] subclass shows only a static message (without an +/// indicator), then updates it when the operation ends. +/// +/// Generally, consider `logger.startProgress` instead of directly creating +/// a [Status] or one of its subclasses. +abstract class Status { + Status({ + this.onFinish, + required Stopwatch stopwatch, + this.timeout, + }) : _stopwatch = stopwatch; + + final VoidCallback? onFinish; + final Duration? timeout; + + @protected + final Stopwatch _stopwatch; + + @protected + String get elapsedTime { + if (_stopwatch.elapsed.inSeconds > 2) { + return _getElapsedAsSeconds(_stopwatch.elapsed); + } + return _getElapsedAsMilliseconds(_stopwatch.elapsed); + } + + String _getElapsedAsSeconds(Duration duration) { + final double seconds = + duration.inMilliseconds / Duration.millisecondsPerSecond; + return '${kSecondsFormat.format(seconds)}s'; + } + + String _getElapsedAsMilliseconds(Duration duration) { + return '${kMillisecondsFormat.format(duration.inMilliseconds)}ms'; + } + + @visibleForTesting + bool get seemsSlow => timeout != null && _stopwatch.elapsed > timeout!; + + /// Call to start spinning. + void start() { + assert(!_stopwatch.isRunning); + _stopwatch.start(); + } + + /// Call to stop spinning after success. + void stop() { + finish(); + } + + /// Call to cancel the spinner after failure or cancellation. + void cancel() { + finish(); + } + + /// Call to clear the current line but not end the progress. + void pause() {} + + /// Call to resume after a pause. + void resume() {} + + @protected + void finish() { + assert(_stopwatch.isRunning); + _stopwatch.stop(); + onFinish?.call(); + } +} + +/// A [Status] that shows nothing. +class SilentStatus extends Status { + SilentStatus({ + required super.stopwatch, + super.onFinish, + }); + + @override + void finish() { + onFinish?.call(); + } +} + +/// Constructor writes [message] to [stdout]. On [cancel] or [stop], will call +/// [onFinish]. On [stop], will additionally print out summary information. +class SummaryStatus extends Status { + SummaryStatus({ + this.message = '', + required super.stopwatch, + this.padding = kDefaultStatusPadding, + super.onFinish, + required Stdio stdio, + }) : _stdio = stdio; + + final String message; + final int padding; + final Stdio _stdio; + + bool _messageShowingOnCurrentLine = false; + + @override + void start() { + _printMessage(); + super.start(); + } + + void _writeToStdOut(String message) => _stdio.stdoutWrite(message); + + void _printMessage() { + assert(!_messageShowingOnCurrentLine); + _writeToStdOut('${message.padRight(padding)} '); + _messageShowingOnCurrentLine = true; + } + + @override + void stop() { + if (!_messageShowingOnCurrentLine) { + _printMessage(); + } + super.stop(); + assert(_messageShowingOnCurrentLine); + _writeToStdOut(elapsedTime.padLeft(_kTimePadding)); + _writeToStdOut('\n'); + } + + @override + void cancel() { + super.cancel(); + if (_messageShowingOnCurrentLine) { + _writeToStdOut('\n'); + } + } + + @override + void pause() { + super.pause(); + if (_messageShowingOnCurrentLine) { + _writeToStdOut('\n'); + _messageShowingOnCurrentLine = false; + } + } +} + +const int _kTimePadding = 8; // should fit "99,999ms" + +/// A kind of animated [Status] that has no message. +/// +/// Call [pause] before outputting any text while this is running. +class AnonymousSpinnerStatus extends Status { + AnonymousSpinnerStatus({ + super.onFinish, + required super.stopwatch, + required Stdio stdio, + required Terminal terminal, + this.slowWarningCallback, + super.timeout, + }) : _stdio = stdio, + _terminal = terminal, + _animation = _selectAnimation(terminal); + + final Stdio _stdio; + final Terminal _terminal; + String _slowWarning = ''; + final SlowWarningCallback? slowWarningCallback; + + static const String _backspaceChar = '\b'; + static const String _clearChar = ' '; + + static const List _emojiAnimations = [ + '⣾⣽⣻⢿⡿⣟⣯⣷', // counter-clockwise + '⣾⣷⣯⣟⡿⢿⣻⣽', // clockwise + '⣾⣷⣯⣟⡿⢿⣻⣽⣷⣾⣽⣻⢿⡿⣟⣯⣷', // bouncing clockwise and counter-clockwise + '⣾⣷⣯⣽⣻⣟⡿⢿⣻⣟⣯⣽', // snaking + '⣾⣽⣻⢿⣿⣷⣯⣟⡿⣿', // alternating rain + '⣀⣠⣤⣦⣶⣾⣿⡿⠿⠻⠛⠋⠉⠙⠛⠟⠿⢿⣿⣷⣶⣴⣤⣄', // crawl up and down, large + '⠙⠚⠖⠦⢤⣠⣄⡤⠴⠲⠓⠋', // crawl up and down, small + '⣀⡠⠤⠔⠒⠊⠉⠑⠒⠢⠤⢄', // crawl up and down, tiny + '⡀⣄⣦⢷⠻⠙⠈⠀⠁⠋⠟⡾⣴⣠⢀⠀', // slide up and down + '⠙⠸⢰⣠⣄⡆⠇⠋', // clockwise line + '⠁⠈⠐⠠⢀⡀⠄⠂', // clockwise dot + '⢇⢣⢱⡸⡜⡎', // vertical wobble up + '⡇⡎⡜⡸⢸⢱⢣⢇', // vertical wobble down + '⡀⣀⣐⣒⣖⣶⣾⣿⢿⠿⠯⠭⠩⠉⠁⠀', // swirl + '⠁⠐⠄⢀⢈⢂⢠⣀⣁⣐⣄⣌⣆⣤⣥⣴⣼⣶⣷⣿⣾⣶⣦⣤⣠⣀⡀⠀⠀', // snowing and melting + '⠁⠋⠞⡴⣠⢀⠀⠈⠙⠻⢷⣦⣄⡀⠀⠉⠛⠲⢤⢀⠀', // falling water + '⠄⡢⢑⠈⠀⢀⣠⣤⡶⠞⠋⠁⠀⠈⠙⠳⣆⡀⠀⠆⡷⣹⢈⠀⠐⠪⢅⡀⠀', // fireworks + '⠐⢐⢒⣒⣲⣶⣷⣿⡿⡷⡧⠧⠇⠃⠁⠀⡀⡠⡡⡱⣱⣳⣷⣿⢿⢯⢧⠧⠣⠃⠂⠀⠈⠨⠸⠺⡺⡾⡿⣿⡿⡷⡗⡇⡅⡄⠄⠀⡀⡐⣐⣒⣓⣳⣻⣿⣾⣼⡼⡸⡘⡈⠈⠀', // fade + '⢸⡯⠭⠅⢸⣇⣀⡀⢸⣇⣸⡇⠈⢹⡏⠁⠈⢹⡏⠁⢸⣯⣭⡅⢸⡯⢕⡂⠀⠀', // text crawl + ]; + + static const List _asciiAnimations = [ + r'-\|/', + ]; + + static List _selectAnimation(Terminal terminal) { + final List animations = + terminal.supportsEmoji ? _emojiAnimations : _asciiAnimations; + return animations[terminal.preferredStyle % animations.length] + .runes + .map((int scalar) => String.fromCharCode(scalar)) + .toList(); + } + + final List _animation; + + Timer? timer; + int ticks = 0; + int _lastAnimationFrameLength = 0; + bool timedOut = false; + + String get _currentAnimationFrame => _animation[ticks % _animation.length]; + int get _currentLineLength => _lastAnimationFrameLength + _slowWarning.length; + + void _writeToStdOut(String message) => _stdio.stdoutWrite(message); + + void _clear(int length) { + _writeToStdOut('${_backspaceChar * length}' + '${_clearChar * length}' + '${_backspaceChar * length}'); + } + + @override + void start() { + super.start(); + assert(timer == null); + _startSpinner(); + } + + void _startSpinner() { + timer = Timer.periodic(const Duration(milliseconds: 100), _callback); + _callback(timer!); + } + + void _callback(Timer timer) { + assert(this.timer == timer); + assert(timer != null); + assert(timer.isActive); + _writeToStdOut(_backspaceChar * _lastAnimationFrameLength); + ticks += 1; + if (seemsSlow) { + if (!timedOut) { + timedOut = true; + _clear(_currentLineLength); + } + if (_slowWarning == '' && slowWarningCallback != null) { + _slowWarning = slowWarningCallback!(); + _writeToStdOut(_slowWarning); + } + } + final String newFrame = _currentAnimationFrame; + _lastAnimationFrameLength = newFrame.runes.length; + _writeToStdOut(newFrame); + } + + @override + void pause() { + assert(timer != null); + assert(timer!.isActive); + if (_terminal.supportsColor) { + _writeToStdOut('\r\x1B[K'); // go to start of line and clear line + } else { + _clear(_currentLineLength); + } + _lastAnimationFrameLength = 0; + timer?.cancel(); + } + + @override + void resume() { + assert(timer != null); + assert(!timer!.isActive); + _startSpinner(); + } + + @override + void finish() { + assert(timer != null); + assert(timer!.isActive); + timer?.cancel(); + timer = null; + _clear(_lastAnimationFrameLength); + _lastAnimationFrameLength = 0; + super.finish(); + } +} + +/// An animated version of [Status]. +/// +/// The constructor writes [message] to [stdout] with padding, then starts an +/// indeterminate progress indicator animation. +/// +/// On [cancel] or [stop], will call [onFinish]. On [stop], will +/// additionally print out summary information. +/// +/// Call [pause] before outputting any text while this is running. +class SpinnerStatus extends AnonymousSpinnerStatus { + SpinnerStatus({ + required this.message, + this.padding = kDefaultStatusPadding, + super.onFinish, + required super.stopwatch, + required super.stdio, + required super.terminal, + }); + + final String message; + final int padding; + + static final String _margin = + AnonymousSpinnerStatus._clearChar * (5 + _kTimePadding - 1); + + int _totalMessageLength = 0; + + @override + int get _currentLineLength => _totalMessageLength + super._currentLineLength; + + @override + void start() { + _printStatus(); + super.start(); + } + + void _printStatus() { + final String line = '${message.padRight(padding)}$_margin'; + _totalMessageLength = line.length; + _writeToStdOut(line); + } + + @override + void pause() { + super.pause(); + _totalMessageLength = 0; + } + + @override + void resume() { + _printStatus(); + super.resume(); + } + + @override + void stop() { + super.stop(); // calls finish, which clears the spinner + assert(_totalMessageLength > _kTimePadding); + _writeToStdOut(AnonymousSpinnerStatus._backspaceChar * (_kTimePadding - 1)); + _writeToStdOut(elapsedTime.padLeft(_kTimePadding)); + _writeToStdOut('\n'); + } + + @override + void cancel() { + super.cancel(); // calls finish, which clears the spinner + assert(_totalMessageLength > 0); + _writeToStdOut('\n'); + } +} + +/// Wraps a block of text into lines no longer than [columnWidth]. +/// +/// Tries to split at whitespace, but if that's not good enough to keep it under +/// the limit, then it splits in the middle of a word. If [columnWidth] (minus +/// any indent) is smaller than [kMinColumnWidth], the text is wrapped at that +/// [kMinColumnWidth] instead. +/// +/// Preserves indentation (leading whitespace) for each line (delimited by '\n') +/// in the input, and will indent wrapped lines that same amount, adding +/// [indent] spaces in addition to any existing indent. +/// +/// If [hangingIndent] is supplied, then that many additional spaces will be +/// added to each line, except for the first line. The [hangingIndent] is added +/// to the specified [indent], if any. This is useful for wrapping +/// text with a heading prefix (e.g. "Usage: "): +/// +/// ```dart +/// String prefix = "Usage: "; +/// print(prefix + wrapText(invocation, indent: 2, hangingIndent: prefix.length, columnWidth: 40)); +/// ``` +/// +/// yields: +/// ``` +/// Usage: app main_command +/// [arguments] +/// ``` +/// +/// If [outputPreferences.wrapText] is false, then the text will be returned +/// unchanged. If [shouldWrap] is specified, then it overrides the +/// [outputPreferences.wrapText] setting. +/// +/// If the amount of indentation (from the text, [indent], and [hangingIndent]) +/// is such that less than [kMinColumnWidth] characters can fit in the +/// [columnWidth], then the indent is truncated to allow the text to fit. +String wrapText( + String text, { + required int columnWidth, + required bool shouldWrap, + int? hangingIndent, + int? indent, +}) { + assert(columnWidth >= 0); + if (text == null || text.isEmpty) { + return ''; + } + indent ??= 0; + hangingIndent ??= 0; + final List splitText = text.split('\n'); + final List result = []; + for (final String line in splitText) { + String trimmedText = line.trimLeft(); + final String leadingWhitespace = + line.substring(0, line.length - trimmedText.length); + List notIndented; + if (hangingIndent != 0) { + // When we have a hanging indent, we want to wrap the first line at one + // width, and the rest at another (offset by hangingIndent), so we wrap + // them twice and recombine. + final List firstLineWrap = _wrapTextAsLines( + trimmedText, + columnWidth: columnWidth - leadingWhitespace.length - indent, + shouldWrap: shouldWrap, + ); + notIndented = [firstLineWrap.removeAt(0)]; + trimmedText = trimmedText.substring(notIndented[0].length).trimLeft(); + if (trimmedText.isNotEmpty) { + notIndented.addAll(_wrapTextAsLines( + trimmedText, + columnWidth: + columnWidth - leadingWhitespace.length - indent - hangingIndent, + shouldWrap: shouldWrap, + )); + } + } else { + notIndented = _wrapTextAsLines( + trimmedText, + columnWidth: columnWidth - leadingWhitespace.length - indent, + shouldWrap: shouldWrap, + ); + } + String? hangingIndentString; + final String indentString = ' ' * indent; + result.addAll(notIndented.map( + (String line) { + // Don't return any lines with just whitespace on them. + if (line.isEmpty) { + return ''; + } + String truncatedIndent = + '$indentString${hangingIndentString ?? ''}$leadingWhitespace'; + if (truncatedIndent.length > columnWidth - kMinColumnWidth) { + truncatedIndent = truncatedIndent.substring( + 0, math.max(columnWidth - kMinColumnWidth, 0)); + } + final String result = '$truncatedIndent$line'; + hangingIndentString ??= ' ' * hangingIndent!; + return result; + }, + )); + } + return result.join('\n'); +} + +/// Wraps a block of text into lines no longer than [columnWidth], starting at the +/// [start] column, and returning the result as a list of strings. +/// +/// Tries to split at whitespace, but if that's not good enough to keep it +/// under the limit, then splits in the middle of a word. Preserves embedded +/// newlines, but not indentation (it trims whitespace from each line). +/// +/// If [columnWidth] is not specified, then the column width will be the width of the +/// terminal window by default. If the stdout is not a terminal window, then the +/// default will be [outputPreferences.wrapColumn]. +/// +/// The [columnWidth] is clamped to [kMinColumnWidth] at minimum (so passing negative +/// widths is fine, for instance). +/// +/// If [outputPreferences.wrapText] is false, then the text will be returned +/// simply split at the newlines, but not wrapped. If [shouldWrap] is specified, +/// then it overrides the [outputPreferences.wrapText] setting. +List _wrapTextAsLines( + String text, { + int start = 0, + required int columnWidth, + required bool shouldWrap, +}) { + if (text == null || text.isEmpty) { + return ['']; + } + assert(start >= 0); + + // Splits a string so that the resulting list has the same number of elements + // as there are visible characters in the string, but elements may include one + // or more adjacent ANSI sequences. Joining the list elements again will + // reconstitute the original string. This is useful for manipulating "visible" + // characters in the presence of ANSI control codes. + List<_AnsiRun> splitWithCodes(String input) { + final RegExp characterOrCode = + RegExp('(\u001b\\[[0-9;]*m|.)', multiLine: true); + List<_AnsiRun> result = <_AnsiRun>[]; + final StringBuffer current = StringBuffer(); + for (final Match match in characterOrCode.allMatches(input)) { + current.write(match[0]); + if (match[0]!.length < 4) { + // This is a regular character, write it out. + result.add(_AnsiRun(current.toString(), match[0]!)); + current.clear(); + } + } + // If there's something accumulated, then it must be an ANSI sequence, so + // add it to the end of the last entry so that we don't lose it. + if (current.isNotEmpty) { + if (result.isNotEmpty) { + result.last.original += current.toString(); + } else { + // If there is nothing in the string besides control codes, then just + // return them as the only entry. + result = <_AnsiRun>[_AnsiRun(current.toString(), '')]; + } + } + return result; + } + + String joinRun(List<_AnsiRun> list, int start, [int? end]) { + return list + .sublist(start, end) + .map((_AnsiRun run) => run.original) + .join() + .trim(); + } + + final List result = []; + final int effectiveLength = math.max(columnWidth - start, kMinColumnWidth); + for (final String line in text.split('\n')) { + // If the line is short enough, even with ANSI codes, then we can just add + // add it and move on. + if (line.length <= effectiveLength || !shouldWrap) { + result.add(line); + continue; + } + final List<_AnsiRun> splitLine = splitWithCodes(line); + if (splitLine.length <= effectiveLength) { + result.add(line); + continue; + } + + int currentLineStart = 0; + int? lastWhitespace; + // Find the start of the current line. + for (int index = 0; index < splitLine.length; ++index) { + if (splitLine[index].character.isNotEmpty && + _isWhitespace(splitLine[index])) { + lastWhitespace = index; + } + + if (index - currentLineStart >= effectiveLength) { + // Back up to the last whitespace, unless there wasn't any, in which + // case we just split where we are. + if (lastWhitespace != null) { + index = lastWhitespace; + } + + result.add(joinRun(splitLine, currentLineStart, index)); + + // Skip any intervening whitespace. + while (index < splitLine.length && _isWhitespace(splitLine[index])) { + index++; + } + + currentLineStart = index; + lastWhitespace = null; + } + } + result.add(joinRun(splitLine, currentLineStart)); + } + return result; +} + +// Used to represent a run of ANSI control sequences next to a visible +// character. +class _AnsiRun { + _AnsiRun(this.original, this.character); + + String original; + String character; +} + +/// Returns true if the code unit at [index] in [text] is a whitespace +/// character. +/// +/// Based on: https://en.wikipedia.org/wiki/Whitespace_character#Unicode +bool _isWhitespace(_AnsiRun run) { + final int rune = run.character.isNotEmpty ? run.character.codeUnitAt(0) : 0x0; + return rune >= 0x0009 && rune <= 0x000D || + rune == 0x0020 || + rune == 0x0085 || + rune == 0x1680 || + rune == 0x180E || + rune >= 0x2000 && rune <= 0x200A || + rune == 0x2028 || + rune == 0x2029 || + rune == 0x202F || + rune == 0x205F || + rune == 0x3000 || + rune == 0xFEFF; +} + +/// An abstraction for instantiation of the correct logger type. +/// +/// Our logger class hierarchy and runtime requirements are overly complicated. +class LoggerFactory { + LoggerFactory({ + required Terminal terminal, + required Stdio stdio, + required OutputPreferences outputPreferences, + StopwatchFactory stopwatchFactory = const StopwatchFactory(), + }) : _terminal = terminal, + _stdio = stdio, + _stopwatchFactory = stopwatchFactory, + _outputPreferences = outputPreferences; + + final Terminal _terminal; + final Stdio _stdio; + final StopwatchFactory _stopwatchFactory; + final OutputPreferences _outputPreferences; + + /// Create the appropriate logger for the current platform and configuration. + Logger createLogger({ + required bool windows, + }) { + Logger logger; + if (windows) { + logger = WindowsStdoutLogger( + terminal: _terminal, + stdio: _stdio, + outputPreferences: _outputPreferences, + stopwatchFactory: _stopwatchFactory, + ); + } else { + logger = StdoutLogger( + terminal: _terminal, + stdio: _stdio, + outputPreferences: _outputPreferences, + stopwatchFactory: _stopwatchFactory); + } + return logger; + } +} diff --git a/packages/flutter_migrate/lib/src/base/project.dart b/packages/flutter_migrate/lib/src/base/project.dart new file mode 100644 index 0000000000..a8924d744b --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/project.dart @@ -0,0 +1,91 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +import 'file_system.dart'; +import 'logger.dart'; + +/// Emum for each officially supported platform. +enum SupportedPlatform { + android, + ios, + linux, + macos, + web, + windows, + fuchsia, + root, // Special platform to represent the root project directory +} + +class FlutterProjectFactory { + FlutterProjectFactory(); + + @visibleForTesting + final Map projects = {}; + + /// Returns a [FlutterProject] view of the given directory or a ToolExit error, + /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid. + FlutterProject fromDirectory(Directory directory) { + assert(directory != null); + return projects.putIfAbsent(directory.path, () { + return FlutterProject(directory); + }); + } +} + +/// Represents the contents of a Flutter project at the specified [directory]. +class FlutterProject { + FlutterProject(this.directory) : assert(directory != null); + + /// Returns a [FlutterProject] view of the current directory or a ToolExit error, + /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid. + static FlutterProject current(FileSystem fs) => + FlutterProject(fs.currentDirectory); + + /// Create a [FlutterProject] and bypass the project caching. + @visibleForTesting + static FlutterProject fromDirectoryTest(Directory directory, + [Logger? logger]) { + logger ??= BufferLogger.test(); + return FlutterProject(directory); + } + + Directory directory; + + /// The `pubspec.yaml` file of this project. + File get pubspecFile => directory.childFile('pubspec.yaml'); + + /// The `.metadata` file of this project. + File get metadataFile => directory.childFile('.metadata'); + + /// Returns a list of platform names that are supported by the project. + List getSupportedPlatforms({bool includeRoot = false}) { + final List platforms = includeRoot + ? [SupportedPlatform.root] + : []; + if (directory.childDirectory('android').existsSync()) { + platforms.add(SupportedPlatform.android); + } + if (directory.childDirectory('ios').existsSync()) { + platforms.add(SupportedPlatform.ios); + } + if (directory.childDirectory('web').existsSync()) { + platforms.add(SupportedPlatform.web); + } + if (directory.childDirectory('macos').existsSync()) { + platforms.add(SupportedPlatform.macos); + } + if (directory.childDirectory('linux').existsSync()) { + platforms.add(SupportedPlatform.linux); + } + if (directory.childDirectory('windows').existsSync()) { + platforms.add(SupportedPlatform.windows); + } + if (directory.childDirectory('fuchsia').existsSync()) { + platforms.add(SupportedPlatform.fuchsia); + } + return platforms; + } +} diff --git a/packages/flutter_migrate/lib/src/base/signals.dart b/packages/flutter_migrate/lib/src/base/signals.dart new file mode 100644 index 0000000000..265f00fae2 --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/signals.dart @@ -0,0 +1,154 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; + +import 'package:meta/meta.dart'; + +import 'common.dart'; +import 'io.dart'; + +typedef SignalHandler = FutureOr Function(ProcessSignal signal); + +/// A class that manages signal handlers. +/// +/// Signal handlers are run in the order that they were added. +abstract class Signals { + @visibleForTesting + factory Signals.test({ + List exitSignals = defaultExitSignals, + }) => + LocalSignals._(exitSignals); + + // The default list of signals that should cause the process to exit. + static const List defaultExitSignals = [ + ProcessSignal.sigterm, + ProcessSignal.sigint, + ]; + + /// Adds a signal handler to run on receipt of signal. + /// + /// The handler will run after all handlers that were previously added for the + /// signal. The function returns an abstract token that should be provided to + /// removeHandler to remove the handler. + Object addHandler(ProcessSignal signal, SignalHandler handler); + + /// Removes a signal handler. + /// + /// Removes the signal handler for the signal identified by the abstract + /// token parameter. Returns true if the handler was removed and false + /// otherwise. + Future removeHandler(ProcessSignal signal, Object token); + + /// If a [SignalHandler] throws an error, either synchronously or + /// asynchronously, it will be added to this stream instead of propagated. + Stream get errors; +} + +/// A class that manages the real dart:io signal handlers. +/// +/// We use a singleton instance of this class to ensure that all handlers for +/// fatal signals run before this class calls exit(). +class LocalSignals implements Signals { + LocalSignals._(this.exitSignals); + + static LocalSignals instance = LocalSignals._( + Signals.defaultExitSignals, + ); + + final List exitSignals; + + // A table mapping (signal, token) -> signal handler. + final Map> _handlersTable = + >{}; + + // A table mapping (signal) -> signal handler list. The list is in the order + // that the signal handlers should be run. + final Map> _handlersList = + >{}; + + // A table mapping (signal) -> low-level signal event stream. + final Map> + _streamSubscriptions = + >{}; + + // The stream controller for errors coming from signal handlers. + final StreamController _errorStreamController = + StreamController.broadcast(); + + @override + Stream get errors => _errorStreamController.stream; + + @override + Object addHandler(ProcessSignal signal, SignalHandler handler) { + final Object token = Object(); + _handlersTable.putIfAbsent(signal, () => {}); + _handlersTable[signal]![token] = handler; + + _handlersList.putIfAbsent(signal, () => []); + _handlersList[signal]!.add(handler); + + // If we added the first one, then call signal.watch(), listen, and cache + // the stream controller. + if (_handlersList[signal]!.length == 1) { + _streamSubscriptions[signal] = signal.watch().listen( + _handleSignal, + onError: (Object e) { + _handlersTable[signal]?.remove(token); + _handlersList[signal]?.remove(handler); + }, + ); + } + return token; + } + + @override + Future removeHandler(ProcessSignal signal, Object token) async { + // We don't know about this signal. + if (!_handlersTable.containsKey(signal)) { + return false; + } + // We don't know about this token. + if (!_handlersTable[signal]!.containsKey(token)) { + return false; + } + final SignalHandler? handler = _handlersTable[signal]!.remove(token); + if (handler == null) { + return false; + } + final bool removed = _handlersList[signal]!.remove(handler); + if (!removed) { + return false; + } + + // If _handlersList[signal] is empty, then lookup the cached stream + // controller and unsubscribe from the stream. + if (_handlersList.isEmpty) { + await _streamSubscriptions[signal]?.cancel(); + } + return true; + } + + Future _handleSignal(ProcessSignal s) async { + final List? handlers = _handlersList[s]; + if (handlers != null) { + final List handlersCopy = handlers.toList(); + for (final SignalHandler handler in handlersCopy) { + try { + await asyncGuard(() async => handler(s)); + } on Exception catch (e) { + if (_errorStreamController.hasListener) { + _errorStreamController.add(e); + } + } + } + } + // If this was a signal that should cause the process to go down, then + // call exit(); + if (exitSignals.contains(s)) { + exit(0); + } + } +} diff --git a/packages/flutter_migrate/lib/src/base/terminal.dart b/packages/flutter_migrate/lib/src/base/terminal.dart new file mode 100644 index 0000000000..07976aab99 --- /dev/null +++ b/packages/flutter_migrate/lib/src/base/terminal.dart @@ -0,0 +1,418 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'common.dart'; +import 'io.dart' as io; +import 'logger.dart'; + +enum TerminalColor { + red, + green, + blue, + cyan, + yellow, + magenta, + grey, +} + +/// A class that contains the context settings for command text output to the +/// console. +class OutputPreferences { + OutputPreferences({ + bool? wrapText, + int? wrapColumn, + bool? showColor, + io.Stdio? stdio, + }) : _stdio = stdio, + wrapText = wrapText ?? stdio?.hasTerminal ?? false, + _overrideWrapColumn = wrapColumn, + showColor = showColor ?? false; + + /// A version of this class for use in tests. + OutputPreferences.test( + {this.wrapText = false, + int wrapColumn = kDefaultTerminalColumns, + this.showColor = false}) + : _overrideWrapColumn = wrapColumn, + _stdio = null; + + final io.Stdio? _stdio; + + /// If [wrapText] is true, then any text sent to the context's [Logger] + /// instance (e.g. from the [printError] or [printStatus] functions) will be + /// wrapped (newlines added between words) to be no longer than the + /// [wrapColumn] specifies. Defaults to true if there is a terminal. To + /// determine if there's a terminal, [OutputPreferences] asks the context's + /// stdio. + final bool wrapText; + + /// The terminal width used by the [wrapText] function if there is no terminal + /// attached to [io.Stdio], --wrap is on, and --wrap-columns was not specified. + static const int kDefaultTerminalColumns = 100; + + /// The column at which output sent to the context's [Logger] instance + /// (e.g. from the [printError] or [printStatus] functions) will be wrapped. + /// Ignored if [wrapText] is false. Defaults to the width of the output + /// terminal, or to [kDefaultTerminalColumns] if not writing to a terminal. + final int? _overrideWrapColumn; + int get wrapColumn { + return _overrideWrapColumn ?? + _stdio?.terminalColumns ?? + kDefaultTerminalColumns; + } + + /// Whether or not to output ANSI color codes when writing to the output + /// terminal. Defaults to whatever [platform.stdoutSupportsAnsi] says if + /// writing to a terminal, and false otherwise. + final bool showColor; + + @override + String toString() { + return '$runtimeType[wrapText: $wrapText, wrapColumn: $wrapColumn, showColor: $showColor]'; + } +} + +/// The command line terminal, if available. +abstract class Terminal { + /// Create a new test [Terminal]. + /// + /// If not specified, [supportsColor] defaults to `false`. + factory Terminal.test({bool supportsColor, bool supportsEmoji}) = + _TestTerminal; + + /// Whether the current terminal supports color escape codes. + bool get supportsColor; + + /// Whether the current terminal can display emoji. + bool get supportsEmoji; + + /// When we have a choice of styles (e.g. animated spinners), this selects the + /// style to use. + int get preferredStyle; + + /// Whether we are interacting with the flutter tool via the terminal. + /// + /// If not set, defaults to false. + bool get usesTerminalUi; + set usesTerminalUi(bool value); + + /// Whether there is a terminal attached to stdin. + /// + /// If true, this usually indicates that a user is using the CLI as + /// opposed to using an IDE. This can be used to determine + /// whether it is appropriate to show a terminal prompt, + /// or whether an automatic selection should be made instead. + bool get stdinHasTerminal; + + /// Warning mark to use in stdout or stderr. + String get warningMark; + + /// Success mark to use in stdout. + String get successMark; + + String bolden(String message); + + String color(String message, TerminalColor color); + + String clearScreen(); + + bool get singleCharMode; + set singleCharMode(bool value); + + /// Return keystrokes from the console. + /// + /// This is a single-subscription stream. This stream may be closed before + /// the application exits. + /// + /// Useful when the console is in [singleCharMode]. + Stream get keystrokes; + + /// Prompts the user to input a character within a given list. Re-prompts if + /// entered character is not in the list. + /// + /// The `prompt`, if non-null, is the text displayed prior to waiting for user + /// input each time. If `prompt` is non-null and `displayAcceptedCharacters` + /// is true, the accepted keys are printed next to the `prompt`. + /// + /// The returned value is the user's input; if `defaultChoiceIndex` is not + /// null, and the user presses enter without any other input, the return value + /// will be the character in `acceptedCharacters` at the index given by + /// `defaultChoiceIndex`. + /// + /// The accepted characters must be a String with a length of 1, excluding any + /// whitespace characters such as `\t`, `\n`, or ` `. + /// + /// If [usesTerminalUi] is false, throws a [StateError]. + Future promptForCharInput( + List acceptedCharacters, { + required Logger logger, + String? prompt, + int? defaultChoiceIndex, + bool displayAcceptedCharacters = true, + }); +} + +class AnsiTerminal implements Terminal { + AnsiTerminal({ + required io.Stdio stdio, + DateTime? + now, // Time used to determine preferredStyle. Defaults to 0001-01-01 00:00. + bool? supportsColor, + }) : _stdio = stdio, + _now = now ?? DateTime(1), + _supportsColor = supportsColor; + + final io.Stdio _stdio; + final DateTime _now; + + static const String bold = '\u001B[1m'; + static const String resetAll = '\u001B[0m'; + static const String resetColor = '\u001B[39m'; + static const String resetBold = '\u001B[22m'; + static const String clear = '\u001B[2J\u001B[H'; + + static const String red = '\u001b[31m'; + static const String green = '\u001b[32m'; + static const String blue = '\u001b[34m'; + static const String cyan = '\u001b[36m'; + static const String magenta = '\u001b[35m'; + static const String yellow = '\u001b[33m'; + static const String grey = '\u001b[90m'; + + static const Map _colorMap = { + TerminalColor.red: red, + TerminalColor.green: green, + TerminalColor.blue: blue, + TerminalColor.cyan: cyan, + TerminalColor.magenta: magenta, + TerminalColor.yellow: yellow, + TerminalColor.grey: grey, + }; + + static String colorCode(TerminalColor color) => _colorMap[color]!; + + @override + bool get supportsColor => _supportsColor ?? stdout.supportsAnsiEscapes; + final bool? _supportsColor; + + // Assume unicode emojis are supported when not on Windows. + // If we are on Windows, unicode emojis are supported in Windows Terminal, + // which sets the WT_SESSION environment variable. See: + // https://github.com/microsoft/terminal/blob/master/doc/user-docs/index.md#tips-and-tricks + @override + bool get supportsEmoji => + !isWindows || Platform.environment.containsKey('WT_SESSION'); + + @override + int get preferredStyle { + const int workdays = DateTime.friday; + if (_now.weekday <= workdays) { + return _now.weekday - 1; + } + return _now.hour + workdays; + } + + final RegExp _boldControls = RegExp( + '(${RegExp.escape(resetBold)}|${RegExp.escape(bold)})', + ); + + @override + bool usesTerminalUi = false; + + @override + String get warningMark { + return bolden(color('[!]', TerminalColor.red)); + } + + @override + String get successMark { + return bolden(color('✓', TerminalColor.green)); + } + + @override + String bolden(String message) { + assert(message != null); + if (!supportsColor || message.isEmpty) { + return message; + } + final StringBuffer buffer = StringBuffer(); + for (String line in message.split('\n')) { + // If there were bolds or resetBolds in the string before, then nuke them: + // they're redundant. This prevents previously embedded resets from + // stopping the boldness. + line = line.replaceAll(_boldControls, ''); + buffer.writeln('$bold$line$resetBold'); + } + final String result = buffer.toString(); + // avoid introducing a new newline to the emboldened text + return (!message.endsWith('\n') && result.endsWith('\n')) + ? result.substring(0, result.length - 1) + : result; + } + + @override + String color(String message, TerminalColor color) { + assert(message != null); + if (!supportsColor || color == null || message.isEmpty) { + return message; + } + final StringBuffer buffer = StringBuffer(); + final String colorCodes = _colorMap[color]!; + for (String line in message.split('\n')) { + // If there were resets in the string before, then keep them, but + // restart the color right after. This prevents embedded resets from + // stopping the colors, and allows nesting of colors. + line = line.replaceAll(resetColor, '$resetColor$colorCodes'); + buffer.writeln('$colorCodes$line$resetColor'); + } + final String result = buffer.toString(); + // avoid introducing a new newline to the colored text + return (!message.endsWith('\n') && result.endsWith('\n')) + ? result.substring(0, result.length - 1) + : result; + } + + @override + String clearScreen() => supportsColor ? clear : '\n\n'; + + @override + bool get singleCharMode { + if (!_stdio.stdinHasTerminal) { + return false; + } + final io.Stdin stdin = _stdio.stdin as io.Stdin; + return stdin.lineMode && stdin.echoMode; + } + + @override + set singleCharMode(bool value) { + if (!_stdio.stdinHasTerminal) { + return; + } + final io.Stdin stdin = _stdio.stdin as io.Stdin; + // The order of setting lineMode and echoMode is important on Windows. + if (value) { + stdin.echoMode = false; + stdin.lineMode = false; + } else { + stdin.lineMode = true; + stdin.echoMode = true; + } + } + + @override + bool get stdinHasTerminal => _stdio.stdinHasTerminal; + + Stream? _broadcastStdInString; + + @override + Stream get keystrokes { + return _broadcastStdInString ??= _stdio.stdin + .transform(const AsciiDecoder(allowInvalid: true)) + .asBroadcastStream(); + } + + @override + Future promptForCharInput( + List acceptedCharacters, { + required Logger logger, + String? prompt, + int? defaultChoiceIndex, + bool displayAcceptedCharacters = true, + }) async { + assert(acceptedCharacters.isNotEmpty); + assert(prompt == null || prompt.isNotEmpty); + if (!usesTerminalUi) { + throw StateError('cannot prompt without a terminal ui'); + } + List charactersToDisplay = acceptedCharacters; + if (defaultChoiceIndex != null) { + assert(defaultChoiceIndex >= 0 && + defaultChoiceIndex < acceptedCharacters.length); + charactersToDisplay = List.of(charactersToDisplay); + charactersToDisplay[defaultChoiceIndex] = + bolden(charactersToDisplay[defaultChoiceIndex]); + acceptedCharacters.add(''); + } + String? choice; + singleCharMode = true; + while (choice == null || + choice.length > 1 || + !acceptedCharacters.contains(choice)) { + if (prompt != null) { + logger.printStatus(prompt, emphasis: true, newline: false); + if (displayAcceptedCharacters) { + logger.printStatus(' [${charactersToDisplay.join("|")}]', + newline: false); + } + // prompt ends with ': ' + logger.printStatus(': ', emphasis: true, newline: false); + } + choice = (await keystrokes.first).trim(); + logger.printStatus(choice); + } + singleCharMode = false; + if (defaultChoiceIndex != null && choice == '') { + choice = acceptedCharacters[defaultChoiceIndex]; + } + return choice; + } +} + +class _TestTerminal implements Terminal { + _TestTerminal({this.supportsColor = false, this.supportsEmoji = false}); + + @override + bool usesTerminalUi = false; + + @override + String bolden(String message) => message; + + @override + String clearScreen() => '\n\n'; + + @override + String color(String message, TerminalColor color) => message; + + @override + Stream get keystrokes => const Stream.empty(); + + @override + Future promptForCharInput( + List acceptedCharacters, { + required Logger logger, + String? prompt, + int? defaultChoiceIndex, + bool displayAcceptedCharacters = true, + }) { + throw UnsupportedError( + 'promptForCharInput not supported in the test terminal.'); + } + + @override + bool get singleCharMode => false; + @override + set singleCharMode(bool value) {} + + @override + final bool supportsColor; + + @override + final bool supportsEmoji; + + @override + int get preferredStyle => 0; + + @override + bool get stdinHasTerminal => false; + + @override + String get successMark => '✓'; + + @override + String get warningMark => '[!]'; +} diff --git a/packages/flutter_migrate/pubspec.yaml b/packages/flutter_migrate/pubspec.yaml index 28f180b2b1..3704d5b35e 100644 --- a/packages/flutter_migrate/pubspec.yaml +++ b/packages/flutter_migrate/pubspec.yaml @@ -10,5 +10,20 @@ environment: dependencies: args: ^2.3.1 + convert: 3.0.2 + file: 6.1.4 + intl: 0.17.0 + meta: 1.8.0 + path: ^1.8.0 + process: 4.2.4 + test_api: 0.4.13 + test_core: 0.4.17 + vm_service: 9.3.0 + xml: ^6.1.0 + yaml: 3.1.1 dev_dependencies: + collection: 1.16.0 + file_testing: ^3.0.0 + lints: ^2.0.0 + test: ^1.16.0 diff --git a/packages/flutter_migrate/test/base/context_test.dart b/packages/flutter_migrate/test/base/context_test.dart new file mode 100644 index 0000000000..53f8e8f691 --- /dev/null +++ b/packages/flutter_migrate/test/base/context_test.dart @@ -0,0 +1,296 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter_migrate/src/base/context.dart'; + +import '../src/common.dart'; + +void main() { + group('AppContext', () { + group('global getter', () { + late bool called; + + setUp(() { + called = false; + }); + + test('returns non-null context in the root zone', () { + expect(context, isNotNull); + }); + + test( + 'returns root context in child of root zone if zone was manually created', + () { + final Zone rootZone = Zone.current; + final AppContext rootContext = context; + runZoned(() { + expect(Zone.current, isNot(rootZone)); + expect(Zone.current.parent, rootZone); + expect(context, rootContext); + called = true; + }); + expect(called, isTrue); + }); + + test('returns child context after run', () async { + final AppContext rootContext = context; + await rootContext.run( + name: 'child', + body: () { + expect(context, isNot(rootContext)); + expect(context.name, 'child'); + called = true; + }); + expect(called, isTrue); + }); + + test('returns grandchild context after nested run', () async { + final AppContext rootContext = context; + await rootContext.run( + name: 'child', + body: () async { + final AppContext childContext = context; + await childContext.run( + name: 'grandchild', + body: () { + expect(context, isNot(rootContext)); + expect(context, isNot(childContext)); + expect(context.name, 'grandchild'); + called = true; + }); + }); + expect(called, isTrue); + }); + + test('scans up zone hierarchy for first context', () async { + final AppContext rootContext = context; + await rootContext.run( + name: 'child', + body: () { + final AppContext childContext = context; + runZoned(() { + expect(context, isNot(rootContext)); + expect(context, same(childContext)); + expect(context.name, 'child'); + called = true; + }); + }); + expect(called, isTrue); + }); + }); + + group('operator[]', () { + test('still finds values if async code runs after body has finished', + () async { + final Completer outer = Completer(); + final Completer inner = Completer(); + String? value; + await context.run( + body: () { + outer.future.then((_) { + value = context.get(); + inner.complete(); + }); + }, + fallbacks: { + String: () => 'value', + }, + ); + expect(value, isNull); + outer.complete(); + await inner.future; + expect(value, 'value'); + }); + + test('caches generated override values', () async { + int consultationCount = 0; + String? value; + await context.run( + body: () async { + final StringBuffer buf = StringBuffer(context.get()!); + buf.write(context.get()); + await context.run(body: () { + buf.write(context.get()); + }); + value = buf.toString(); + }, + overrides: { + String: () { + consultationCount++; + return 'v'; + }, + }, + ); + expect(value, 'vvv'); + expect(consultationCount, 1); + }); + + test('caches generated fallback values', () async { + int consultationCount = 0; + String? value; + await context.run( + body: () async { + final StringBuffer buf = StringBuffer(context.get()!); + buf.write(context.get()); + await context.run(body: () { + buf.write(context.get()); + }); + value = buf.toString(); + }, + fallbacks: { + String: () { + consultationCount++; + return 'v'; + }, + }, + ); + expect(value, 'vvv'); + expect(consultationCount, 1); + }); + + test('returns null if generated value is null', () async { + final String? value = await context.run( + body: () => context.get(), + overrides: { + String: () => null, + }, + ); + expect(value, isNull); + }); + + test('throws if generator has dependency cycle', () async { + final Future value = context.run( + body: () async { + return context.get(); + }, + fallbacks: { + int: () => int.parse(context.get() ?? ''), + String: () => '${context.get()}', + double: () => context.get()! * 1.0, + }, + ); + expect( + () => value, + throwsA( + isA().having( + (ContextDependencyCycleException error) => error.cycle, + 'cycle', + [String, double, int]).having( + (ContextDependencyCycleException error) => error.toString(), + 'toString()', + 'Dependency cycle detected: String -> double -> int', + ), + ), + ); + }); + }); + + group('run', () { + test('returns the value returned by body', () async { + expect(await context.run(body: () => 123), 123); + expect(await context.run(body: () => 'value'), 'value'); + expect(await context.run(body: () async => 456), 456); + }); + + test('passes name to child context', () async { + await context.run( + name: 'child', + body: () { + expect(context.name, 'child'); + }); + }); + + group('fallbacks', () { + late bool called; + + setUp(() { + called = false; + }); + + test('are applied after parent context is consulted', () async { + final String? value = await context.run( + body: () { + return context.run( + body: () { + called = true; + return context.get(); + }, + fallbacks: { + String: () => 'child', + }, + ); + }, + ); + expect(called, isTrue); + expect(value, 'child'); + }); + + test('are not applied if parent context supplies value', () async { + bool childConsulted = false; + final String? value = await context.run( + body: () { + return context.run( + body: () { + called = true; + return context.get(); + }, + fallbacks: { + String: () { + childConsulted = true; + return 'child'; + }, + }, + ); + }, + fallbacks: { + String: () => 'parent', + }, + ); + expect(called, isTrue); + expect(value, 'parent'); + expect(childConsulted, isFalse); + }); + + test('may depend on one another', () async { + final String? value = await context.run( + body: () { + return context.get(); + }, + fallbacks: { + int: () => 123, + String: () => '-${context.get()}-', + }, + ); + expect(value, '-123-'); + }); + }); + + group('overrides', () { + test('intercept consultation of parent context', () async { + bool parentConsulted = false; + final String? value = await context.run( + body: () { + return context.run( + body: () => context.get(), + overrides: { + String: () => 'child', + }, + ); + }, + fallbacks: { + String: () { + parentConsulted = true; + return 'parent'; + }, + }, + ); + expect(value, 'child'); + expect(parentConsulted, isFalse); + }); + }); + }); + }); +} diff --git a/packages/flutter_migrate/test/base/file_system_test.dart b/packages/flutter_migrate/test/base/file_system_test.dart new file mode 100644 index 0000000000..b7a6424f97 --- /dev/null +++ b/packages/flutter_migrate/test/base/file_system_test.dart @@ -0,0 +1,357 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io' as io; + +import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; +import 'package:flutter_migrate/src/base/common.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:test/fake.dart'; + +import '../src/common.dart'; + +class LocalFileSystemFake extends LocalFileSystem { + LocalFileSystemFake.test({required super.signals}) : super.test(); + + @override + Directory get superSystemTempDirectory => directory('/does_not_exist'); +} + +void main() { + group('fsUtils', () { + late MemoryFileSystem fs; + late FileSystemUtils fsUtils; + + setUp(() { + fs = MemoryFileSystem.test(); + fsUtils = FileSystemUtils( + fileSystem: fs, + ); + }); + + testWithoutContext('getUniqueFile creates a unique file name', () async { + final File fileA = fsUtils.getUniqueFile( + fs.currentDirectory, 'foo', 'json') + ..createSync(); + final File fileB = + fsUtils.getUniqueFile(fs.currentDirectory, 'foo', 'json'); + + expect(fileA.path, '/foo_01.json'); + expect(fileB.path, '/foo_02.json'); + }); + + testWithoutContext('getUniqueDirectory creates a unique directory name', + () async { + final Directory directoryA = + fsUtils.getUniqueDirectory(fs.currentDirectory, 'foo')..createSync(); + final Directory directoryB = + fsUtils.getUniqueDirectory(fs.currentDirectory, 'foo'); + + expect(directoryA.path, '/foo_01'); + expect(directoryB.path, '/foo_02'); + }); + }); + + group('copyDirectorySync', () { + /// Test file_systems.copyDirectorySync() using MemoryFileSystem. + /// Copies between 2 instances of file systems which is also supported by copyDirectorySync(). + testWithoutContext('test directory copy', () async { + final MemoryFileSystem sourceMemoryFs = MemoryFileSystem.test(); + const String sourcePath = '/some/origin'; + final Directory sourceDirectory = + await sourceMemoryFs.directory(sourcePath).create(recursive: true); + sourceMemoryFs.currentDirectory = sourcePath; + final File sourceFile1 = sourceMemoryFs.file('some_file.txt') + ..writeAsStringSync('bleh'); + final DateTime writeTime = sourceFile1.lastModifiedSync(); + sourceMemoryFs + .file('sub_dir/another_file.txt') + .createSync(recursive: true); + sourceMemoryFs.directory('empty_directory').createSync(); + + // Copy to another memory file system instance. + final MemoryFileSystem targetMemoryFs = MemoryFileSystem.test(); + const String targetPath = '/some/non-existent/target'; + final Directory targetDirectory = targetMemoryFs.directory(targetPath); + + copyDirectory(sourceDirectory, targetDirectory); + + expect(targetDirectory.existsSync(), true); + targetMemoryFs.currentDirectory = targetPath; + expect(targetMemoryFs.directory('empty_directory').existsSync(), true); + expect( + targetMemoryFs.file('sub_dir/another_file.txt').existsSync(), true); + expect(targetMemoryFs.file('some_file.txt').readAsStringSync(), 'bleh'); + + // Assert that the copy operation hasn't modified the original file in some way. + expect( + sourceMemoryFs.file('some_file.txt').lastModifiedSync(), writeTime); + // There's still 3 things in the original directory as there were initially. + expect(sourceMemoryFs.directory(sourcePath).listSync().length, 3); + }); + + testWithoutContext('Skip files if shouldCopyFile returns false', () { + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final Directory origin = fileSystem.directory('/origin'); + origin.createSync(); + fileSystem + .file(fileSystem.path.join('origin', 'a.txt')) + .writeAsStringSync('irrelevant'); + fileSystem.directory('/origin/nested').createSync(); + fileSystem + .file(fileSystem.path.join('origin', 'nested', 'a.txt')) + .writeAsStringSync('irrelevant'); + fileSystem + .file(fileSystem.path.join('origin', 'nested', 'b.txt')) + .writeAsStringSync('irrelevant'); + + final Directory destination = fileSystem.directory('/destination'); + copyDirectory(origin, destination, + shouldCopyFile: (File origin, File dest) { + return origin.basename == 'b.txt'; + }); + + expect(destination.existsSync(), isTrue); + expect(destination.childDirectory('nested').existsSync(), isTrue); + expect( + destination.childDirectory('nested').childFile('b.txt').existsSync(), + isTrue); + + expect(destination.childFile('a.txt').existsSync(), isFalse); + expect( + destination.childDirectory('nested').childFile('a.txt').existsSync(), + isFalse); + }); + + testWithoutContext('Skip directories if shouldCopyDirectory returns false', + () { + final MemoryFileSystem fileSystem = MemoryFileSystem.test(); + final Directory origin = fileSystem.directory('/origin'); + origin.createSync(); + fileSystem + .file(fileSystem.path.join('origin', 'a.txt')) + .writeAsStringSync('irrelevant'); + fileSystem.directory('/origin/nested').createSync(); + fileSystem + .file(fileSystem.path.join('origin', 'nested', 'a.txt')) + .writeAsStringSync('irrelevant'); + fileSystem + .file(fileSystem.path.join('origin', 'nested', 'b.txt')) + .writeAsStringSync('irrelevant'); + + final Directory destination = fileSystem.directory('/destination'); + copyDirectory(origin, destination, + shouldCopyDirectory: (Directory directory) { + return !directory.path.endsWith('nested'); + }); + + expect(destination, exists); + expect(destination.childDirectory('nested'), isNot(exists)); + expect(destination.childDirectory('nested').childFile('b.txt'), + isNot(exists)); + }); + }); + + group('LocalFileSystem', () { + late FakeProcessSignal fakeSignal; + late ProcessSignal signalUnderTest; + + setUp(() { + fakeSignal = FakeProcessSignal(); + signalUnderTest = ProcessSignal(fakeSignal); + }); + + testWithoutContext('runs shutdown hooks', () async { + final Signals signals = Signals.test(); + final LocalFileSystem localFileSystem = LocalFileSystem.test( + signals: signals, + ); + final Directory temp = localFileSystem.systemTempDirectory; + + expect(temp.existsSync(), isTrue); + expect(localFileSystem.shutdownHooks.registeredHooks, hasLength(1)); + final BufferLogger logger = BufferLogger.test(); + await localFileSystem.shutdownHooks.runShutdownHooks(logger); + expect(temp.existsSync(), isFalse); + expect(logger.traceText, contains('Running 1 shutdown hook')); + }); + + testWithoutContext('deletes system temp entry on a fatal signal', () async { + final Completer completer = Completer(); + final Signals signals = Signals.test(); + final LocalFileSystem localFileSystem = LocalFileSystem.test( + signals: signals, + fatalSignals: [signalUnderTest], + ); + final Directory temp = localFileSystem.systemTempDirectory; + + signals.addHandler(signalUnderTest, (ProcessSignal s) { + completer.complete(); + }); + + expect(temp.existsSync(), isTrue); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + + expect(temp.existsSync(), isFalse); + }); + + testWithoutContext('throwToolExit when temp not found', () async { + final Signals signals = Signals.test(); + final LocalFileSystemFake localFileSystem = LocalFileSystemFake.test( + signals: signals, + ); + + try { + localFileSystem.systemTempDirectory; + fail('expected tool exit'); + } on ToolExit catch (e) { + expect( + e.message, + 'Your system temp directory (/does_not_exist) does not exist. ' + 'Did you set an invalid override in your environment? ' + 'See issue https://github.com/flutter/flutter/issues/74042 for more context.'); + } + }); + }); +} + +class FakeProcessSignal extends Fake implements io.ProcessSignal { + final StreamController controller = + StreamController(); + + @override + Stream watch() => controller.stream; +} + +/// Various convenience file system methods. +class FileSystemUtils { + FileSystemUtils({ + required FileSystem fileSystem, + }) : _fileSystem = fileSystem; + + final FileSystem _fileSystem; + + /// Appends a number to a filename in order to make it unique under a + /// directory. + File getUniqueFile(Directory dir, String baseName, String ext) { + final FileSystem fs = dir.fileSystem; + int i = 1; + + while (true) { + final String name = '${baseName}_${i.toString().padLeft(2, '0')}.$ext'; + final File file = fs.file(dir.fileSystem.path.join(dir.path, name)); + if (!file.existsSync()) { + file.createSync(recursive: true); + return file; + } + i += 1; + } + } + + // /// Appends a number to a filename in order to make it unique under a + // /// directory. + // File getUniqueFile(Directory dir, String baseName, String ext) { + // return _getUniqueFile(dir, baseName, ext); + // } + + /// Appends a number to a directory name in order to make it unique under a + /// directory. + Directory getUniqueDirectory(Directory dir, String baseName) { + final FileSystem fs = dir.fileSystem; + int i = 1; + + while (true) { + final String name = '${baseName}_${i.toString().padLeft(2, '0')}'; + final Directory directory = + fs.directory(_fileSystem.path.join(dir.path, name)); + if (!directory.existsSync()) { + return directory; + } + i += 1; + } + } + + /// Escapes [path]. + /// + /// On Windows it replaces all '\' with '\\'. On other platforms, it returns the + /// path unchanged. + String escapePath(String path) => + isWindows ? path.replaceAll(r'\', r'\\') : path; + + /// Returns true if the file system [entity] has not been modified since the + /// latest modification to [referenceFile]. + /// + /// Returns true, if [entity] does not exist. + /// + /// Returns false, if [entity] exists, but [referenceFile] does not. + bool isOlderThanReference({ + required FileSystemEntity entity, + required File referenceFile, + }) { + if (!entity.existsSync()) { + return true; + } + return referenceFile.existsSync() && + referenceFile.statSync().modified.isAfter(entity.statSync().modified); + } +} + +/// Creates `destDir` if needed, then recursively copies `srcDir` to +/// `destDir`, invoking [onFileCopied], if specified, for each +/// source/destination file pair. +/// +/// Skips files if [shouldCopyFile] returns `false`. +/// Does not recurse over directories if [shouldCopyDirectory] returns `false`. +void copyDirectory( + Directory srcDir, + Directory destDir, { + bool Function(File srcFile, File destFile)? shouldCopyFile, + bool Function(Directory)? shouldCopyDirectory, + void Function(File srcFile, File destFile)? onFileCopied, +}) { + if (!srcDir.existsSync()) { + throw Exception( + 'Source directory "${srcDir.path}" does not exist, nothing to copy'); + } + + if (!destDir.existsSync()) { + destDir.createSync(recursive: true); + } + + for (final FileSystemEntity entity in srcDir.listSync()) { + final String newPath = + destDir.fileSystem.path.join(destDir.path, entity.basename); + if (entity is Link) { + final Link newLink = destDir.fileSystem.link(newPath); + newLink.createSync(entity.targetSync()); + } else if (entity is File) { + final File newFile = destDir.fileSystem.file(newPath); + if (shouldCopyFile != null && !shouldCopyFile(entity, newFile)) { + continue; + } + newFile.writeAsBytesSync(entity.readAsBytesSync()); + onFileCopied?.call(entity, newFile); + } else if (entity is Directory) { + if (shouldCopyDirectory != null && !shouldCopyDirectory(entity)) { + continue; + } + copyDirectory( + entity, + destDir.fileSystem.directory(newPath), + shouldCopyFile: shouldCopyFile, + onFileCopied: onFileCopied, + ); + } else { + throw Exception( + '${entity.path} is neither File nor Directory, was ${entity.runtimeType}'); + } + } +} diff --git a/packages/flutter_migrate/test/base/io_test.dart b/packages/flutter_migrate/test/base/io_test.dart new file mode 100644 index 0000000000..608715e497 --- /dev/null +++ b/packages/flutter_migrate/test/base/io_test.dart @@ -0,0 +1,86 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io' as io; + +import 'package:file/memory.dart'; +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:test/fake.dart'; + +import '../src/common.dart'; +import '../src/io.dart'; + +void main() { + testWithoutContext('IOOverrides can inject a memory file system', () async { + final MemoryFileSystem memoryFileSystem = MemoryFileSystem.test(); + final FlutterIOOverrides flutterIOOverrides = + FlutterIOOverrides(fileSystem: memoryFileSystem); + await io.IOOverrides.runWithIOOverrides(() async { + // statics delegate correctly. + expect(io.FileSystemEntity.isWatchSupported, + memoryFileSystem.isWatchSupported); + expect(io.Directory.systemTemp.path, + memoryFileSystem.systemTempDirectory.path); + + // can create and write to files/directories sync. + final io.File file = io.File('abc'); + file.writeAsStringSync('def'); + final io.Directory directory = io.Directory('foobar'); + directory.createSync(); + + expect(memoryFileSystem.file('abc').existsSync(), true); + expect(memoryFileSystem.file('abc').readAsStringSync(), 'def'); + expect(memoryFileSystem.directory('foobar').existsSync(), true); + + // can create and write to files/directories async. + final io.File fileB = io.File('xyz'); + await fileB.writeAsString('def'); + final io.Directory directoryB = io.Directory('barfoo'); + await directoryB.create(); + + expect(memoryFileSystem.file('xyz').existsSync(), true); + expect(memoryFileSystem.file('xyz').readAsStringSync(), 'def'); + expect(memoryFileSystem.directory('barfoo').existsSync(), true); + + // Links + final io.Link linkA = io.Link('hhh'); + final io.Link linkB = io.Link('ggg'); + io.File('jjj').createSync(); + io.File('lll').createSync(); + await linkA.create('jjj'); + linkB.createSync('lll'); + + expect(await memoryFileSystem.link('hhh').resolveSymbolicLinks(), + await linkA.resolveSymbolicLinks()); + expect(memoryFileSystem.link('ggg').resolveSymbolicLinksSync(), + linkB.resolveSymbolicLinksSync()); + }, flutterIOOverrides); + }); + + testWithoutContext('ProcessSignal signals are properly delegated', () async { + final FakeProcessSignal signal = FakeProcessSignal(); + final ProcessSignal signalUnderTest = ProcessSignal(signal); + + signal.controller.add(signal); + + expect(signalUnderTest, await signalUnderTest.watch().first); + }); + + testWithoutContext('ProcessSignal toString() works', () async { + expect(io.ProcessSignal.sigint.toString(), ProcessSignal.sigint.toString()); + }); + + testWithoutContext('test_api defines the Declarer in a known place', () { + expect(Zone.current[#test.declarer], isNotNull); + }); +} + +class FakeProcessSignal extends Fake implements io.ProcessSignal { + final StreamController controller = + StreamController(); + + @override + Stream watch() => controller.stream; +} diff --git a/packages/flutter_migrate/test/base/logger_test.dart b/packages/flutter_migrate/test/base/logger_test.dart new file mode 100644 index 0000000000..da055220ee --- /dev/null +++ b/packages/flutter_migrate/test/base/logger_test.dart @@ -0,0 +1,679 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:test/fake.dart'; + +import '../src/common.dart'; +import '../src/fakes.dart'; + +// final Platform _kNoAnsiPlatform = FakePlatform(); +final String red = RegExp.escape(AnsiTerminal.red); +final String bold = RegExp.escape(AnsiTerminal.bold); +final String resetBold = RegExp.escape(AnsiTerminal.resetBold); +final String resetColor = RegExp.escape(AnsiTerminal.resetColor); + +void main() { + testWithoutContext('correct logger instance is created', () { + final LoggerFactory loggerFactory = LoggerFactory( + terminal: Terminal.test(), + stdio: FakeStdio(), + outputPreferences: OutputPreferences.test(), + ); + + expect( + loggerFactory.createLogger( + windows: false, + ), + isA()); + expect( + loggerFactory.createLogger( + windows: true, + ), + isA()); + }); + + testWithoutContext( + 'WindowsStdoutLogger rewrites emojis when terminal does not support emoji', + () { + final FakeStdio stdio = FakeStdio(); + final WindowsStdoutLogger logger = WindowsStdoutLogger( + outputPreferences: OutputPreferences.test(), + stdio: stdio, + terminal: Terminal.test(supportsColor: false, supportsEmoji: false), + ); + + logger.printStatus('🔥🖼️✗✓🔨💪✏️'); + + expect(stdio.writtenToStdout, ['X√\n']); + }); + + testWithoutContext( + 'WindowsStdoutLogger does not rewrite emojis when terminal does support emoji', + () { + final FakeStdio stdio = FakeStdio(); + final WindowsStdoutLogger logger = WindowsStdoutLogger( + outputPreferences: OutputPreferences.test(), + stdio: stdio, + terminal: Terminal.test(supportsColor: true, supportsEmoji: true), + ); + + logger.printStatus('🔥🖼️✗✓🔨💪✏️'); + + expect(stdio.writtenToStdout, ['🔥🖼️✗✓🔨💪✏️\n']); + }); + testWithoutContext( + 'Logger does not throw when stdio write throws synchronously', () async { + final FakeStdout stdout = FakeStdout(syncError: true); + final FakeStdout stderr = FakeStdout(syncError: true); + final Stdio stdio = Stdio.test(stdout: stdout, stderr: stderr); + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: stdio, + ), + stdio: stdio, + outputPreferences: OutputPreferences.test(), + ); + + logger.printStatus('message'); + logger.printError('error message'); + }); + + testWithoutContext( + 'Logger does not throw when stdio write throws asynchronously', () async { + final FakeStdout stdout = FakeStdout(syncError: false); + final FakeStdout stderr = FakeStdout(syncError: false); + final Stdio stdio = Stdio.test(stdout: stdout, stderr: stderr); + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: stdio, + ), + stdio: stdio, + outputPreferences: OutputPreferences.test(), + ); + logger.printStatus('message'); + logger.printError('error message'); + + await stdout.done; + await stderr.done; + }); + + testWithoutContext( + 'Logger does not throw when stdio completes done with an error', + () async { + final FakeStdout stdout = + FakeStdout(syncError: false, completeWithError: true); + final FakeStdout stderr = + FakeStdout(syncError: false, completeWithError: true); + final Stdio stdio = Stdio.test(stdout: stdout, stderr: stderr); + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: stdio, + ), + stdio: stdio, + outputPreferences: OutputPreferences.test(), + ); + logger.printStatus('message'); + logger.printError('error message'); + + expect(() async => stdout.done, throwsException); + expect(() async => stderr.done, throwsException); + }); + + group('Output format', () { + late FakeStdio fakeStdio; + late SummaryStatus summaryStatus; + late int called; + + setUp(() { + fakeStdio = FakeStdio(); + called = 0; + summaryStatus = SummaryStatus( + message: 'Hello world', + padding: 20, + onFinish: () => called++, + stdio: fakeStdio, + stopwatch: FakeStopwatch(), + ); + }); + + List outputStdout() => fakeStdio.writtenToStdout.join().split('\n'); + List outputStderr() => fakeStdio.writtenToStderr.join().split('\n'); + + testWithoutContext('Error logs are wrapped', () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printError('0123456789' * 15); + final List lines = outputStderr(); + + expect(outputStdout().length, equals(1)); + expect(outputStdout().first, isEmpty); + expect(lines[0], equals('0123456789' * 4)); + expect(lines[1], equals('0123456789' * 4)); + expect(lines[2], equals('0123456789' * 4)); + expect(lines[3], equals('0123456789' * 3)); + }); + + testWithoutContext('Error logs are wrapped and can be indented.', () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printError('0123456789' * 15, indent: 5); + final List lines = outputStderr(); + + expect(outputStdout().length, equals(1)); + expect(outputStdout().first, isEmpty); + expect(lines.length, equals(6)); + expect(lines[0], equals(' 01234567890123456789012345678901234')); + expect(lines[1], equals(' 56789012345678901234567890123456789')); + expect(lines[2], equals(' 01234567890123456789012345678901234')); + expect(lines[3], equals(' 56789012345678901234567890123456789')); + expect(lines[4], equals(' 0123456789')); + expect(lines[5], isEmpty); + }); + + testWithoutContext('Error logs are wrapped and can have hanging indent.', + () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printError('0123456789' * 15, hangingIndent: 5); + final List lines = outputStderr(); + + expect(outputStdout().length, equals(1)); + expect(outputStdout().first, isEmpty); + expect(lines.length, equals(6)); + expect(lines[0], equals('0123456789012345678901234567890123456789')); + expect(lines[1], equals(' 01234567890123456789012345678901234')); + expect(lines[2], equals(' 56789012345678901234567890123456789')); + expect(lines[3], equals(' 01234567890123456789012345678901234')); + expect(lines[4], equals(' 56789')); + expect(lines[5], isEmpty); + }); + + testWithoutContext( + 'Error logs are wrapped, indented, and can have hanging indent.', + () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printError('0123456789' * 15, indent: 4, hangingIndent: 5); + final List lines = outputStderr(); + + expect(outputStdout().length, equals(1)); + expect(outputStdout().first, isEmpty); + expect(lines.length, equals(6)); + expect(lines[0], equals(' 012345678901234567890123456789012345')); + expect(lines[1], equals(' 6789012345678901234567890123456')); + expect(lines[2], equals(' 7890123456789012345678901234567')); + expect(lines[3], equals(' 8901234567890123456789012345678')); + expect(lines[4], equals(' 901234567890123456789')); + expect(lines[5], isEmpty); + }); + + testWithoutContext('Stdout logs are wrapped', () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printStatus('0123456789' * 15); + final List lines = outputStdout(); + + expect(outputStderr().length, equals(1)); + expect(outputStderr().first, isEmpty); + expect(lines[0], equals('0123456789' * 4)); + expect(lines[1], equals('0123456789' * 4)); + expect(lines[2], equals('0123456789' * 4)); + expect(lines[3], equals('0123456789' * 3)); + }); + + testWithoutContext('Stdout logs are wrapped and can be indented.', + () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printStatus('0123456789' * 15, indent: 5); + final List lines = outputStdout(); + + expect(outputStderr().length, equals(1)); + expect(outputStderr().first, isEmpty); + expect(lines.length, equals(6)); + expect(lines[0], equals(' 01234567890123456789012345678901234')); + expect(lines[1], equals(' 56789012345678901234567890123456789')); + expect(lines[2], equals(' 01234567890123456789012345678901234')); + expect(lines[3], equals(' 56789012345678901234567890123456789')); + expect(lines[4], equals(' 0123456789')); + expect(lines[5], isEmpty); + }); + + testWithoutContext('Stdout logs are wrapped and can have hanging indent.', + () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40)); + logger.printStatus('0123456789' * 15, hangingIndent: 5); + final List lines = outputStdout(); + + expect(outputStderr().length, equals(1)); + expect(outputStderr().first, isEmpty); + expect(lines.length, equals(6)); + expect(lines[0], equals('0123456789012345678901234567890123456789')); + expect(lines[1], equals(' 01234567890123456789012345678901234')); + expect(lines[2], equals(' 56789012345678901234567890123456789')); + expect(lines[3], equals(' 01234567890123456789012345678901234')); + expect(lines[4], equals(' 56789')); + expect(lines[5], isEmpty); + }); + + testWithoutContext( + 'Stdout logs are wrapped, indented, and can have hanging indent.', + () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + ); + logger.printStatus('0123456789' * 15, indent: 4, hangingIndent: 5); + final List lines = outputStdout(); + + expect(outputStderr().length, equals(1)); + expect(outputStderr().first, isEmpty); + expect(lines.length, equals(6)); + expect(lines[0], equals(' 012345678901234567890123456789012345')); + expect(lines[1], equals(' 6789012345678901234567890123456')); + expect(lines[2], equals(' 7890123456789012345678901234567')); + expect(lines[3], equals(' 8901234567890123456789012345678')); + expect(lines[4], equals(' 901234567890123456789')); + expect(lines[5], isEmpty); + }); + + testWithoutContext('Error logs are red', () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + supportsColor: true, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(showColor: true), + ); + logger.printError('Pants on fire!'); + final List lines = outputStderr(); + + expect(outputStdout().length, equals(1)); + expect(outputStdout().first, isEmpty); + expect( + lines[0], + equals( + '${AnsiTerminal.red}Pants on fire!${AnsiTerminal.resetColor}')); + }); + + testWithoutContext('Stdout logs are not colored', () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(showColor: true), + ); + logger.printStatus('All good.'); + + final List lines = outputStdout(); + expect(outputStderr().length, equals(1)); + expect(outputStderr().first, isEmpty); + expect(lines[0], equals('All good.')); + }); + + testWithoutContext('Stdout printBox puts content inside a box', () { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(showColor: true), + ); + logger.printBox('Hello world', title: 'Test title'); + final String stdout = fakeStdio.writtenToStdout.join(); + expect( + stdout, + contains('\n' + '┌─ Test title ┐\n' + '│ Hello world │\n' + '└─────────────┘\n'), + ); + }); + + testWithoutContext('Stdout printBox does not require title', () { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(showColor: true), + ); + logger.printBox('Hello world'); + final String stdout = fakeStdio.writtenToStdout.join(); + expect( + stdout, + contains('\n' + '┌─────────────┐\n' + '│ Hello world │\n' + '└─────────────┘\n'), + ); + }); + + testWithoutContext('Stdout printBox handles new lines', () { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(showColor: true), + ); + logger.printBox('Hello world\nThis is a new line', title: 'Test title'); + final String stdout = fakeStdio.writtenToStdout.join(); + expect( + stdout, + contains('\n' + '┌─ Test title ───────┐\n' + '│ Hello world │\n' + '│ This is a new line │\n' + '└────────────────────┘\n'), + ); + }); + + testWithoutContext( + 'Stdout printBox handles content with ANSI escape characters', () { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(showColor: true), + ); + const String bold = '\u001B[1m'; + const String clear = '\u001B[2J\u001B[H'; + logger.printBox('${bold}Hello world$clear', title: 'Test title'); + final String stdout = fakeStdio.writtenToStdout.join(); + expect( + stdout, + contains('\n' + '┌─ Test title ┐\n' + '│ ${bold}Hello world$clear │\n' + '└─────────────┘\n'), + ); + }); + + testWithoutContext('Stdout printBox handles column limit', () { + const int columnLimit = 14; + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(showColor: true, wrapColumn: columnLimit), + ); + logger.printBox('This line is longer than $columnLimit characters', + title: 'Test'); + final String stdout = fakeStdio.writtenToStdout.join(); + final List stdoutLines = stdout.split('\n'); + + expect(stdoutLines.length, greaterThan(1)); + expect(stdoutLines[1].length, equals(columnLimit)); + expect( + stdout, + contains('\n' + '┌─ Test ─────┐\n' + '│ This line │\n' + '│ is longer │\n' + '│ than 14 │\n' + '│ characters │\n' + '└────────────┘\n'), + ); + }); + + testWithoutContext( + 'Stdout printBox handles column limit and respects new lines', () { + const int columnLimit = 14; + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(showColor: true, wrapColumn: columnLimit), + ); + logger.printBox('This\nline is longer than\n\n$columnLimit characters', + title: 'Test'); + final String stdout = fakeStdio.writtenToStdout.join(); + final List stdoutLines = stdout.split('\n'); + + expect(stdoutLines.length, greaterThan(1)); + expect(stdoutLines[1].length, equals(columnLimit)); + expect( + stdout, + contains('\n' + '┌─ Test ─────┐\n' + '│ This │\n' + '│ line is │\n' + '│ longer │\n' + '│ than │\n' + '│ │\n' + '│ 14 │\n' + '│ characters │\n' + '└────────────┘\n'), + ); + }); + + testWithoutContext( + 'Stdout printBox breaks long words that exceed the column limit', () { + const int columnLimit = 14; + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: + OutputPreferences.test(showColor: true, wrapColumn: columnLimit), + ); + logger.printBox('Thiswordislongerthan${columnLimit}characters', + title: 'Test'); + final String stdout = fakeStdio.writtenToStdout.join(); + final List stdoutLines = stdout.split('\n'); + + expect(stdoutLines.length, greaterThan(1)); + expect(stdoutLines[1].length, equals(columnLimit)); + expect( + stdout, + contains('\n' + '┌─ Test ─────┐\n' + '│ Thiswordis │\n' + '│ longerthan │\n' + '│ 14characte │\n' + '│ rs │\n' + '└────────────┘\n'), + ); + }); + + testWithoutContext('Stdout startProgress on non-color terminal', () async { + final FakeStopwatch fakeStopwatch = FakeStopwatch(); + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(), + stopwatchFactory: FakeStopwatchFactory(stopwatch: fakeStopwatch), + ); + final Status status = logger.startProgress( + 'Hello', + progressIndicatorPadding: + 20, // this minus the "Hello" equals the 15 below. + ); + expect(outputStderr().length, equals(1)); + expect(outputStderr().first, isEmpty); + // the 5 below is the margin that is always included between the message and the time. + expect(outputStdout().join('\n'), matches(r'^Hello {15} {5}$')); + + fakeStopwatch.elapsed = const Duration(seconds: 4, milliseconds: 123); + status.stop(); + + expect(outputStdout(), ['Hello 4.1s', '']); + }); + + testWithoutContext('SummaryStatus works when canceled', () async { + final SummaryStatus summaryStatus = SummaryStatus( + message: 'Hello world', + padding: 20, + onFinish: () => called++, + stdio: fakeStdio, + stopwatch: FakeStopwatch(), + ); + summaryStatus.start(); + final List lines = outputStdout(); + expect(lines[0], startsWith('Hello world ')); + expect(lines.length, equals(1)); + expect(lines[0].endsWith('\n'), isFalse); + + // Verify a cancel does _not_ print the time and prints a newline. + summaryStatus.cancel(); + expect(outputStdout(), [ + 'Hello world ', + '', + ]); + + // Verify that stopping or canceling multiple times throws. + expect(summaryStatus.cancel, throwsAssertionError); + expect(summaryStatus.stop, throwsAssertionError); + }); + + testWithoutContext('SummaryStatus works when stopped', () async { + summaryStatus.start(); + final List lines = outputStdout(); + expect(lines[0], startsWith('Hello world ')); + expect(lines.length, equals(1)); + + // Verify a stop prints the time. + summaryStatus.stop(); + expect(outputStdout(), [ + 'Hello world 0ms', + '', + ]); + + // Verify that stopping or canceling multiple times throws. + expect(summaryStatus.stop, throwsAssertionError); + expect(summaryStatus.cancel, throwsAssertionError); + }); + + testWithoutContext('sequential startProgress calls with StdoutLogger', + () async { + final Logger logger = StdoutLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + stdio: fakeStdio, + outputPreferences: OutputPreferences.test(), + ); + logger.startProgress('AAA').stop(); + logger.startProgress('BBB').stop(); + final List output = outputStdout(); + + expect(output.length, equals(3)); + + // There's 61 spaces at the start: 59 (padding default) - 3 (length of AAA) + 5 (margin). + // Then there's a left-padded "0ms" 8 characters wide, so 5 spaces then "0ms" + // (except sometimes it's randomly slow so we handle up to "99,999ms"). + expect(output[0], matches(RegExp(r'AAA[ ]{61}[\d, ]{5}[\d]ms'))); + expect(output[1], matches(RegExp(r'BBB[ ]{61}[\d, ]{5}[\d]ms'))); + }); + + testWithoutContext('sequential startProgress calls with BufferLogger', + () async { + final BufferLogger logger = BufferLogger( + terminal: AnsiTerminal( + stdio: fakeStdio, + ), + outputPreferences: OutputPreferences.test(), + ); + logger.startProgress('AAA').stop(); + logger.startProgress('BBB').stop(); + + expect(logger.statusText, 'AAA\nBBB\n'); + }); + }); +} + +/// A fake [Logger] that throws the [Invocation] for any method call. +class FakeLogger implements Logger { + @override + dynamic noSuchMethod(Invocation invocation) => + throw invocation; // ignore: only_throw_errors +} + +class FakeStdout extends Fake implements Stdout { + FakeStdout({required this.syncError, this.completeWithError = false}); + + final bool syncError; + final bool completeWithError; + final Completer _completer = Completer(); + + @override + void write(Object? object) { + if (syncError) { + throw Exception('Error!'); + } + Zone.current.runUnaryGuarded((_) { + if (completeWithError) { + _completer.completeError(Exception('Some pipe error')); + } else { + _completer.complete(); + throw Exception('Error!'); + } + }, null); + } + + @override + Future get done => _completer.future; +} diff --git a/packages/flutter_migrate/test/base/signals_test.dart b/packages/flutter_migrate/test/base/signals_test.dart new file mode 100644 index 0000000000..87f0d04155 --- /dev/null +++ b/packages/flutter_migrate/test/base/signals_test.dart @@ -0,0 +1,181 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io' as io; + +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:test/fake.dart'; + +import '../src/common.dart'; + +void main() { + group('Signals', () { + late Signals signals; + late FakeProcessSignal fakeSignal; + late ProcessSignal signalUnderTest; + + setUp(() { + signals = Signals.test(); + fakeSignal = FakeProcessSignal(); + signalUnderTest = ProcessSignal(fakeSignal); + }); + + testWithoutContext('signal handler runs', () async { + final Completer completer = Completer(); + signals.addHandler(signalUnderTest, (ProcessSignal s) { + expect(s, signalUnderTest); + completer.complete(); + }); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + }); + + testWithoutContext('signal handlers run in order', () async { + final Completer completer = Completer(); + + bool first = false; + + signals.addHandler(signalUnderTest, (ProcessSignal s) { + expect(s, signalUnderTest); + first = true; + }); + + signals.addHandler(signalUnderTest, (ProcessSignal s) { + expect(s, signalUnderTest); + expect(first, isTrue); + completer.complete(); + }); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + }); + + testWithoutContext( + 'signal handlers do not cause concurrent modification errors when removing handlers in a signal callback', + () async { + final Completer completer = Completer(); + late Object token; + Future handle(ProcessSignal s) async { + expect(s, signalUnderTest); + expect(await signals.removeHandler(signalUnderTest, token), true); + completer.complete(); + } + + token = signals.addHandler(signalUnderTest, handle); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + }); + + testWithoutContext('signal handler error goes on error stream', () async { + final Exception exn = Exception('Error'); + signals.addHandler(signalUnderTest, (ProcessSignal s) async { + throw exn; + }); + + final Completer completer = Completer(); + final List errList = []; + final StreamSubscription errSub = signals.errors.listen( + (Object err) { + errList.add(err); + completer.complete(); + }, + ); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + await errSub.cancel(); + expect(errList, contains(exn)); + }); + + testWithoutContext('removed signal handler does not run', () async { + final Object token = signals.addHandler( + signalUnderTest, + (ProcessSignal s) async { + fail('Signal handler should have been removed.'); + }, + ); + + await signals.removeHandler(signalUnderTest, token); + + final List errList = []; + final StreamSubscription errSub = signals.errors.listen( + (Object err) { + errList.add(err); + }, + ); + + fakeSignal.controller.add(fakeSignal); + + await errSub.cancel(); + expect(errList, isEmpty); + }); + + testWithoutContext('non-removed signal handler still runs', () async { + final Completer completer = Completer(); + signals.addHandler(signalUnderTest, (ProcessSignal s) { + expect(s, signalUnderTest); + completer.complete(); + }); + + final Object token = signals.addHandler( + signalUnderTest, + (ProcessSignal s) async { + fail('Signal handler should have been removed.'); + }, + ); + await signals.removeHandler(signalUnderTest, token); + + final List errList = []; + final StreamSubscription errSub = signals.errors.listen( + (Object err) { + errList.add(err); + }, + ); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + await errSub.cancel(); + expect(errList, isEmpty); + }); + + testWithoutContext('only handlers for the correct signal run', () async { + final FakeProcessSignal mockSignal2 = FakeProcessSignal(); + final ProcessSignal otherSignal = ProcessSignal(mockSignal2); + + final Completer completer = Completer(); + signals.addHandler(signalUnderTest, (ProcessSignal s) { + expect(s, signalUnderTest); + completer.complete(); + }); + + signals.addHandler(otherSignal, (ProcessSignal s) async { + fail('Wrong signal!.'); + }); + + final List errList = []; + final StreamSubscription errSub = signals.errors.listen( + (Object err) { + errList.add(err); + }, + ); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + await errSub.cancel(); + expect(errList, isEmpty); + }); + }); +} + +class FakeProcessSignal extends Fake implements io.ProcessSignal { + final StreamController controller = + StreamController(); + + @override + Stream watch() => controller.stream; +} diff --git a/packages/flutter_migrate/test/base/terminal_test.dart b/packages/flutter_migrate/test/base/terminal_test.dart new file mode 100644 index 0000000000..6686ec269b --- /dev/null +++ b/packages/flutter_migrate/test/base/terminal_test.dart @@ -0,0 +1,331 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:test/fake.dart'; + +import '../src/common.dart'; + +void main() { + group('output preferences', () { + testWithoutContext('can wrap output', () async { + final BufferLogger bufferLogger = BufferLogger( + outputPreferences: + OutputPreferences.test(wrapText: true, wrapColumn: 40), + terminal: TestTerminal(), + ); + bufferLogger.printStatus('0123456789' * 8); + + expect(bufferLogger.statusText, equals(('${'0123456789' * 4}\n') * 2)); + }); + + testWithoutContext('can turn off wrapping', () async { + final BufferLogger bufferLogger = BufferLogger( + outputPreferences: OutputPreferences.test(), + terminal: TestTerminal(), + ); + final String testString = '0123456789' * 20; + bufferLogger.printStatus(testString); + + expect(bufferLogger.statusText, equals('$testString\n')); + }); + }); + + group('ANSI coloring and bold', () { + late AnsiTerminal terminal; + + setUp(() { + terminal = AnsiTerminal( + stdio: Stdio(), // Danger, using real stdio. + supportsColor: true, + ); + }); + + testWithoutContext('adding colors works', () { + for (final TerminalColor color in TerminalColor.values) { + expect( + terminal.color('output', color), + equals( + '${AnsiTerminal.colorCode(color)}output${AnsiTerminal.resetColor}'), + ); + } + }); + + testWithoutContext('adding bold works', () { + expect( + terminal.bolden('output'), + equals('${AnsiTerminal.bold}output${AnsiTerminal.resetBold}'), + ); + }); + + testWithoutContext('nesting bold within color works', () { + expect( + terminal.color(terminal.bolden('output'), TerminalColor.blue), + equals( + '${AnsiTerminal.blue}${AnsiTerminal.bold}output${AnsiTerminal.resetBold}${AnsiTerminal.resetColor}'), + ); + expect( + terminal.color('non-bold ${terminal.bolden('output')} also non-bold', + TerminalColor.blue), + equals( + '${AnsiTerminal.blue}non-bold ${AnsiTerminal.bold}output${AnsiTerminal.resetBold} also non-bold${AnsiTerminal.resetColor}'), + ); + }); + + testWithoutContext('nesting color within bold works', () { + expect( + terminal.bolden(terminal.color('output', TerminalColor.blue)), + equals( + '${AnsiTerminal.bold}${AnsiTerminal.blue}output${AnsiTerminal.resetColor}${AnsiTerminal.resetBold}'), + ); + expect( + terminal.bolden( + 'non-color ${terminal.color('output', TerminalColor.blue)} also non-color'), + equals( + '${AnsiTerminal.bold}non-color ${AnsiTerminal.blue}output${AnsiTerminal.resetColor} also non-color${AnsiTerminal.resetBold}'), + ); + }); + + testWithoutContext('nesting color within color works', () { + expect( + terminal.color(terminal.color('output', TerminalColor.blue), + TerminalColor.magenta), + equals( + '${AnsiTerminal.magenta}${AnsiTerminal.blue}output${AnsiTerminal.resetColor}${AnsiTerminal.magenta}${AnsiTerminal.resetColor}'), + ); + expect( + terminal.color( + 'magenta ${terminal.color('output', TerminalColor.blue)} also magenta', + TerminalColor.magenta), + equals( + '${AnsiTerminal.magenta}magenta ${AnsiTerminal.blue}output${AnsiTerminal.resetColor}${AnsiTerminal.magenta} also magenta${AnsiTerminal.resetColor}'), + ); + }); + + testWithoutContext('nesting bold within bold works', () { + expect( + terminal.bolden(terminal.bolden('output')), + equals('${AnsiTerminal.bold}output${AnsiTerminal.resetBold}'), + ); + expect( + terminal.bolden('bold ${terminal.bolden('output')} still bold'), + equals( + '${AnsiTerminal.bold}bold output still bold${AnsiTerminal.resetBold}'), + ); + }); + }); + + group('character input prompt', () { + late AnsiTerminal terminalUnderTest; + + setUp(() { + terminalUnderTest = TestTerminal(stdio: FakeStdio()); + }); + + testWithoutContext('character prompt throws if usesTerminalUi is false', + () async { + expect( + terminalUnderTest.promptForCharInput( + ['a', 'b', 'c'], + prompt: 'Please choose something', + logger: BufferLogger.test(), + ), + throwsStateError); + }); + + testWithoutContext('character prompt', () async { + final BufferLogger bufferLogger = BufferLogger( + terminal: terminalUnderTest, + outputPreferences: OutputPreferences.test(), + ); + terminalUnderTest.usesTerminalUi = true; + mockStdInStream = Stream.fromFutures(>[ + Future.value('d'), // Not in accepted list. + Future.value('\n'), // Not in accepted list + Future.value('b'), + ]).asBroadcastStream(); + final String choice = await terminalUnderTest.promptForCharInput( + ['a', 'b', 'c'], + prompt: 'Please choose something', + logger: bufferLogger, + ); + expect(choice, 'b'); + expect( + bufferLogger.statusText, + 'Please choose something [a|b|c]: d\n' + 'Please choose something [a|b|c]: \n' + 'Please choose something [a|b|c]: b\n'); + }); + + testWithoutContext( + 'default character choice without displayAcceptedCharacters', () async { + final BufferLogger bufferLogger = BufferLogger( + terminal: terminalUnderTest, + outputPreferences: OutputPreferences.test(), + ); + terminalUnderTest.usesTerminalUi = true; + mockStdInStream = Stream.fromFutures(>[ + Future.value('\n'), // Not in accepted list + ]).asBroadcastStream(); + final String choice = await terminalUnderTest.promptForCharInput( + ['a', 'b', 'c'], + prompt: 'Please choose something', + displayAcceptedCharacters: false, + defaultChoiceIndex: 1, // which is b. + logger: bufferLogger, + ); + + expect(choice, 'b'); + expect(bufferLogger.statusText, 'Please choose something: \n'); + }); + + testWithoutContext( + 'Does not set single char mode when a terminal is not attached', () { + final Stdio stdio = FakeStdio()..stdinHasTerminal = false; + final AnsiTerminal ansiTerminal = AnsiTerminal( + stdio: stdio, + ); + + expect(() => ansiTerminal.singleCharMode = true, returnsNormally); + }); + }); + + testWithoutContext('AnsiTerminal.preferredStyle', () { + final Stdio stdio = FakeStdio(); + expect(AnsiTerminal(stdio: stdio).preferredStyle, + 0); // Defaults to 0 for backwards compatibility. + + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018)).preferredStyle, 0); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 2)).preferredStyle, + 1); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 3)).preferredStyle, + 2); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 4)).preferredStyle, + 3); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 5)).preferredStyle, + 4); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 6)).preferredStyle, + 5); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 7)).preferredStyle, + 5); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 8)).preferredStyle, + 0); + expect(AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 9)).preferredStyle, + 1); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 10)).preferredStyle, + 2); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 11)).preferredStyle, + 3); + + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 1, 1)).preferredStyle, + 0); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 2, 1)).preferredStyle, + 1); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 3, 1)).preferredStyle, + 2); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 4, 1)).preferredStyle, + 3); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 5, 1)).preferredStyle, + 4); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 6, 1)).preferredStyle, + 6); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 7, 1)).preferredStyle, + 6); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 8, 1)).preferredStyle, + 0); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 9, 1)).preferredStyle, + 1); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 10, 1)) + .preferredStyle, + 2); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 11, 1)) + .preferredStyle, + 3); + + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 1, 23)) + .preferredStyle, + 0); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 2, 23)) + .preferredStyle, + 1); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 3, 23)) + .preferredStyle, + 2); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 4, 23)) + .preferredStyle, + 3); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 5, 23)) + .preferredStyle, + 4); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 6, 23)) + .preferredStyle, + 28); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 7, 23)) + .preferredStyle, + 28); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 8, 23)) + .preferredStyle, + 0); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 9, 23)) + .preferredStyle, + 1); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 10, 23)) + .preferredStyle, + 2); + expect( + AnsiTerminal(stdio: stdio, now: DateTime(2018, 1, 11, 23)) + .preferredStyle, + 3); + }); +} + +late Stream mockStdInStream; + +class TestTerminal extends AnsiTerminal { + TestTerminal({ + Stdio? stdio, + DateTime? now, + }) : super(stdio: stdio ?? Stdio(), now: now ?? DateTime(2018)); + + @override + Stream get keystrokes { + return mockStdInStream; + } + + @override + bool singleCharMode = false; + + @override + int get preferredStyle => 0; +} + +class FakeStdio extends Fake implements Stdio { + @override + bool stdinHasTerminal = false; +} diff --git a/packages/flutter_migrate/test/src/common.dart b/packages/flutter_migrate/test/src/common.dart new file mode 100644 index 0000000000..40e55b0b6c --- /dev/null +++ b/packages/flutter_migrate/test/src/common.dart @@ -0,0 +1,200 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io' as io; + +import 'package:flutter_migrate/src/base/context.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; // flutter_ignore: package_path_import +import 'package:test_api/test_api.dart' // ignore: deprecated_member_use + as test_package show test; +import 'package:test_api/test_api.dart' // ignore: deprecated_member_use + hide + test; + +import 'test_utils.dart'; + +export 'package:test_api/test_api.dart' // ignore: deprecated_member_use + hide + isInstanceOf, + test; + +void tryToDelete(FileSystemEntity fileEntity) { + // This should not be necessary, but it turns out that + // on Windows it's common for deletions to fail due to + // bogus (we think) "access denied" errors. + try { + if (fileEntity.existsSync()) { + fileEntity.deleteSync(recursive: true); + } + } on FileSystemException catch (error) { + // We print this so that it's visible in the logs, to get an idea of how + // common this problem is, and if any patterns are ever noticed by anyone. + // ignore: avoid_print + print('Failed to delete ${fileEntity.path}: $error'); + } +} + +/// Gets the path to the root of the Flutter repository. +/// +/// This will first look for a `FLUTTER_ROOT` environment variable. If the +/// environment variable is set, it will be returned. Otherwise, this will +/// deduce the path from `platform.script`. +String getFlutterRoot() { + if (io.Platform.environment.containsKey('FLUTTER_ROOT')) { + return io.Platform.environment['FLUTTER_ROOT']!; + } + + Error invalidScript() => StateError( + 'Could not determine flutter_tools/ path from script URL (${io.Platform.script}); consider setting FLUTTER_ROOT explicitly.'); + + Uri scriptUri; + switch (io.Platform.script.scheme) { + case 'file': + scriptUri = io.Platform.script; + break; + case 'data': + final RegExp flutterTools = RegExp( + r'(file://[^"]*[/\\]flutter_tools[/\\][^"]+\.dart)', + multiLine: true); + final Match? match = + flutterTools.firstMatch(Uri.decodeFull(io.Platform.script.path)); + if (match == null) { + throw invalidScript(); + } + scriptUri = Uri.parse(match.group(1)!); + break; + default: + throw invalidScript(); + } + + final List parts = path.split(fileSystem.path.fromUri(scriptUri)); + final int toolsIndex = parts.indexOf('flutter_tools'); + if (toolsIndex == -1) { + throw invalidScript(); + } + final String toolsPath = path.joinAll(parts.sublist(0, toolsIndex + 1)); + return path.normalize(path.join(toolsPath, '..', '..')); +} + +String getMigratePackageRoot() { + return io.Directory.current.path; +} + +String getMigrateMain() { + return fileSystem.path + .join(getMigratePackageRoot(), 'bin', 'flutter_migrate.dart'); +} + +Future runMigrateCommand(List args, + {String? workingDirectory}) { + final List commandArgs = ['dart', 'run', getMigrateMain()]; + commandArgs.addAll(args); + return processManager.run(commandArgs, workingDirectory: workingDirectory); +} + +/// The tool overrides `test` to ensure that files created under the +/// system temporary directory are deleted after each test by calling +/// `LocalFileSystem.dispose()`. +@isTest +void test( + String description, + FutureOr Function() body, { + String? testOn, + dynamic skip, + List? tags, + Map? onPlatform, + int? retry, +}) { + test_package.test( + description, + () async { + addTearDown(() async { + await fileSystem.dispose(); + }); + + return body(); + }, + skip: skip, + tags: tags, + onPlatform: onPlatform, + retry: retry, + testOn: testOn, + // We don't support "timeout"; see ../../dart_test.yaml which + // configures all tests to have a 15 minute timeout which should + // definitely be enough. + ); +} + +/// Executes a test body in zone that does not allow context-based injection. +/// +/// For classes which have been refactored to exclude context-based injection +/// or globals like [fs] or [platform], prefer using this test method as it +/// will prevent accidentally including these context getters in future code +/// changes. +/// +/// For more information, see https://github.com/flutter/flutter/issues/47161 +@isTest +void testWithoutContext( + String description, + FutureOr Function() body, { + String? testOn, + dynamic skip, + List? tags, + Map? onPlatform, + int? retry, +}) { + return test( + description, + () async { + return runZoned(body, zoneValues: { + contextKey: const _NoContext(), + }); + }, + skip: skip, + tags: tags, + onPlatform: onPlatform, + retry: retry, + testOn: testOn, + // We don't support "timeout"; see ../../dart_test.yaml which + // configures all tests to have a 15 minute timeout which should + // definitely be enough. + ); +} + +/// An implementation of [AppContext] that throws if context.get is called in the test. +/// +/// The intention of the class is to ensure we do not accidentally regress when +/// moving towards more explicit dependency injection by accidentally using +/// a Zone value in place of a constructor parameter. +class _NoContext implements AppContext { + const _NoContext(); + + @override + T get() { + throw UnsupportedError('context.get<$T> is not supported in test methods. ' + 'Use Testbed or testUsingContext if accessing Zone injected ' + 'values.'); + } + + @override + String get name => 'No Context'; + + @override + Future run({ + required FutureOr Function() body, + String? name, + Map? overrides, + Map? fallbacks, + ZoneSpecification? zoneSpecification, + }) async { + return body(); + } +} + +/// Matcher for functions that throw [AssertionError]. +final Matcher throwsAssertionError = throwsA(isA()); diff --git a/packages/flutter_migrate/test/src/context.dart b/packages/flutter_migrate/test/src/context.dart new file mode 100644 index 0000000000..4f43b48127 --- /dev/null +++ b/packages/flutter_migrate/test/src/context.dart @@ -0,0 +1,125 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter_migrate/src/base/context.dart'; +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:flutter_migrate/src/base/terminal.dart'; +import 'package:meta/meta.dart'; +import 'package:process/process.dart'; + +import 'common.dart'; +import 'fakes.dart'; + +/// Return the test logger. This assumes that the current Logger is a BufferLogger. +BufferLogger get testLogger => context.get()! as BufferLogger; + +@isTest +void testUsingContext( + String description, + dynamic Function() testMethod, { + Map overrides = const {}, + bool initializeFlutterRoot = true, + String? testOn, + bool? + skip, // should default to `false`, but https://github.com/dart-lang/test/issues/545 doesn't allow this +}) { + if (overrides[FileSystem] != null && overrides[ProcessManager] == null) { + throw StateError( + 'If you override the FileSystem context you must also provide a ProcessManager, ' + 'otherwise the processes you launch will not be dealing with the same file system ' + 'that you are dealing with in your test.'); + } + + // Ensure we don't rely on the default [Config] constructor which will + // leak a sticky $HOME/.flutter_settings behind! + Directory? configDir; + tearDown(() { + if (configDir != null) { + tryToDelete(configDir!); + configDir = null; + } + }); + + test(description, () async { + await runInContext(() { + return context.run( + name: 'mocks', + overrides: { + AnsiTerminal: () => AnsiTerminal(stdio: FakeStdio()), + OutputPreferences: () => OutputPreferences.test(), + Logger: () => BufferLogger.test(), + ProcessManager: () => const LocalProcessManager(), + }, + body: () { + return runZonedGuarded>(() { + try { + return context.run( + // Apply the overrides to the test context in the zone since their + // instantiation may reference items already stored on the context. + overrides: overrides, + name: 'test-specific overrides', + body: () async { + if (initializeFlutterRoot) { + // Provide a sane default for the flutterRoot directory. Individual + // tests can override this either in the test or during setup. + // Cache.flutterRoot ??= flutterRoot; + } + return await testMethod(); + }, + ); + // This catch rethrows, so doesn't need to catch only Exception. + } catch (error) { + // ignore: avoid_catches_without_on_clauses + _printBufferedErrors(context); + rethrow; + } + }, (Object error, StackTrace stackTrace) { + // When things fail, it's ok to print to the console! + print(error); // ignore: avoid_print + print(stackTrace); // ignore: avoid_print + _printBufferedErrors(context); + throw error; //ignore: only_throw_errors + }); + }, + ); + }, overrides: {}); + }, testOn: testOn, skip: skip); + // We don't support "timeout"; see ../../dart_test.yaml which + // configures all tests to have a 15 minute timeout which should + // definitely be enough. +} + +void _printBufferedErrors(AppContext testContext) { + if (testContext.get() is BufferLogger) { + final BufferLogger bufferLogger = + testContext.get()! as BufferLogger; + if (bufferLogger.errorText.isNotEmpty) { + // This is where the logger outputting errors is implemented, so it has + // to use `print`. + print(bufferLogger.errorText); // ignore: avoid_print + } + bufferLogger.clear(); + } +} + +Future runInContext( + FutureOr Function() runner, { + Map? overrides, +}) async { + // Wrap runner with any asynchronous initialization that should run with the + // overrides and callbacks. + // late bool runningOnBot; + FutureOr runnerWrapper() async { + return runner(); + } + + return context.run( + name: 'global fallbacks', + body: runnerWrapper, + overrides: overrides, + fallbacks: {}); +} diff --git a/packages/flutter_migrate/test/src/fakes.dart b/packages/flutter_migrate/test/src/fakes.dart new file mode 100644 index 0000000000..3e6b072fac --- /dev/null +++ b/packages/flutter_migrate/test/src/fakes.dart @@ -0,0 +1,285 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io show IOSink, Stdout, StdoutException; + +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/logger.dart'; +import 'package:test/fake.dart'; + +/// An IOSink that completes a future with the first line written to it. +class CompleterIOSink extends MemoryIOSink { + CompleterIOSink({ + this.throwOnAdd = false, + }); + + final bool throwOnAdd; + + final Completer> _completer = Completer>(); + + Future> get future => _completer.future; + + @override + void add(List data) { + if (!_completer.isCompleted) { + // When throwOnAdd is true, complete with empty so any expected output + // doesn't appear. + _completer.complete(throwOnAdd ? [] : data); + } + if (throwOnAdd) { + throw Exception('CompleterIOSink Error'); + } + super.add(data); + } +} + +/// An IOSink that collects whatever is written to it. +class MemoryIOSink implements IOSink { + @override + Encoding encoding = utf8; + + final List> writes = >[]; + + @override + void add(List data) { + writes.add(data); + } + + @override + Future addStream(Stream> stream) { + final Completer completer = Completer(); + late StreamSubscription> sub; + sub = stream.listen( + (List data) { + try { + add(data); + // Catches all exceptions to propagate them to the completer. + } catch (err, stack) { + // ignore: avoid_catches_without_on_clauses + sub.cancel(); + completer.completeError(err, stack); + } + }, + onError: completer.completeError, + onDone: completer.complete, + cancelOnError: true, + ); + return completer.future; + } + + @override + void writeCharCode(int charCode) { + add([charCode]); + } + + @override + void write(Object? obj) { + add(encoding.encode('$obj')); + } + + @override + void writeln([Object? obj = '']) { + add(encoding.encode('$obj\n')); + } + + @override + void writeAll(Iterable objects, [String separator = '']) { + bool addSeparator = false; + for (final dynamic object in objects) { + if (addSeparator) { + write(separator); + } + write(object); + addSeparator = true; + } + } + + @override + void addError(dynamic error, [StackTrace? stackTrace]) { + throw UnimplementedError(); + } + + @override + Future get done => close(); + + @override + Future close() async {} + + @override + Future flush() async {} + + void clear() { + writes.clear(); + } + + String getAndClear() { + final String result = + utf8.decode(writes.expand((List l) => l).toList()); + clear(); + return result; + } +} + +class MemoryStdout extends MemoryIOSink implements io.Stdout { + @override + bool get hasTerminal => _hasTerminal; + set hasTerminal(bool value) { + assert(value != null); + _hasTerminal = value; + } + + bool _hasTerminal = true; + + @override + io.IOSink get nonBlocking => this; + + @override + bool get supportsAnsiEscapes => _supportsAnsiEscapes; + set supportsAnsiEscapes(bool value) { + assert(value != null); + _supportsAnsiEscapes = value; + } + + bool _supportsAnsiEscapes = true; + + @override + int get terminalColumns { + if (_terminalColumns != null) { + return _terminalColumns!; + } + throw const io.StdoutException('unspecified mock value'); + } + + set terminalColumns(int value) => _terminalColumns = value; + int? _terminalColumns; + + @override + int get terminalLines { + if (_terminalLines != null) { + return _terminalLines!; + } + throw const io.StdoutException('unspecified mock value'); + } + + set terminalLines(int value) => _terminalLines = value; + int? _terminalLines; +} + +/// A Stdio that collects stdout and supports simulated stdin. +class FakeStdio extends Stdio { + final MemoryStdout _stdout = MemoryStdout()..terminalColumns = 80; + final MemoryIOSink _stderr = MemoryIOSink(); + final FakeStdin _stdin = FakeStdin(); + + @override + MemoryStdout get stdout => _stdout; + + @override + MemoryIOSink get stderr => _stderr; + + @override + Stream> get stdin => _stdin; + + void simulateStdin(String line) { + _stdin.controller.add(utf8.encode('$line\n')); + } + + @override + bool hasTerminal = true; + + List get writtenToStdout => + _stdout.writes.map(_stdout.encoding.decode).toList(); + List get writtenToStderr => + _stderr.writes.map(_stderr.encoding.decode).toList(); +} + +class FakeStdin extends Fake implements Stdin { + final StreamController> controller = StreamController>(); + + @override + bool echoMode = true; + + @override + bool hasTerminal = true; + + @override + bool echoNewlineMode = true; + + @override + bool lineMode = true; + + @override + Stream transform(StreamTransformer, S> transformer) { + return controller.stream.transform(transformer); + } + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return controller.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } +} + +class FakeStopwatch implements Stopwatch { + @override + bool get isRunning => _isRunning; + bool _isRunning = false; + + @override + void start() => _isRunning = true; + + @override + void stop() => _isRunning = false; + + @override + Duration elapsed = Duration.zero; + + @override + int get elapsedMicroseconds => elapsed.inMicroseconds; + + @override + int get elapsedMilliseconds => elapsed.inMilliseconds; + + @override + int get elapsedTicks => elapsed.inMilliseconds; + + @override + int get frequency => 1000; + + @override + void reset() { + _isRunning = false; + elapsed = Duration.zero; + } + + @override + String toString() => '$runtimeType $elapsed $isRunning'; +} + +class FakeStopwatchFactory implements StopwatchFactory { + FakeStopwatchFactory( + {Stopwatch? stopwatch, Map? stopwatches}) + : stopwatches = { + if (stopwatches != null) ...stopwatches, + if (stopwatch != null) '': stopwatch, + }; + + Map stopwatches; + + @override + Stopwatch createStopwatch([String name = '']) { + return stopwatches[name] ?? FakeStopwatch(); + } +} diff --git a/packages/flutter_migrate/test/src/io.dart b/packages/flutter_migrate/test/src/io.dart new file mode 100644 index 0000000000..c7e6bf3ec9 --- /dev/null +++ b/packages/flutter_migrate/test/src/io.dart @@ -0,0 +1,138 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io show Directory, File, IOOverrides, Link; + +import 'package:flutter_migrate/src/base/file_system.dart'; + +/// An [IOOverrides] that can delegate to [FileSystem] implementation if provided. +/// +/// Does not override any of the socket facilities. +/// +/// Do not provide a [LocalFileSystem] as a delegate. Since internally this calls +/// out to `dart:io` classes, it will result in a stack overflow error as the +/// IOOverrides and LocalFileSystem call each other endlessly. +/// +/// The only safe delegate types are those that do not call out to `dart:io`, +/// like the [MemoryFileSystem]. +class FlutterIOOverrides extends io.IOOverrides { + FlutterIOOverrides({FileSystem? fileSystem}) + : _fileSystemDelegate = fileSystem; + + final FileSystem? _fileSystemDelegate; + + @override + io.Directory createDirectory(String path) { + if (_fileSystemDelegate == null) { + return super.createDirectory(path); + } + return _fileSystemDelegate!.directory(path); + } + + @override + io.File createFile(String path) { + if (_fileSystemDelegate == null) { + return super.createFile(path); + } + return _fileSystemDelegate!.file(path); + } + + @override + io.Link createLink(String path) { + if (_fileSystemDelegate == null) { + return super.createLink(path); + } + return _fileSystemDelegate!.link(path); + } + + @override + Stream fsWatch(String path, int events, bool recursive) { + if (_fileSystemDelegate == null) { + return super.fsWatch(path, events, recursive); + } + return _fileSystemDelegate! + .file(path) + .watch(events: events, recursive: recursive); + } + + @override + bool fsWatchIsSupported() { + if (_fileSystemDelegate == null) { + return super.fsWatchIsSupported(); + } + return _fileSystemDelegate!.isWatchSupported; + } + + @override + Future fseGetType(String path, bool followLinks) { + if (_fileSystemDelegate == null) { + return super.fseGetType(path, followLinks); + } + return _fileSystemDelegate!.type(path, followLinks: followLinks); + } + + @override + FileSystemEntityType fseGetTypeSync(String path, bool followLinks) { + if (_fileSystemDelegate == null) { + return super.fseGetTypeSync(path, followLinks); + } + return _fileSystemDelegate!.typeSync(path, followLinks: followLinks); + } + + @override + Future fseIdentical(String path1, String path2) { + if (_fileSystemDelegate == null) { + return super.fseIdentical(path1, path2); + } + return _fileSystemDelegate!.identical(path1, path2); + } + + @override + bool fseIdenticalSync(String path1, String path2) { + if (_fileSystemDelegate == null) { + return super.fseIdenticalSync(path1, path2); + } + return _fileSystemDelegate!.identicalSync(path1, path2); + } + + @override + io.Directory getCurrentDirectory() { + if (_fileSystemDelegate == null) { + return super.getCurrentDirectory(); + } + return _fileSystemDelegate!.currentDirectory; + } + + @override + io.Directory getSystemTempDirectory() { + if (_fileSystemDelegate == null) { + return super.getSystemTempDirectory(); + } + return _fileSystemDelegate!.systemTempDirectory; + } + + @override + void setCurrentDirectory(String path) { + if (_fileSystemDelegate == null) { + return super.setCurrentDirectory(path); + } + _fileSystemDelegate!.currentDirectory = path; + } + + @override + Future stat(String path) { + if (_fileSystemDelegate == null) { + return super.stat(path); + } + return _fileSystemDelegate!.stat(path); + } + + @override + FileStat statSync(String path) { + if (_fileSystemDelegate == null) { + return super.statSync(path); + } + return _fileSystemDelegate!.statSync(path); + } +} diff --git a/packages/flutter_migrate/test/src/test_flutter_command_runner.dart b/packages/flutter_migrate/test/src/test_flutter_command_runner.dart new file mode 100644 index 0000000000..8e4d36ba41 --- /dev/null +++ b/packages/flutter_migrate/test/src/test_flutter_command_runner.dart @@ -0,0 +1,40 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:args/command_runner.dart'; +import 'package:flutter_migrate/src/base/command.dart'; + +export 'package:test_api/test_api.dart' // ignore: deprecated_member_use + hide + isInstanceOf, + test; + +CommandRunner createTestCommandRunner([MigrateCommand? command]) { + final CommandRunner runner = TestCommandRunner(); + if (command != null) { + runner.addCommand(command); + } + return runner; +} + +class TestCommandRunner extends CommandRunner { + TestCommandRunner() + : super( + 'flutter', + 'Manage your Flutter app development.\n' + '\n' + 'Common commands:\n' + '\n' + ' flutter create \n' + ' Create a new Flutter project in the specified directory.\n' + '\n' + ' flutter run [options]\n' + ' Run your Flutter application on an attached device or in an emulator.', + ); + + @override + Future run(Iterable args) { + return super.run(args); + } +} diff --git a/packages/flutter_migrate/test/src/test_utils.dart b/packages/flutter_migrate/test/src/test_utils.dart new file mode 100644 index 0000000000..b51e127a91 --- /dev/null +++ b/packages/flutter_migrate/test/src/test_utils.dart @@ -0,0 +1,62 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter_migrate/src/base/file_system.dart'; +import 'package:flutter_migrate/src/base/io.dart'; +import 'package:flutter_migrate/src/base/signals.dart'; +import 'package:process/process.dart'; + +import 'common.dart'; + +/// The [FileSystem] for the integration test environment. +LocalFileSystem fileSystem = + LocalFileSystem.test(signals: LocalSignals.instance); + +/// The [ProcessManager] for the integration test environment. +const ProcessManager processManager = LocalProcessManager(); + +/// Creates a temporary directory but resolves any symlinks to return the real +/// underlying path to avoid issues with breakpoints/hot reload. +/// https://github.com/flutter/flutter/pull/21741 +Directory createResolvedTempDirectorySync(String prefix) { + assert(prefix.endsWith('.')); + final Directory tempDirectory = + fileSystem.systemTempDirectory.createTempSync('flutter_$prefix'); + return fileSystem.directory(tempDirectory.resolveSymbolicLinksSync()); +} + +void writeFile(String path, String content, + {bool writeFutureModifiedDate = false}) { + final File file = fileSystem.file(path) + ..createSync(recursive: true) + ..writeAsStringSync(content, flush: true); + // Some integration tests on Windows to not see this file as being modified + // recently enough for the hot reload to pick this change up unless the + // modified time is written in the future. + if (writeFutureModifiedDate) { + file.setLastModifiedSync(DateTime.now().add(const Duration(seconds: 5))); + } +} + +void writePackages(String folder) { + writeFile(fileSystem.path.join(folder, '.packages'), ''' +test:${fileSystem.path.join(fileSystem.currentDirectory.path, 'lib')}/ +'''); +} + +Future getPackages(String folder) async { + final List command = [ + fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'), + 'pub', + 'get', + ]; + final ProcessResult result = + await processManager.run(command, workingDirectory: folder); + if (result.exitCode != 0) { + throw Exception( + 'flutter pub get failed: ${result.stderr}\n${result.stdout}'); + } +} diff --git a/script/configs/custom_analysis.yaml b/script/configs/custom_analysis.yaml index 1ad0f641cc..a281f3c4cf 100644 --- a/script/configs/custom_analysis.yaml +++ b/script/configs/custom_analysis.yaml @@ -8,10 +8,13 @@ # Deliberately uses flutter_lints, as that's what it is demonstrating. - flutter_lints/example +# Adopts some flutter_tools rules regarding public api docs due to being an +# extension of the tool and using tools base code. +- flutter_migrate # Adds unawaited_futures. We should investigating adding this to the root # options instead. - metrics_center # Has some constructions that are currently handled poorly by dart format. - rfw/example # Disables docs requirements, as it is test code. -- web_benchmarks/testing/test_app +- web_benchmarks/testing/test_app \ No newline at end of file