[flutter_migrate] base boilerplate files (#2694)
diff --git a/packages/flutter_migrate/analysis_options.yaml b/packages/flutter_migrate/analysis_options.yaml
new file mode 100644
index 0000000..9d4b822
--- /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 0000000..18604d7
--- /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<void> {
+ @override
+ Future<void> run() async {
+ await runCommand();
+ }
+
+ Future<CommandResult> 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 0000000..70804f5
--- /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<T> Function(Object error)` or
+/// `FutureOr<T> Function(Object error, StackTrace stackTrace)` otherwise an
+/// [ArgumentError] will be thrown synchronously.
+Future<T> asyncGuard<T>(
+ Future<T> Function() fn, {
+ Function? onError,
+}) {
+ if (onError != null &&
+ onError is! _UnaryOnError<T> &&
+ onError is! _BinaryOnError<T>) {
+ 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<T> completer = Completer<T>();
+
+ void handleError(Object e, StackTrace s) {
+ if (completer.isCompleted) {
+ return;
+ }
+ if (onError == null) {
+ completer.completeError(e, s);
+ return;
+ }
+ if (onError is _BinaryOnError<T>) {
+ completer.complete(onError(e, s));
+ } else if (onError is _UnaryOnError<T>) {
+ completer.complete(onError(e));
+ }
+ }
+
+ runZoned<void>(() 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<T> = FutureOr<T> Function(Object error);
+typedef _BinaryOnError<T> = FutureOr<T> 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 0000000..ea1dd4d
--- /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<Type> 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 <Type, Generator>{},
+ this._fallbacks = const <Type, Generator>{},
+ ]);
+
+ final String? name;
+ final AppContext? _parent;
+ final Map<Type, Generator> _overrides;
+ final Map<Type, Generator> _fallbacks;
+ final Map<Type, dynamic> _values = <Type, dynamic>{};
+
+ List<Type>? _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<Type, Generator> generators) {
+ if (!generators.containsKey(type)) {
+ return null;
+ }
+
+ return _values.putIfAbsent(type, () {
+ _reentrantChecks ??= <Type>[];
+
+ final int index = _reentrantChecks!.indexOf(type);
+ if (index >= 0) {
+ // We're already in the process of trying to generate this type.
+ throw ContextDependencyCycleException._(
+ UnmodifiableListView<Type>(_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<T>() {
+ dynamic value = _generateIfNecessary(T, _overrides);
+ if (value == null && _parent != null) {
+ value = _parent!.get<T>();
+ }
+ 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<V> run<V>({
+ required FutureOr<V> Function() body,
+ String? name,
+ Map<Type, Generator>? overrides,
+ Map<Type, Generator>? fallbacks,
+ ZoneSpecification? zoneSpecification,
+ }) async {
+ final AppContext child = AppContext._(
+ this,
+ name,
+ Map<Type, Generator>.unmodifiable(overrides ?? const <Type, Generator>{}),
+ Map<Type, Generator>.unmodifiable(fallbacks ?? const <Type, Generator>{}),
+ );
+ return runZoned<Future<V>>(
+ () 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 0000000..ee5eaf8
--- /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<ProcessSignal> fatalSignals = Signals.defaultExitSignals,
+ }) : this(signals, fatalSignals, ShutdownHooks());
+
+ Directory? _systemTemp;
+ final Map<ProcessSignal, Object> _signalTokens = <ProcessSignal, Object>{};
+
+ final ShutdownHooks shutdownHooks;
+
+ Future<void> dispose() async {
+ _tryToDeleteTemp();
+ for (final MapEntry<ProcessSignal, Object> signalToken
+ in _signalTokens.entries) {
+ await _signals.removeHandler(signalToken.key, signalToken.value);
+ }
+ _signalTokens.clear();
+ }
+
+ final Signals _signals;
+ final List<ProcessSignal> _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<void> Function();
+
+abstract class ShutdownHooks {
+ factory ShutdownHooks() => _DefaultShutdownHooks();
+
+ /// Registers a [ShutdownHook] to be executed before the VM exits.
+ void addShutdownHook(ShutdownHook shutdownHook);
+
+ @visibleForTesting
+ List<ShutdownHook> 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<void> runShutdownHooks(Logger logger);
+}
+
+class _DefaultShutdownHooks implements ShutdownHooks {
+ _DefaultShutdownHooks();
+
+ @override
+ final List<ShutdownHook> registeredHooks = <ShutdownHook>[];
+
+ bool _shutdownHooksRunning = false;
+
+ @override
+ void addShutdownHook(ShutdownHook shutdownHook) {
+ assert(!_shutdownHooksRunning);
+ registeredHooks.add(shutdownHook);
+ }
+
+ @override
+ Future<void> runShutdownHooks(Logger logger) async {
+ logger.printTrace(
+ 'Running ${registeredHooks.length} shutdown hook${registeredHooks.length == 1 ? '' : 's'}',
+ );
+ _shutdownHooksRunning = true;
+ try {
+ final List<Future<dynamic>> futures = <Future<dynamic>>[];
+ for (final ShutdownHook shutdownHook in registeredHooks) {
+ final FutureOr<dynamic> result = shutdownHook();
+ if (result is Future<dynamic>) {
+ futures.add(result);
+ }
+ }
+ await Future.wait<dynamic>(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 0000000..8ae2813
--- /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<List<int>> 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<void>(() async {
+ sink.write(message);
+ }, onError: (Object error, StackTrace stackTrace) {
+ if (fallback == null) {
+ print(message);
+ } else {
+ fallback(message, error, stackTrace);
+ }
+ });
+ }
+
+ /// Adds [stream] to [stdout].
+ Future<void> addStdoutStream(Stream<List<int>> stream) =>
+ stdout.addStream(stream);
+
+ /// Adds [stream] to [stderr].
+ Future<void> addStderrStream(Stream<List<int>> 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<ProcessSignal> watch() {
+ return _delegate
+ .watch()
+ .map<ProcessSignal>((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<ProcessSignal> watch() {
+ // This uses the real platform since it invokes dart:io functionality directly.
+ if (isWindows) {
+ return const Stream<ProcessSignal>.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 0000000..c84f098
--- /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<String> lines =
+ wrapText(message, shouldWrap: true, columnWidth: maxTextWidthPerLine)
+ .split('\n');
+ final List<int> 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<String> _emojiAnimations = <String>[
+ '⣾⣽⣻⢿⡿⣟⣯⣷', // 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<String> _asciiAnimations = <String>[
+ r'-\|/',
+ ];
+
+ static List<String> _selectAnimation(Terminal terminal) {
+ final List<String> animations =
+ terminal.supportsEmoji ? _emojiAnimations : _asciiAnimations;
+ return animations[terminal.preferredStyle % animations.length]
+ .runes
+ .map<String>((int scalar) => String.fromCharCode(scalar))
+ .toList();
+ }
+
+ final List<String> _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 <subcommand>
+/// [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<String> splitText = text.split('\n');
+ final List<String> result = <String>[];
+ for (final String line in splitText) {
+ String trimmedText = line.trimLeft();
+ final String leadingWhitespace =
+ line.substring(0, line.length - trimmedText.length);
+ List<String> 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<String> firstLineWrap = _wrapTextAsLines(
+ trimmedText,
+ columnWidth: columnWidth - leadingWhitespace.length - indent,
+ shouldWrap: shouldWrap,
+ );
+ notIndented = <String>[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>(
+ (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<String> _wrapTextAsLines(
+ String text, {
+ int start = 0,
+ required int columnWidth,
+ required bool shouldWrap,
+}) {
+ if (text == null || text.isEmpty) {
+ return <String>[''];
+ }
+ 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<String>((_AnsiRun run) => run.original)
+ .join()
+ .trim();
+ }
+
+ final List<String> result = <String>[];
+ 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 0000000..a8924d7
--- /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<String, FlutterProject> projects = <String, FlutterProject>{};
+
+ /// 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<SupportedPlatform> getSupportedPlatforms({bool includeRoot = false}) {
+ final List<SupportedPlatform> platforms = includeRoot
+ ? <SupportedPlatform>[SupportedPlatform.root]
+ : <SupportedPlatform>[];
+ 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 0000000..265f00f
--- /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<void> 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<ProcessSignal> exitSignals = defaultExitSignals,
+ }) =>
+ LocalSignals._(exitSignals);
+
+ // The default list of signals that should cause the process to exit.
+ static const List<ProcessSignal> defaultExitSignals = <ProcessSignal>[
+ 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<bool> 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<Object> 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<ProcessSignal> exitSignals;
+
+ // A table mapping (signal, token) -> signal handler.
+ final Map<ProcessSignal, Map<Object, SignalHandler>> _handlersTable =
+ <ProcessSignal, Map<Object, SignalHandler>>{};
+
+ // A table mapping (signal) -> signal handler list. The list is in the order
+ // that the signal handlers should be run.
+ final Map<ProcessSignal, List<SignalHandler>> _handlersList =
+ <ProcessSignal, List<SignalHandler>>{};
+
+ // A table mapping (signal) -> low-level signal event stream.
+ final Map<ProcessSignal, StreamSubscription<ProcessSignal>>
+ _streamSubscriptions =
+ <ProcessSignal, StreamSubscription<ProcessSignal>>{};
+
+ // The stream controller for errors coming from signal handlers.
+ final StreamController<Object> _errorStreamController =
+ StreamController<Object>.broadcast();
+
+ @override
+ Stream<Object> get errors => _errorStreamController.stream;
+
+ @override
+ Object addHandler(ProcessSignal signal, SignalHandler handler) {
+ final Object token = Object();
+ _handlersTable.putIfAbsent(signal, () => <Object, SignalHandler>{});
+ _handlersTable[signal]![token] = handler;
+
+ _handlersList.putIfAbsent(signal, () => <SignalHandler>[]);
+ _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<bool> 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<void> _handleSignal(ProcessSignal s) async {
+ final List<SignalHandler>? handlers = _handlersList[s];
+ if (handlers != null) {
+ final List<SignalHandler> handlersCopy = handlers.toList();
+ for (final SignalHandler handler in handlersCopy) {
+ try {
+ await asyncGuard<void>(() 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 0000000..07976aa
--- /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<String> 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<String> promptForCharInput(
+ List<String> 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<TerminalColor, String> _colorMap = <TerminalColor, String>{
+ 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<String>? _broadcastStdInString;
+
+ @override
+ Stream<String> get keystrokes {
+ return _broadcastStdInString ??= _stdio.stdin
+ .transform<String>(const AsciiDecoder(allowInvalid: true))
+ .asBroadcastStream();
+ }
+
+ @override
+ Future<String> promptForCharInput(
+ List<String> 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<String> charactersToDisplay = acceptedCharacters;
+ if (defaultChoiceIndex != null) {
+ assert(defaultChoiceIndex >= 0 &&
+ defaultChoiceIndex < acceptedCharacters.length);
+ charactersToDisplay = List<String>.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<String> get keystrokes => const Stream<String>.empty();
+
+ @override
+ Future<String> promptForCharInput(
+ List<String> 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 28f180b..3704d5b 100644
--- a/packages/flutter_migrate/pubspec.yaml
+++ b/packages/flutter_migrate/pubspec.yaml
@@ -10,5 +10,20 @@
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 0000000..53f8e8f
--- /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<void>(() {
+ 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<void>(
+ 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<void>(
+ name: 'child',
+ body: () async {
+ final AppContext childContext = context;
+ await childContext.run<void>(
+ 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<void>(
+ name: 'child',
+ body: () {
+ final AppContext childContext = context;
+ runZoned<void>(() {
+ 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<void> outer = Completer<void>();
+ final Completer<void> inner = Completer<void>();
+ String? value;
+ await context.run<void>(
+ body: () {
+ outer.future.then<void>((_) {
+ value = context.get<String>();
+ inner.complete();
+ });
+ },
+ fallbacks: <Type, Generator>{
+ 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<void>(
+ body: () async {
+ final StringBuffer buf = StringBuffer(context.get<String>()!);
+ buf.write(context.get<String>());
+ await context.run<void>(body: () {
+ buf.write(context.get<String>());
+ });
+ value = buf.toString();
+ },
+ overrides: <Type, Generator>{
+ 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<String>()!);
+ buf.write(context.get<String>());
+ await context.run<void>(body: () {
+ buf.write(context.get<String>());
+ });
+ value = buf.toString();
+ },
+ fallbacks: <Type, Generator>{
+ 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<String?>(
+ body: () => context.get<String>(),
+ overrides: <Type, Generator>{
+ String: () => null,
+ },
+ );
+ expect(value, isNull);
+ });
+
+ test('throws if generator has dependency cycle', () async {
+ final Future<String?> value = context.run<String?>(
+ body: () async {
+ return context.get<String>();
+ },
+ fallbacks: <Type, Generator>{
+ int: () => int.parse(context.get<String>() ?? ''),
+ String: () => '${context.get<double>()}',
+ double: () => context.get<int>()! * 1.0,
+ },
+ );
+ expect(
+ () => value,
+ throwsA(
+ isA<ContextDependencyCycleException>().having(
+ (ContextDependencyCycleException error) => error.cycle,
+ 'cycle',
+ <Type>[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<int>(body: () => 123), 123);
+ expect(await context.run<String>(body: () => 'value'), 'value');
+ expect(await context.run<int>(body: () async => 456), 456);
+ });
+
+ test('passes name to child context', () async {
+ await context.run<void>(
+ 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<String?>(
+ body: () {
+ return context.run<String?>(
+ body: () {
+ called = true;
+ return context.get<String>();
+ },
+ fallbacks: <Type, Generator>{
+ 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<String?>(
+ body: () {
+ return context.run<String?>(
+ body: () {
+ called = true;
+ return context.get<String>();
+ },
+ fallbacks: <Type, Generator>{
+ String: () {
+ childConsulted = true;
+ return 'child';
+ },
+ },
+ );
+ },
+ fallbacks: <Type, Generator>{
+ String: () => 'parent',
+ },
+ );
+ expect(called, isTrue);
+ expect(value, 'parent');
+ expect(childConsulted, isFalse);
+ });
+
+ test('may depend on one another', () async {
+ final String? value = await context.run<String?>(
+ body: () {
+ return context.get<String>();
+ },
+ fallbacks: <Type, Generator>{
+ int: () => 123,
+ String: () => '-${context.get<int>()}-',
+ },
+ );
+ expect(value, '-123-');
+ });
+ });
+
+ group('overrides', () {
+ test('intercept consultation of parent context', () async {
+ bool parentConsulted = false;
+ final String? value = await context.run<String?>(
+ body: () {
+ return context.run<String?>(
+ body: () => context.get<String>(),
+ overrides: <Type, Generator>{
+ String: () => 'child',
+ },
+ );
+ },
+ fallbacks: <Type, Generator>{
+ 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 0000000..b7a6424
--- /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<void> completer = Completer<void>();
+ final Signals signals = Signals.test();
+ final LocalFileSystem localFileSystem = LocalFileSystem.test(
+ signals: signals,
+ fatalSignals: <ProcessSignal>[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<io.ProcessSignal> controller =
+ StreamController<io.ProcessSignal>();
+
+ @override
+ Stream<io.ProcessSignal> 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 0000000..608715e
--- /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<io.ProcessSignal> controller =
+ StreamController<io.ProcessSignal>();
+
+ @override
+ Stream<io.ProcessSignal> 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 0000000..da05522
--- /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<StdoutLogger>());
+ expect(
+ loggerFactory.createLogger(
+ windows: true,
+ ),
+ isA<WindowsStdoutLogger>());
+ });
+
+ 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, <String>['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, <String>['🔥🖼️✗✓🔨💪✏️\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<String> outputStdout() => fakeStdio.writtenToStdout.join().split('\n');
+ List<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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(), <String>['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<String> 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(), <String>[
+ '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<String> lines = outputStdout();
+ expect(lines[0], startsWith('Hello world '));
+ expect(lines.length, equals(1));
+
+ // Verify a stop prints the time.
+ summaryStatus.stop();
+ expect(outputStdout(), <String>[
+ '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<String> 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<void> _completer = Completer<void>();
+
+ @override
+ void write(Object? object) {
+ if (syncError) {
+ throw Exception('Error!');
+ }
+ Zone.current.runUnaryGuarded<void>((_) {
+ if (completeWithError) {
+ _completer.completeError(Exception('Some pipe error'));
+ } else {
+ _completer.complete();
+ throw Exception('Error!');
+ }
+ }, null);
+ }
+
+ @override
+ Future<void> 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 0000000..87f0d04
--- /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<void> completer = Completer<void>();
+ 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<void> completer = Completer<void>();
+
+ 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<void> completer = Completer<void>();
+ late Object token;
+ Future<void> 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<void> completer = Completer<void>();
+ final List<Object> errList = <Object>[];
+ final StreamSubscription<Object> 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<Object> errList = <Object>[];
+ final StreamSubscription<Object> 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<void> completer = Completer<void>();
+ 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<Object> errList = <Object>[];
+ final StreamSubscription<Object> 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<void> completer = Completer<void>();
+ signals.addHandler(signalUnderTest, (ProcessSignal s) {
+ expect(s, signalUnderTest);
+ completer.complete();
+ });
+
+ signals.addHandler(otherSignal, (ProcessSignal s) async {
+ fail('Wrong signal!.');
+ });
+
+ final List<Object> errList = <Object>[];
+ final StreamSubscription<Object> 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<io.ProcessSignal> controller =
+ StreamController<io.ProcessSignal>();
+
+ @override
+ Stream<io.ProcessSignal> 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 0000000..6686ec2
--- /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(
+ <String>['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<String>.fromFutures(<Future<String>>[
+ Future<String>.value('d'), // Not in accepted list.
+ Future<String>.value('\n'), // Not in accepted list
+ Future<String>.value('b'),
+ ]).asBroadcastStream();
+ final String choice = await terminalUnderTest.promptForCharInput(
+ <String>['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<String>.fromFutures(<Future<String>>[
+ Future<String>.value('\n'), // Not in accepted list
+ ]).asBroadcastStream();
+ final String choice = await terminalUnderTest.promptForCharInput(
+ <String>['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<String> mockStdInStream;
+
+class TestTerminal extends AnsiTerminal {
+ TestTerminal({
+ Stdio? stdio,
+ DateTime? now,
+ }) : super(stdio: stdio ?? Stdio(), now: now ?? DateTime(2018));
+
+ @override
+ Stream<String> 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 0000000..40e55b0
--- /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<String> 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<ProcessResult> runMigrateCommand(List<String> args,
+ {String? workingDirectory}) {
+ final List<String> commandArgs = <String>['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<void> Function() body, {
+ String? testOn,
+ dynamic skip,
+ List<String>? tags,
+ Map<String, dynamic>? 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<void> Function() body, {
+ String? testOn,
+ dynamic skip,
+ List<String>? tags,
+ Map<String, dynamic>? onPlatform,
+ int? retry,
+}) {
+ return test(
+ description,
+ () async {
+ return runZoned(body, zoneValues: <Object, Object>{
+ 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<T>() {
+ 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<V> run<V>({
+ required FutureOr<V> Function() body,
+ String? name,
+ Map<Type, Generator>? overrides,
+ Map<Type, Generator>? fallbacks,
+ ZoneSpecification? zoneSpecification,
+ }) async {
+ return body();
+ }
+}
+
+/// Matcher for functions that throw [AssertionError].
+final Matcher throwsAssertionError = throwsA(isA<AssertionError>());
diff --git a/packages/flutter_migrate/test/src/context.dart b/packages/flutter_migrate/test/src/context.dart
new file mode 100644
index 0000000..4f43b48
--- /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<Logger>()! as BufferLogger;
+
+@isTest
+void testUsingContext(
+ String description,
+ dynamic Function() testMethod, {
+ Map<Type, Generator> overrides = const <Type, Generator>{},
+ 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<dynamic>(() {
+ return context.run<dynamic>(
+ name: 'mocks',
+ overrides: <Type, Generator>{
+ AnsiTerminal: () => AnsiTerminal(stdio: FakeStdio()),
+ OutputPreferences: () => OutputPreferences.test(),
+ Logger: () => BufferLogger.test(),
+ ProcessManager: () => const LocalProcessManager(),
+ },
+ body: () {
+ return runZonedGuarded<Future<dynamic>>(() {
+ try {
+ return context.run<dynamic>(
+ // 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: <Type, Generator>{});
+ }, 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<Logger>() is BufferLogger) {
+ final BufferLogger bufferLogger =
+ testContext.get<Logger>()! 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<T> runInContext<T>(
+ FutureOr<T> Function() runner, {
+ Map<Type, Generator>? overrides,
+}) async {
+ // Wrap runner with any asynchronous initialization that should run with the
+ // overrides and callbacks.
+ // late bool runningOnBot;
+ FutureOr<T> runnerWrapper() async {
+ return runner();
+ }
+
+ return context.run<T>(
+ name: 'global fallbacks',
+ body: runnerWrapper,
+ overrides: overrides,
+ fallbacks: <Type, Generator>{});
+}
diff --git a/packages/flutter_migrate/test/src/fakes.dart b/packages/flutter_migrate/test/src/fakes.dart
new file mode 100644
index 0000000..3e6b072
--- /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<List<int>> _completer = Completer<List<int>>();
+
+ Future<List<int>> get future => _completer.future;
+
+ @override
+ void add(List<int> data) {
+ if (!_completer.isCompleted) {
+ // When throwOnAdd is true, complete with empty so any expected output
+ // doesn't appear.
+ _completer.complete(throwOnAdd ? <int>[] : 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<List<int>> writes = <List<int>>[];
+
+ @override
+ void add(List<int> data) {
+ writes.add(data);
+ }
+
+ @override
+ Future<void> addStream(Stream<List<int>> stream) {
+ final Completer<void> completer = Completer<void>();
+ late StreamSubscription<List<int>> sub;
+ sub = stream.listen(
+ (List<int> 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(<int>[charCode]);
+ }
+
+ @override
+ void write(Object? obj) {
+ add(encoding.encode('$obj'));
+ }
+
+ @override
+ void writeln([Object? obj = '']) {
+ add(encoding.encode('$obj\n'));
+ }
+
+ @override
+ void writeAll(Iterable<dynamic> 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<void> get done => close();
+
+ @override
+ Future<void> close() async {}
+
+ @override
+ Future<void> flush() async {}
+
+ void clear() {
+ writes.clear();
+ }
+
+ String getAndClear() {
+ final String result =
+ utf8.decode(writes.expand((List<int> 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<List<int>> get stdin => _stdin;
+
+ void simulateStdin(String line) {
+ _stdin.controller.add(utf8.encode('$line\n'));
+ }
+
+ @override
+ bool hasTerminal = true;
+
+ List<String> get writtenToStdout =>
+ _stdout.writes.map<String>(_stdout.encoding.decode).toList();
+ List<String> get writtenToStderr =>
+ _stderr.writes.map<String>(_stderr.encoding.decode).toList();
+}
+
+class FakeStdin extends Fake implements Stdin {
+ final StreamController<List<int>> controller = StreamController<List<int>>();
+
+ @override
+ bool echoMode = true;
+
+ @override
+ bool hasTerminal = true;
+
+ @override
+ bool echoNewlineMode = true;
+
+ @override
+ bool lineMode = true;
+
+ @override
+ Stream<S> transform<S>(StreamTransformer<List<int>, S> transformer) {
+ return controller.stream.transform(transformer);
+ }
+
+ @override
+ StreamSubscription<List<int>> listen(
+ void Function(List<int> 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<String, Stopwatch>? stopwatches})
+ : stopwatches = <String, Stopwatch>{
+ if (stopwatches != null) ...stopwatches,
+ if (stopwatch != null) '': stopwatch,
+ };
+
+ Map<String, Stopwatch> 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 0000000..c7e6bf3
--- /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<FileSystemEvent> 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<FileSystemEntityType> 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<bool> 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<FileStat> 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 0000000..8e4d36b
--- /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<void> createTestCommandRunner([MigrateCommand? command]) {
+ final CommandRunner<void> runner = TestCommandRunner();
+ if (command != null) {
+ runner.addCommand(command);
+ }
+ return runner;
+}
+
+class TestCommandRunner extends CommandRunner<void> {
+ TestCommandRunner()
+ : super(
+ 'flutter',
+ 'Manage your Flutter app development.\n'
+ '\n'
+ 'Common commands:\n'
+ '\n'
+ ' flutter create <output directory>\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<void> run(Iterable<String> 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 0000000..b51e127
--- /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<void> getPackages(String folder) async {
+ final List<String> command = <String>[
+ 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 1ad0f64..a281f3c 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