[flutter_plugin_tools] Adds update-excerpts command (#5339)

This commit is contained in:
stuartmorgan
2022-04-28 12:34:11 -04:00
committed by GitHub
parent 88829b67ac
commit 4cecb9b264
9 changed files with 604 additions and 6 deletions

View File

@ -1,5 +1,8 @@
## NEXT ## 0.8.3
- Adds a new `update-excerpts` command to maintain README files using the
`code-excerpter` package from flutter/site-shared.
- `license-check` now ignores submodules.
- Allows `make-deps-path-based` to skip packages it has alredy rewritten, so - Allows `make-deps-path-based` to skip packages it has alredy rewritten, so
that running multiple times won't fail after the first time. that running multiple times won't fail after the first time.

View File

@ -107,6 +107,17 @@ dart run ./script/tool/bin/flutter_plugin_tools.dart native-test --ios --android
dart run ./script/tool/bin/flutter_plugin_tools.dart native-test --macos --packages plugin_name dart run ./script/tool/bin/flutter_plugin_tools.dart native-test --macos --packages plugin_name
``` ```
### Update README.md from Example Sources
`update-excerpts` requires sources that are in a submodule. If you didn't clone
with submodules, you will need to `git submodule update --init --recursive`
before running this command.
```sh
cd <repository root>
dart run ./script/tool/bin/flutter_plugin_tools.dart update-excerpts --packages plugin_name
```
### Publish a Release ### Publish a Release
**Releases are automated for `flutter/plugins` and `flutter/packages`.** **Releases are automated for `flutter/plugins` and `flutter/packages`.**

View File

@ -3,7 +3,9 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:git/git.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'common/core.dart'; import 'common/core.dart';
import 'common/plugin_command.dart'; import 'common/plugin_command.dart';
@ -105,7 +107,9 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
/// Validates that code files have copyright and license blocks. /// Validates that code files have copyright and license blocks.
class LicenseCheckCommand extends PluginCommand { class LicenseCheckCommand extends PluginCommand {
/// Creates a new license check command for [packagesDir]. /// Creates a new license check command for [packagesDir].
LicenseCheckCommand(Directory packagesDir) : super(packagesDir); LicenseCheckCommand(Directory packagesDir,
{Platform platform = const LocalPlatform(), GitDir? gitDir})
: super(packagesDir, platform: platform, gitDir: gitDir);
@override @override
final String name = 'license-check'; final String name = 'license-check';
@ -116,7 +120,14 @@ class LicenseCheckCommand extends PluginCommand {
@override @override
Future<void> run() async { Future<void> run() async {
final Iterable<File> allFiles = await _getAllFiles(); // Create a set of absolute paths to submodule directories, with trailing
// separator, to do prefix matching with to test directory inclusion.
final Iterable<String> submodulePaths = (await _getSubmoduleDirectories())
.map(
(Directory dir) => '${dir.absolute.path}${platform.pathSeparator}');
final Iterable<File> allFiles = (await _getAllFiles()).where(
(File file) => !submodulePaths.any(file.absolute.path.startsWith));
final Iterable<File> codeFiles = allFiles.where((File file) => final Iterable<File> codeFiles = allFiles.where((File file) =>
_codeFileExtensions.contains(p.extension(file.path)) && _codeFileExtensions.contains(p.extension(file.path)) &&
@ -275,6 +286,24 @@ class LicenseCheckCommand extends PluginCommand {
.where((FileSystemEntity entity) => entity is File) .where((FileSystemEntity entity) => entity is File)
.map((FileSystemEntity file) => file as File) .map((FileSystemEntity file) => file as File)
.toList(); .toList();
// Returns the directories containing mapped submodules, if any.
Future<Iterable<Directory>> _getSubmoduleDirectories() async {
final List<Directory> submodulePaths = <Directory>[];
final Directory repoRoot =
packagesDir.fileSystem.directory((await gitDir).path);
final File submoduleSpec = repoRoot.childFile('.gitmodules');
if (submoduleSpec.existsSync()) {
final RegExp pathLine = RegExp(r'path\s*=\s*(.*)');
for (final String line in submoduleSpec.readAsLinesSync()) {
final RegExpMatch? match = pathLine.firstMatch(line);
if (match != null) {
submodulePaths.add(repoRoot.childDirectory(match.group(1)!.trim()));
}
}
}
return submodulePaths;
}
} }
enum _LicenseFailureType { incorrectFirstParty, unknownThirdParty } enum _LicenseFailureType { incorrectFirstParty, unknownThirdParty }

View File

@ -28,6 +28,7 @@ import 'publish_plugin_command.dart';
import 'pubspec_check_command.dart'; import 'pubspec_check_command.dart';
import 'readme_check_command.dart'; import 'readme_check_command.dart';
import 'test_command.dart'; import 'test_command.dart';
import 'update_excerpts_command.dart';
import 'version_check_command.dart'; import 'version_check_command.dart';
import 'xcode_analyze_command.dart'; import 'xcode_analyze_command.dart';
@ -68,6 +69,7 @@ void main(List<String> args) {
..addCommand(PubspecCheckCommand(packagesDir)) ..addCommand(PubspecCheckCommand(packagesDir))
..addCommand(ReadmeCheckCommand(packagesDir)) ..addCommand(ReadmeCheckCommand(packagesDir))
..addCommand(TestCommand(packagesDir)) ..addCommand(TestCommand(packagesDir))
..addCommand(UpdateExcerptsCommand(packagesDir))
..addCommand(VersionCheckCommand(packagesDir)) ..addCommand(VersionCheckCommand(packagesDir))
..addCommand(XcodeAnalyzeCommand(packagesDir)); ..addCommand(XcodeAnalyzeCommand(packagesDir));

View File

@ -0,0 +1,225 @@
// 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;
import 'package:file/file.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:git/git.dart';
import 'package:platform/platform.dart';
import 'package:yaml/yaml.dart';
import 'package:yaml_edit/yaml_edit.dart';
import 'common/package_looping_command.dart';
import 'common/process_runner.dart';
import 'common/repository_package.dart';
/// A command to update README code excerpts from code files.
class UpdateExcerptsCommand extends PackageLoopingCommand {
/// Creates a excerpt updater command instance.
UpdateExcerptsCommand(
Directory packagesDir, {
ProcessRunner processRunner = const ProcessRunner(),
Platform platform = const LocalPlatform(),
GitDir? gitDir,
}) : super(
packagesDir,
processRunner: processRunner,
platform: platform,
gitDir: gitDir,
) {
argParser.addFlag(_failOnChangeFlag, hide: true);
}
static const String _failOnChangeFlag = 'fail-on-change';
static const String _buildRunnerConfigName = 'excerpt';
// The name of the build_runner configuration file that will be in an example
// directory if the package is set up to use `code-excerpt`.
static const String _buildRunnerConfigFile =
'build.$_buildRunnerConfigName.yaml';
// The relative directory path to put the extracted excerpt yaml files.
static const String _excerptOutputDir = 'excerpts';
// The filename to store the pre-modification copy of the pubspec.
static const String _originalPubspecFilename =
'pubspec.plugin_tools_original.yaml';
@override
final String name = 'update-excerpts';
@override
final String description = 'Updates code excerpts in README.md files, based '
'on code from code files, via code-excerpt';
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final Iterable<RepositoryPackage> configuredExamples = package
.getExamples()
.where((RepositoryPackage example) =>
example.directory.childFile(_buildRunnerConfigFile).existsSync());
if (configuredExamples.isEmpty) {
return PackageResult.skip(
'No $_buildRunnerConfigFile found in example(s).');
}
final Directory repoRoot =
packagesDir.fileSystem.directory((await gitDir).path);
for (final RepositoryPackage example in configuredExamples) {
_addSubmoduleDependencies(example, repoRoot: repoRoot);
try {
// Ensure that dependencies are available.
final int pubGetExitCode = await processRunner.runAndStream(
'dart', <String>['pub', 'get'],
workingDir: example.directory);
if (pubGetExitCode != 0) {
return PackageResult.fail(
<String>['Unable to get script dependencies']);
}
// Update the excerpts.
if (!await _extractSnippets(example)) {
return PackageResult.fail(<String>['Unable to extract excerpts']);
}
if (!await _injectSnippets(example, targetPackage: package)) {
return PackageResult.fail(<String>['Unable to inject excerpts']);
}
} finally {
// Clean up the pubspec changes and extracted excerpts directory.
_undoPubspecChanges(example);
final Directory excerptDirectory =
example.directory.childDirectory(_excerptOutputDir);
if (excerptDirectory.existsSync()) {
excerptDirectory.deleteSync(recursive: true);
}
}
}
if (getBoolArg(_failOnChangeFlag)) {
final String? stateError = await _validateRepositoryState();
if (stateError != null) {
printError('README.md is out of sync with its source excerpts.\n\n'
'If you edited code in README.md directly, you should instead edit '
'the example source files. If you edited source files, run the '
'repository tooling\'s "$name" command on this package, and update '
'your PR with the resulting changes.');
return PackageResult.fail(<String>[stateError]);
}
}
return PackageResult.success();
}
/// Runs the extraction step to create the excerpt files for the given
/// example, returning true on success.
Future<bool> _extractSnippets(RepositoryPackage example) async {
final int exitCode = await processRunner.runAndStream(
'dart',
<String>[
'run',
'build_runner',
'build',
'--config',
_buildRunnerConfigName,
'--output',
_excerptOutputDir,
'--delete-conflicting-outputs',
],
workingDir: example.directory);
return exitCode == 0;
}
/// Runs the injection step to update [targetPackage]'s README with the latest
/// excerpts from [example], returning true on success.
Future<bool> _injectSnippets(
RepositoryPackage example, {
required RepositoryPackage targetPackage,
}) async {
final String relativeReadmePath =
getRelativePosixPath(targetPackage.readmeFile, from: example.directory);
final int exitCode = await processRunner.runAndStream(
'dart',
<String>[
'run',
'code_excerpt_updater',
'--write-in-place',
'--yaml',
'--no-escape-ng-interpolation',
relativeReadmePath,
],
workingDir: example.directory);
return exitCode == 0;
}
/// Adds `code_excerpter` and `code_excerpt_updater` to [package]'s
/// `dev_dependencies` using path-based references to the submodule copies.
///
/// This is done on the fly rather than being checked in so that:
/// - Just building examples don't require everyone to check out submodules.
/// - Examples can be analyzed/built even on versions of Flutter that these
/// submodules do not support.
void _addSubmoduleDependencies(RepositoryPackage package,
{required Directory repoRoot}) {
final String pubspecContents = package.pubspecFile.readAsStringSync();
// Save aside a copy of the current pubspec state. This allows restoration
// to the previous state regardless of its git status at the time the script
// ran.
package.directory
.childFile(_originalPubspecFilename)
.writeAsStringSync(pubspecContents);
// Update the actual pubspec.
final YamlEditor editablePubspec = YamlEditor(pubspecContents);
const String devDependenciesKey = 'dev_dependencies';
final YamlNode root = editablePubspec.parseAt(<String>[]);
// Ensure that there's a `dev_dependencies` entry to update.
if ((root as YamlMap)[devDependenciesKey] == null) {
editablePubspec.update(<String>['dev_dependencies'], YamlMap());
}
final Set<String> submoduleDependencies = <String>{
'code_excerpter',
'code_excerpt_updater',
};
final String relativeRootPath =
getRelativePosixPath(repoRoot, from: package.directory);
for (final String dependency in submoduleDependencies) {
editablePubspec.update(<String>[
devDependenciesKey,
dependency
], <String, String>{
'path': '$relativeRootPath/site-shared/packages/$dependency'
});
}
package.pubspecFile.writeAsStringSync(editablePubspec.toString());
}
/// Restores the version of the pubspec that was present before running
/// [_addSubmoduleDependencies].
void _undoPubspecChanges(RepositoryPackage package) {
package.directory
.childFile(_originalPubspecFilename)
.renameSync(package.pubspecFile.path);
}
/// Checks the git state, returning an error string unless nothing has
/// changed.
Future<String?> _validateRepositoryState() async {
final io.ProcessResult modifiedFiles = await processRunner.run(
'git',
<String>['ls-files', '--modified'],
workingDir: packagesDir,
logOnError: true,
);
if (modifiedFiles.exitCode != 0) {
return 'Unable to determine local file state';
}
final String stdout = modifiedFiles.stdout as String;
return stdout.trim().isEmpty ? null : 'Snippets are out of sync';
}
}

View File

@ -1,7 +1,7 @@
name: flutter_plugin_tools name: flutter_plugin_tools
description: Productivity utils for flutter/plugins and flutter/packages description: Productivity utils for flutter/plugins and flutter/packages
repository: https://github.com/flutter/plugins/tree/main/script/tool repository: https://github.com/flutter/plugins/tree/main/script/tool
version: 0.8.2+1 version: 0.8.3
dependencies: dependencies:
args: ^2.1.0 args: ^2.1.0
@ -21,6 +21,7 @@ dependencies:
test: ^1.17.3 test: ^1.17.3
uuid: ^3.0.4 uuid: ^3.0.4
yaml: ^3.1.0 yaml: ^3.1.0
yaml_edit: ^2.0.2
dev_dependencies: dev_dependencies:
build_runner: ^2.0.3 build_runner: ^2.0.3

View File

@ -448,7 +448,7 @@ void main() {
])); ]));
}); });
test('fails if files are changed with --file-on-change', () async { test('fails if files are changed with --fail-on-change', () async {
const List<String> files = <String>[ const List<String> files = <String>[
'linux/foo_plugin.cc', 'linux/foo_plugin.cc',
'macos/Classes/Foo.h', 'macos/Classes/Foo.h',

View File

@ -7,24 +7,35 @@ import 'package:file/file.dart';
import 'package:file/memory.dart'; import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart'; import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/license_check_command.dart'; import 'package:flutter_plugin_tools/src/license_check_command.dart';
import 'package:mockito/mockito.dart';
import 'package:platform/platform.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'common/plugin_command_test.mocks.dart';
import 'mocks.dart';
import 'util.dart'; import 'util.dart';
void main() { void main() {
group('$LicenseCheckCommand', () { group('LicenseCheckCommand', () {
late CommandRunner<void> runner; late CommandRunner<void> runner;
late FileSystem fileSystem; late FileSystem fileSystem;
late Platform platform;
late Directory root; late Directory root;
setUp(() { setUp(() {
fileSystem = MemoryFileSystem(); fileSystem = MemoryFileSystem();
platform = MockPlatformWithSeparator();
final Directory packagesDir = final Directory packagesDir =
fileSystem.currentDirectory.childDirectory('packages'); fileSystem.currentDirectory.childDirectory('packages');
root = packagesDir.parent; root = packagesDir.parent;
final MockGitDir gitDir = MockGitDir();
when(gitDir.path).thenReturn(packagesDir.parent.path);
final LicenseCheckCommand command = LicenseCheckCommand( final LicenseCheckCommand command = LicenseCheckCommand(
packagesDir, packagesDir,
platform: platform,
gitDir: gitDir,
); );
runner = runner =
CommandRunner<void>('license_test', 'Test for $LicenseCheckCommand'); CommandRunner<void>('license_test', 'Test for $LicenseCheckCommand');
@ -123,6 +134,33 @@ void main() {
} }
}); });
test('ignores submodules', () async {
const String submoduleName = 'a_submodule';
final File submoduleSpec = root.childFile('.gitmodules');
submoduleSpec.writeAsStringSync('''
[submodule "$submoduleName"]
path = $submoduleName
url = https://github.com/foo/$submoduleName
''');
const List<String> submoduleFiles = <String>[
'$submoduleName/foo.dart',
'$submoduleName/a/b/bar.dart',
'$submoduleName/LICENSE',
];
for (final String filePath in submoduleFiles) {
root.childFile(filePath).createSync(recursive: true);
}
final List<String> output =
await runCapturingPrint(runner, <String>['license-check']);
for (final String filePath in submoduleFiles) {
expect(output, isNot(contains('Checking $filePath')));
}
});
test('passes if all checked files have license blocks', () async { test('passes if all checked files have license blocks', () async {
final File checked = root.childFile('checked.cc'); final File checked = root.childFile('checked.cc');
checked.createSync(); checked.createSync();
@ -509,6 +547,11 @@ void main() {
}); });
} }
class MockPlatformWithSeparator extends MockPlatform {
@override
String get pathSeparator => isWindows ? r'\' : '/';
}
const String _correctLicenseFileText = ''' const String _correctLicenseFileText = '''
Copyright 2013 The Flutter Authors. All rights reserved. Copyright 2013 The Flutter Authors. All rights reserved.

View File

@ -0,0 +1,284 @@
// 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;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/common/repository_package.dart';
import 'package:flutter_plugin_tools/src/update_excerpts_command.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'common/plugin_command_test.mocks.dart';
import 'mocks.dart';
import 'util.dart';
void main() {
late FileSystem fileSystem;
late Directory packagesDir;
late RecordingProcessRunner processRunner;
late CommandRunner<void> runner;
setUp(() {
fileSystem = MemoryFileSystem();
packagesDir = createPackagesDirectory(fileSystem: fileSystem);
final MockGitDir gitDir = MockGitDir();
when(gitDir.path).thenReturn(packagesDir.parent.path);
processRunner = RecordingProcessRunner();
final UpdateExcerptsCommand command = UpdateExcerptsCommand(
packagesDir,
processRunner: processRunner,
platform: MockPlatform(),
gitDir: gitDir,
);
runner = CommandRunner<void>(
'update_excerpts_command', 'Test for update_excerpts_command');
runner.addCommand(command);
});
test('runs pub get before running scripts', () async {
final Directory package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
final Directory example = package.childDirectory('example');
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(
processRunner.recordedCalls,
containsAll(<ProcessCall>[
ProcessCall('dart', const <String>['pub', 'get'], example.path),
ProcessCall(
'dart',
const <String>[
'run',
'build_runner',
'build',
'--config',
'excerpt',
'--output',
'excerpts',
'--delete-conflicting-outputs',
],
example.path),
]));
});
test('runs when config is present', () async {
final Directory package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
final Directory example = package.childDirectory('example');
final List<String> output =
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(
processRunner.recordedCalls,
containsAll(<ProcessCall>[
ProcessCall(
'dart',
const <String>[
'run',
'build_runner',
'build',
'--config',
'excerpt',
'--output',
'excerpts',
'--delete-conflicting-outputs',
],
example.path),
ProcessCall(
'dart',
const <String>[
'run',
'code_excerpt_updater',
'--write-in-place',
'--yaml',
'--no-escape-ng-interpolation',
'../README.md',
],
example.path),
]));
expect(
output,
containsAllInOrder(<Matcher>[
contains('Ran for 1 package(s)'),
]));
});
test('skips when no config is present', () async {
createFakePlugin('a_package', packagesDir);
final List<String> output =
await runCapturingPrint(runner, <String>['update-excerpts']);
expect(processRunner.recordedCalls, isEmpty);
expect(
output,
containsAllInOrder(<Matcher>[
contains('Skipped 1 package(s)'),
]));
});
test('restores pubspec even if running the script fails', () async {
final Directory package = createFakePlugin('a_package', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
MockProcess(exitCode: 1), // dart pub get
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
// Check that it's definitely a failure in a step between making the changes
// and restoring the original.
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to get script dependencies')
]));
final String examplePubspecContent = RepositoryPackage(package)
.getExamples()
.first
.pubspecFile
.readAsStringSync();
expect(examplePubspecContent, isNot(contains('code_excerpter')));
expect(examplePubspecContent, isNot(contains('code_excerpt_updater')));
});
test('fails if pub get fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
MockProcess(exitCode: 1), // dart pub get
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to get script dependencies')
]));
});
test('fails if extraction fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
MockProcess(exitCode: 0), // dart pub get
MockProcess(exitCode: 1), // dart run build_runner ...
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to extract excerpts')
]));
});
test('fails if injection fails', () async {
createFakePlugin('a_package', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
processRunner.mockProcessesForExecutable['dart'] = <io.Process>[
MockProcess(exitCode: 0), // dart pub get
MockProcess(exitCode: 0), // dart run build_runner ...
MockProcess(exitCode: 1), // dart run code_excerpt_updater ...
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('The following packages had errors:'),
contains('a_package:\n'
' Unable to inject excerpts')
]));
});
test('fails if files are changed with --fail-on-change', () async {
createFakePlugin('a_plugin', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc';
processRunner.mockProcessesForExecutable['git'] = <io.Process>[
MockProcess(stdout: changedFilePath),
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts', '--fail-on-change'],
errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('README.md is out of sync with its source excerpts'),
]));
});
test('fails if git ls-files fails', () async {
createFakePlugin('a_plugin', packagesDir,
extraFiles: <String>['example/build.excerpt.yaml']);
processRunner.mockProcessesForExecutable['git'] = <io.Process>[
MockProcess(exitCode: 1)
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['update-excerpts', '--fail-on-change'],
errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('Unable to determine local file state'),
]));
});
}