Unverified Commit 5d587f95 authored by Christopher Fujino's avatar Christopher Fujino Committed by GitHub

[flutter_conductor] Refactor next command (#91768)

parent 7a7d9a27
...@@ -4,12 +4,12 @@ ...@@ -4,12 +4,12 @@
import 'package:args/command_runner.dart'; import 'package:args/command_runner.dart';
import 'package:file/file.dart' show File; import 'package:file/file.dart' show File;
import 'package:meta/meta.dart' show visibleForTesting; import 'package:meta/meta.dart' show visibleForTesting, visibleForOverriding;
import './globals.dart'; import './globals.dart';
import './proto/conductor_state.pb.dart' as pb; import './proto/conductor_state.pb.dart' as pb;
import './proto/conductor_state.pbenum.dart'; import './proto/conductor_state.pbenum.dart';
import './repository.dart'; import './repository.dart';
import './state.dart'; import './state.dart' as state_import;
import './stdio.dart'; import './stdio.dart';
const String kStateOption = 'state-file'; const String kStateOption = 'state-file';
...@@ -21,7 +21,7 @@ class NextCommand extends Command<void> { ...@@ -21,7 +21,7 @@ class NextCommand extends Command<void> {
NextCommand({ NextCommand({
required this.checkouts, required this.checkouts,
}) { }) {
final String defaultPath = defaultStateFilePath(checkouts.platform); final String defaultPath = state_import.defaultStateFilePath(checkouts.platform);
argParser.addOption( argParser.addOption(
kStateOption, kStateOption,
defaultsTo: defaultPath, defaultsTo: defaultPath,
...@@ -48,352 +48,374 @@ class NextCommand extends Command<void> { ...@@ -48,352 +48,374 @@ class NextCommand extends Command<void> {
@override @override
Future<void> run() async { Future<void> run() async {
await runNext( await NextContext(
autoAccept: argResults![kYesFlag] as bool, autoAccept: argResults![kYesFlag] as bool,
checkouts: checkouts, checkouts: checkouts,
force: argResults![kForceFlag] as bool, force: argResults![kForceFlag] as bool,
stateFile: checkouts.fileSystem.file(argResults![kStateOption]), stateFile: checkouts.fileSystem.file(argResults![kStateOption]),
); ).run();
}
}
@visibleForTesting
bool prompt(String message, Stdio stdio) {
stdio.write('${message.trim()} (y/n) ');
final String response = stdio.readLineSync().trim();
final String firstChar = response[0].toUpperCase();
if (firstChar == 'Y') {
return true;
}
if (firstChar == 'N') {
return false;
} }
throw ConductorException(
'Unknown user input (expected "y" or "n"): $response',
);
} }
@visibleForTesting /// Utility class for proceeding to the next step in a release.
Future<void> runNext({ ///
required bool autoAccept, /// Any calls to functions that cause side effects are wrapped in methods to
required bool force, /// allow overriding in unit tests.
required Checkouts checkouts, class NextContext {
required File stateFile, NextContext({
}) async { required this.autoAccept,
final Stdio stdio = checkouts.stdio; required this.force,
const List<CherrypickState> finishedStates = <CherrypickState>[ required this.checkouts,
CherrypickState.COMPLETED, required this.stateFile,
CherrypickState.ABANDONED, });
];
if (!stateFile.existsSync()) {
throw ConductorException(
'No persistent state file found at ${stateFile.path}.',
);
}
final pb.ConductorState state = readStateFromFile(stateFile); final bool autoAccept;
final bool force;
final Checkouts checkouts;
final File stateFile;
switch (state.currentPhase) { Future<void> run() async {
case pb.ReleasePhase.APPLY_ENGINE_CHERRYPICKS: final Stdio stdio = checkouts.stdio;
final Remote upstream = Remote( const List<CherrypickState> finishedStates = <CherrypickState>[
name: RemoteName.upstream, CherrypickState.COMPLETED,
url: state.engine.upstream.url, CherrypickState.ABANDONED,
); ];
final EngineRepository engine = EngineRepository( if (!stateFile.existsSync()) {
checkouts, throw ConductorException(
initialRef: state.engine.workingBranch, 'No persistent state file found at ${stateFile.path}.',
upstreamRemote: upstream,
previousCheckoutLocation: state.engine.checkoutPath,
); );
// check if the candidate branch is enabled in .ci.yaml }
final CiYaml engineCiYaml = await engine.ciYaml;
if (!engineCiYaml.enabledBranches.contains(state.engine.candidateBranch)) {
engineCiYaml.enableBranch(state.engine.candidateBranch);
// commit
final String revision = await engine.commit(
'add branch ${state.engine.candidateBranch} to enabled_branches in .ci.yaml',
addFirst: true,
);
// append to list of cherrypicks so we know a PR is required
state.engine.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
}
if (!requiresEnginePR(state)) { final pb.ConductorState state = readStateFromFile(stateFile);
stdio.printStatus(
'This release has no engine cherrypicks. No Engine PR is necessary.\n', switch (state.currentPhase) {
case pb.ReleasePhase.APPLY_ENGINE_CHERRYPICKS:
final Remote upstream = Remote(
name: RemoteName.upstream,
url: state.engine.upstream.url,
); );
break; final EngineRepository engine = EngineRepository(
} checkouts,
initialRef: state.engine.workingBranch,
upstreamRemote: upstream,
previousCheckoutLocation: state.engine.checkoutPath,
);
// check if the candidate branch is enabled in .ci.yaml
final CiYaml engineCiYaml = await engine.ciYaml;
if (!engineCiYaml.enabledBranches.contains(state.engine.candidateBranch)) {
engineCiYaml.enableBranch(state.engine.candidateBranch);
// commit
final String revision = await engine.commit(
'add branch ${state.engine.candidateBranch} to enabled_branches in .ci.yaml',
addFirst: true,
);
// append to list of cherrypicks so we know a PR is required
state.engine.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
}
if (!state_import.requiresEnginePR(state)) {
stdio.printStatus(
'This release has no engine cherrypicks. No Engine PR is necessary.\n',
);
break;
}
final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[]; final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[];
for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) { for (final pb.Cherrypick cherrypick in state.engine.cherrypicks) {
if (!finishedStates.contains(cherrypick.state)) { if (!finishedStates.contains(cherrypick.state)) {
unappliedCherrypicks.add(cherrypick); unappliedCherrypicks.add(cherrypick);
}
} }
}
if (unappliedCherrypicks.isEmpty) { if (unappliedCherrypicks.isEmpty) {
stdio.printStatus('All engine cherrypicks have been auto-applied by the conductor.\n'); stdio.printStatus('All engine cherrypicks have been auto-applied by the conductor.\n');
} else {
if (unappliedCherrypicks.length == 1) {
stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.');
} else { } else {
stdio.printStatus('There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.'); if (unappliedCherrypicks.length == 1) {
stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.');
} else {
stdio.printStatus('There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.');
}
stdio.printStatus('These must be applied manually in the directory '
'${state.engine.checkoutPath} before proceeding.\n');
} }
stdio.printStatus('These must be applied manually in the directory ' if (autoAccept == false) {
'${state.engine.checkoutPath} before proceeding.\n'); final bool response = prompt(
} 'Are you ready to push your engine branch to the repository '
if (autoAccept == false) { '${state.engine.mirror.url}?',
final bool response = prompt( stdio,
'Are you ready to push your engine branch to the repository ' );
'${state.engine.mirror.url}?', if (!response) {
stdio, stdio.printError('Aborting command.');
); writeStateToFile(stateFile, state, stdio.logs);
if (!response) { return;
stdio.printError('Aborting command.'); }
writeStateToFile(stateFile, state, stdio.logs);
return;
} }
}
await engine.pushRef(
fromRef: 'HEAD',
// Explicitly create new branch
toRef: 'refs/heads/${state.engine.workingBranch}',
remote: state.engine.mirror.name,
);
break; await engine.pushRef(
case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES: fromRef: 'HEAD',
stdio.printStatus(<String>[ // Explicitly create new branch
'You must validate pre-submit CI for your engine PR, merge it, and codesign', toRef: 'refs/heads/${state.engine.workingBranch}',
'binaries before proceeding.\n', remote: state.engine.mirror.name,
].join('\n'));
if (autoAccept == false) {
// TODO(fujino): actually test if binaries have been codesigned on macOS
final bool response = prompt(
'Has CI passed for the engine PR and binaries been codesigned?',
stdio,
);
if (!response) {
stdio.printError('Aborting command.');
writeStateToFile(stateFile, state, stdio.logs);
return;
}
}
break;
case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS:
if (state.engine.cherrypicks.isEmpty && state.engine.dartRevision.isEmpty) {
stdio.printStatus(
'This release has no engine cherrypicks, and thus the engine.version file\n'
'in the framework does not need to be updated.',
); );
if (state.framework.cherrypicks.isEmpty) { break;
stdio.printStatus( case pb.ReleasePhase.CODESIGN_ENGINE_BINARIES:
'This release also has no framework cherrypicks. Therefore, a framework\n' stdio.printStatus(<String>[
'pull request is not required.', 'You must validate pre-submit CI for your engine PR, merge it, and codesign',
'binaries before proceeding.\n',
].join('\n'));
if (autoAccept == false) {
// TODO(fujino): actually test if binaries have been codesigned on macOS
final bool response = prompt(
'Has CI passed for the engine PR and binaries been codesigned?',
stdio,
); );
break; if (!response) {
stdio.printError('Aborting command.');
writeStateToFile(stateFile, state, stdio.logs);
return;
}
} }
} break;
final Remote engineUpstreamRemote = Remote( case pb.ReleasePhase.APPLY_FRAMEWORK_CHERRYPICKS:
name: RemoteName.upstream, if (state.engine.cherrypicks.isEmpty && state.engine.dartRevision.isEmpty) {
url: state.engine.upstream.url, stdio.printStatus(
); 'This release has no engine cherrypicks, and thus the engine.version file\n'
final EngineRepository engine = EngineRepository( 'in the framework does not need to be updated.',
checkouts, );
// We explicitly want to check out the merged version from upstream
initialRef: '${engineUpstreamRemote.name}/${state.engine.candidateBranch}',
upstreamRemote: engineUpstreamRemote,
previousCheckoutLocation: state.engine.checkoutPath,
);
final String engineRevision = await engine.reverseParse('HEAD'); if (state.framework.cherrypicks.isEmpty) {
stdio.printStatus(
'This release also has no framework cherrypicks. Therefore, a framework\n'
'pull request is not required.',
);
break;
}
}
final Remote engineUpstreamRemote = Remote(
name: RemoteName.upstream,
url: state.engine.upstream.url,
);
final EngineRepository engine = EngineRepository(
checkouts,
// We explicitly want to check out the merged version from upstream
initialRef: '${engineUpstreamRemote.name}/${state.engine.candidateBranch}',
upstreamRemote: engineUpstreamRemote,
previousCheckoutLocation: state.engine.checkoutPath,
);
final Remote upstream = Remote( final String engineRevision = await engine.reverseParse('HEAD');
name: RemoteName.upstream,
url: state.framework.upstream.url,
);
final FrameworkRepository framework = FrameworkRepository(
checkouts,
initialRef: state.framework.workingBranch,
upstreamRemote: upstream,
previousCheckoutLocation: state.framework.checkoutPath,
);
// Check if the current candidate branch is enabled final Remote upstream = Remote(
if (!(await framework.ciYaml).enabledBranches.contains(state.framework.candidateBranch)) { name: RemoteName.upstream,
(await framework.ciYaml).enableBranch(state.framework.candidateBranch); url: state.framework.upstream.url,
// commit
final String revision = await framework.commit(
'add branch ${state.framework.candidateBranch} to enabled_branches in .ci.yaml',
addFirst: true,
); );
// append to list of cherrypicks so we know a PR is required final FrameworkRepository framework = FrameworkRepository(
state.framework.cherrypicks.add(pb.Cherrypick( checkouts,
appliedRevision: revision, initialRef: state.framework.workingBranch,
state: pb.CherrypickState.COMPLETED, upstreamRemote: upstream,
)); previousCheckoutLocation: state.framework.checkoutPath,
}
stdio.printStatus('Rolling new engine hash $engineRevision to framework checkout...');
final bool needsCommit = await framework.updateEngineRevision(engineRevision);
if (needsCommit) {
final String revision = await framework.commit(
'Update Engine revision to $engineRevision for ${state.releaseChannel} release ${state.releaseVersion}',
addFirst: true,
); );
// append to list of cherrypicks so we know a PR is required
state.framework.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
}
final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[]; // Check if the current candidate branch is enabled
for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) { if (!(await framework.ciYaml).enabledBranches.contains(state.framework.candidateBranch)) {
if (!finishedStates.contains(cherrypick.state)) { (await framework.ciYaml).enableBranch(state.framework.candidateBranch);
unappliedCherrypicks.add(cherrypick); // commit
final String revision = await framework.commit(
'add branch ${state.framework.candidateBranch} to enabled_branches in .ci.yaml',
addFirst: true,
);
// append to list of cherrypicks so we know a PR is required
state.framework.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
}
stdio.printStatus('Rolling new engine hash $engineRevision to framework checkout...');
final bool needsCommit = await framework.updateEngineRevision(engineRevision);
if (needsCommit) {
final String revision = await framework.commit(
'Update Engine revision to $engineRevision for ${state.releaseChannel} release ${state.releaseVersion}',
addFirst: true,
);
// append to list of cherrypicks so we know a PR is required
state.framework.cherrypicks.add(pb.Cherrypick(
appliedRevision: revision,
state: pb.CherrypickState.COMPLETED,
));
} }
}
if (state.framework.cherrypicks.isEmpty) { final List<pb.Cherrypick> unappliedCherrypicks = <pb.Cherrypick>[];
stdio.printStatus( for (final pb.Cherrypick cherrypick in state.framework.cherrypicks) {
'This release has no framework cherrypicks. However, a framework PR is still\n' if (!finishedStates.contains(cherrypick.state)) {
'required to roll engine cherrypicks.', unappliedCherrypicks.add(cherrypick);
); }
} else if (unappliedCherrypicks.isEmpty) {
stdio.printStatus('All framework cherrypicks were auto-applied by the conductor.');
} else {
if (unappliedCherrypicks.length == 1) {
stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.',);
} }
else {
stdio.printStatus('There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.',); if (state.framework.cherrypicks.isEmpty) {
stdio.printStatus(
'This release has no framework cherrypicks. However, a framework PR is still\n'
'required to roll engine cherrypicks.',
);
} else if (unappliedCherrypicks.isEmpty) {
stdio.printStatus('All framework cherrypicks were auto-applied by the conductor.');
} else {
if (unappliedCherrypicks.length == 1) {
stdio.printStatus('There was ${unappliedCherrypicks.length} cherrypick that was not auto-applied.',);
}
else {
stdio.printStatus('There were ${unappliedCherrypicks.length} cherrypicks that were not auto-applied.',);
}
stdio.printStatus(
'These must be applied manually in the directory '
'${state.framework.checkoutPath} before proceeding.\n',
);
} }
stdio.printStatus(
'These must be applied manually in the directory '
'${state.framework.checkoutPath} before proceeding.\n',
);
}
if (autoAccept == false) { if (autoAccept == false) {
final bool response = prompt( final bool response = prompt(
'Are you ready to push your framework branch to the repository ' 'Are you ready to push your framework branch to the repository '
'${state.framework.mirror.url}?', '${state.framework.mirror.url}?',
stdio, stdio,
); );
if (!response) { if (!response) {
stdio.printError('Aborting command.'); stdio.printError('Aborting command.');
writeStateToFile(stateFile, state, stdio.logs); writeStateToFile(stateFile, state, stdio.logs);
return; return;
}
} }
}
await framework.pushRef( await framework.pushRef(
fromRef: 'HEAD', fromRef: 'HEAD',
// Explicitly create new branch // Explicitly create new branch
toRef: 'refs/heads/${state.framework.workingBranch}', toRef: 'refs/heads/${state.framework.workingBranch}',
remote: state.framework.mirror.name, remote: state.framework.mirror.name,
); );
break; break;
case pb.ReleasePhase.PUBLISH_VERSION: case pb.ReleasePhase.PUBLISH_VERSION:
stdio.printStatus('Please ensure that you have merged your framework PR and that'); stdio.printStatus('Please ensure that you have merged your framework PR and that');
stdio.printStatus('post-submit CI has finished successfully.\n'); stdio.printStatus('post-submit CI has finished successfully.\n');
final Remote upstream = Remote( final Remote upstream = Remote(
name: RemoteName.upstream, name: RemoteName.upstream,
url: state.framework.upstream.url, url: state.framework.upstream.url,
); );
final FrameworkRepository framework = FrameworkRepository( final FrameworkRepository framework = FrameworkRepository(
checkouts, checkouts,
// We explicitly want to check out the merged version from upstream // We explicitly want to check out the merged version from upstream
initialRef: '${upstream.name}/${state.framework.candidateBranch}', initialRef: '${upstream.name}/${state.framework.candidateBranch}',
upstreamRemote: upstream, upstreamRemote: upstream,
previousCheckoutLocation: state.framework.checkoutPath, previousCheckoutLocation: state.framework.checkoutPath,
);
final String headRevision = await framework.reverseParse('HEAD');
if (autoAccept == false) {
final bool response = prompt(
'Are you ready to tag commit $headRevision as ${state.releaseVersion}\n'
'and push to remote ${state.framework.upstream.url}?',
stdio,
); );
if (!response) { final String headRevision = await framework.reverseParse('HEAD');
stdio.printError('Aborting command.'); if (autoAccept == false) {
writeStateToFile(stateFile, state, stdio.logs); final bool response = prompt(
return; 'Are you ready to tag commit $headRevision as ${state.releaseVersion}\n'
'and push to remote ${state.framework.upstream.url}?',
stdio,
);
if (!response) {
stdio.printError('Aborting command.');
writeStateToFile(stateFile, state, stdio.logs);
return;
}
} }
} await framework.tag(headRevision, state.releaseVersion, upstream.name);
await framework.tag(headRevision, state.releaseVersion, upstream.name); break;
break; case pb.ReleasePhase.PUBLISH_CHANNEL:
case pb.ReleasePhase.PUBLISH_CHANNEL: final Remote upstream = Remote(
final Remote upstream = Remote( name: RemoteName.upstream,
name: RemoteName.upstream, url: state.framework.upstream.url,
url: state.framework.upstream.url,
);
final FrameworkRepository framework = FrameworkRepository(
checkouts,
// We explicitly want to check out the merged version from upstream
initialRef: '${upstream.name}/${state.framework.candidateBranch}',
upstreamRemote: upstream,
previousCheckoutLocation: state.framework.checkoutPath,
);
final String headRevision = await framework.reverseParse('HEAD');
if (autoAccept == false) {
// dryRun: true means print out git command
await framework.pushRef(
fromRef: headRevision,
toRef: state.releaseChannel,
remote: state.framework.upstream.url,
force: force,
dryRun: true,
); );
final FrameworkRepository framework = FrameworkRepository(
final bool response = prompt( checkouts,
'Are you ready to publish this release?', // We explicitly want to check out the merged version from upstream
stdio, initialRef: '${upstream.name}/${state.framework.candidateBranch}',
upstreamRemote: upstream,
previousCheckoutLocation: state.framework.checkoutPath,
); );
if (!response) { final String headRevision = await framework.reverseParse('HEAD');
stdio.printError('Aborting command.'); if (autoAccept == false) {
writeStateToFile(stateFile, state, stdio.logs); // dryRun: true means print out git command
return; await framework.pushRef(
fromRef: headRevision,
toRef: state.releaseChannel,
remote: state.framework.upstream.url,
force: force,
dryRun: true,
);
final bool response = prompt(
'Are you ready to publish this release?',
stdio,
);
if (!response) {
stdio.printError('Aborting command.');
writeStateToFile(stateFile, state, stdio.logs);
return;
}
} }
} await framework.pushRef(
await framework.pushRef( fromRef: headRevision,
fromRef: headRevision, toRef: state.releaseChannel,
toRef: state.releaseChannel, remote: state.framework.upstream.url,
remote: state.framework.upstream.url, force: force,
force: force,
);
break;
case pb.ReleasePhase.VERIFY_RELEASE:
stdio.printStatus(
'The current status of packaging builds can be seen at:\n'
'\t$kLuciPackagingConsoleLink',
);
if (autoAccept == false) {
final bool response = prompt(
'Have all packaging builds finished successfully?',
stdio,
); );
if (!response) { break;
stdio.printError('Aborting command.'); case pb.ReleasePhase.VERIFY_RELEASE:
writeStateToFile(stateFile, state, stdio.logs); stdio.printStatus(
return; 'The current status of packaging builds can be seen at:\n'
'\t$kLuciPackagingConsoleLink',
);
if (autoAccept == false) {
final bool response = prompt(
'Have all packaging builds finished successfully?',
stdio,
);
if (!response) {
stdio.printError('Aborting command.');
writeStateToFile(stateFile, state, stdio.logs);
return;
}
} }
} break;
break; case pb.ReleasePhase.RELEASE_COMPLETED:
case pb.ReleasePhase.RELEASE_COMPLETED: throw ConductorException('This release is finished.');
throw ConductorException('This release is finished.'); }
final ReleasePhase nextPhase = state_import.getNextPhase(state.currentPhase);
stdio.printStatus('\nUpdating phase from ${state.currentPhase} to $nextPhase...\n');
state.currentPhase = nextPhase;
stdio.printStatus(state_import.phaseInstructions(state));
writeStateToFile(stateFile, state, stdio.logs);
}
/// Persist the state to a file.
@visibleForOverriding
void writeStateToFile(File file, pb.ConductorState state, [List<String> logs = const <String>[]]) {
state_import.writeStateToFile(file, state, logs);
}
@visibleForTesting
bool prompt(String message, Stdio stdio) {
stdio.write('${message.trim()} (y/n) ');
final String response = stdio.readLineSync().trim();
final String firstChar = response[0].toUpperCase();
if (firstChar == 'Y') {
return true;
}
if (firstChar == 'N') {
return false;
}
throw ConductorException(
'Unknown user input (expected "y" or "n"): $response',
);
} }
final ReleasePhase nextPhase = getNextPhase(state.currentPhase);
stdio.printStatus('\nUpdating phase from ${state.currentPhase} to $nextPhase...\n');
state.currentPhase = nextPhase;
stdio.printStatus(phaseInstructions(state));
writeStateToFile(stateFile, state, stdio.logs); /// Read the state from a file.
@visibleForOverriding
pb.ConductorState readStateFromFile(File file) => state_import.readStateFromFile(file);
} }
...@@ -1052,6 +1052,34 @@ void main() { ...@@ -1052,6 +1052,34 @@ void main() {
}, onPlatform: <String, dynamic>{ }, onPlatform: <String, dynamic>{
'windows': const Skip('Flutter Conductor only supported on macos/linux'), 'windows': const Skip('Flutter Conductor only supported on macos/linux'),
}); });
group('prompt', () {
test('throws if user inputs character that is not "y" or "n"', () {
final FileSystem fileSystem = MemoryFileSystem.test();
final TestStdio stdio = TestStdio(
stdin: <String>['x'],
verbose: true,
);
final Checkouts checkouts = Checkouts(
fileSystem: fileSystem,
parentDirectory: fileSystem.directory('/'),
platform: FakePlatform(),
processManager: FakeProcessManager.empty(),
stdio: stdio,
);
final NextContext context = NextContext(
autoAccept: false,
force: false,
checkouts: checkouts,
stateFile: fileSystem.file('/statefile.json'),
);
expect(
() => context.prompt('Asking a question?', stdio),
throwsExceptionWith('Unknown user input (expected "y" or "n")'),
);
});
});
} }
void _initializeCiYamlFile( void _initializeCiYamlFile(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment