diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches.dart b/packages/shorebird_cli/lib/src/commands/patches/patches.dart index 94488ca9..3538da2e 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/patches.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/patches.dart @@ -1,3 +1,5 @@ export 'patches_command.dart'; +export 'patches_info_command.dart'; +export 'patches_list_command.dart'; export 'promote_command.dart'; export 'set_track_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart index f6b2f4aa..cef0108e 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/patches_command.dart @@ -7,6 +7,8 @@ import 'package:shorebird_cli/src/shorebird_command.dart'; class PatchesCommand extends ShorebirdCommand { /// {@macro patches_command} PatchesCommand() { + addSubcommand(PatchesInfoCommand()); + addSubcommand(PatchesListCommand()); addSubcommand(PromoteCommand()); addSubcommand(SetTrackCommand()); } diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches_info_command.dart b/packages/shorebird_cli/lib/src/commands/patches/patches_info_command.dart new file mode 100644 index 00000000..7f29646b --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/patches_info_command.dart @@ -0,0 +1,135 @@ +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/formatters/file_size_formatter.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template patches_info_command} +/// `shorebird patches info` +/// Show details for a specific patch. +/// {@endtemplate} +class PatchesInfoCommand extends ShorebirdCommand { + /// {@macro patches_info_command} + PatchesInfoCommand() { + argParser + ..addOption( + CommonArguments.releaseVersionArg.name, + help: CommonArguments.patchReleaseVersionDescription, + mandatory: true, + ) + ..addOption( + 'patch-number', + help: 'The patch number to show details for (e.g. "1").', + mandatory: true, + ) + ..addOption( + CommonArguments.appIdArg.name, + help: CommonArguments.appIdArg.description, + ) + ..addOption( + CommonArguments.flavorArg.name, + help: 'The product flavor to query patches for (e.g. "prod").', + ); + } + + @override + String get name => 'info'; + + @override + String get description => + 'Show details for a specific patch.\n\n' + 'Example output:\n' + ' ID: 42\n' + ' Number: 1\n' + ' Track: stable\n' + ' Rolled back: no\n' + ' Notes: Optional patch notes.\n' + ' Artifacts:\n' + ' android arm64-v8a 1.20 MB\n' + ' android armeabi-v7a 1.10 MB\n' + ' ios arm64 896 KB\n\n' + '${ShorebirdCommand.jsonHint('shorebird patches info --release-version 1.0.0+1 --patch-number 1 --app-id --json')}'; + + @override + Future run() async { + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; + + final releaseVersion = + results[CommonArguments.releaseVersionArg.name] as String; + final patchNumber = int.parse(results['patch-number'] as String); + + final Release release; + final List patches; + try { + release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + patches = await codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: release.id, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: + 'Failed to fetch patch $patchNumber ' + 'for release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + + final patch = patches.firstWhereOrNull((p) => p.number == patchNumber); + if (patch == null) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: + 'No patch found with number $patchNumber ' + 'for release "$releaseVersion".', + ); + return ExitCode.usage.code; + } + logger + ..err('No patch found with number $patchNumber.') + ..info( + 'Available patches: ${patches.map((p) => p.number).join(', ')}', + ); + return ExitCode.usage.code; + } + + if (isJsonMode) { + emitJsonSuccess({'patch': patch.toJson()}); + return ExitCode.success.code; + } + + logger.info('ID: ${patch.id}'); + logger.info('Number: ${patch.number}'); + if (patch.channel != null) { + logger.info('Track: ${patch.channel}'); + } + logger.info('Rolled back: ${patch.isRolledBack ? 'yes' : 'no'}'); + if (patch.notes != null) { + logger.info('Notes: ${patch.notes}'); + } + if (patch.artifacts.isNotEmpty) { + logger.info('Artifacts:'); + for (final artifact in patch.artifacts) { + final platform = artifact.platform.value.padRight(8); + final arch = artifact.arch.padRight(12); + logger.info(' $platform $arch ${formatBytes(artifact.size)}'); + } + } + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/patches/patches_list_command.dart b/packages/shorebird_cli/lib/src/commands/patches/patches_list_command.dart new file mode 100644 index 00000000..cb36e6c0 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patches/patches_list_command.dart @@ -0,0 +1,99 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/logging.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template patches_list_command} +/// `shorebird patches list` +/// List patches for a release. +/// {@endtemplate} +class PatchesListCommand extends ShorebirdCommand { + /// {@macro patches_list_command} + PatchesListCommand() { + argParser + ..addOption( + CommonArguments.releaseVersionArg.name, + help: CommonArguments.patchReleaseVersionDescription, + mandatory: true, + ) + ..addOption( + CommonArguments.appIdArg.name, + help: CommonArguments.appIdArg.description, + ) + ..addOption( + CommonArguments.flavorArg.name, + help: 'The product flavor to list patches for (e.g. "prod").', + ); + } + + @override + String get name => 'list'; + + @override + String get description => + 'List patches for a release.\n\n' + 'Example output (one line per patch):\n' + ' 42 #1 track: stable\n' + ' 43 #2 [no track]\n' + ' 44 #3 track: beta [rolled back]\n\n' + '${ShorebirdCommand.jsonHint('shorebird patches list --release-version 1.0.0+1 --app-id --json')}'; + + @override + Future run() async { + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; + + final releaseVersion = + results[CommonArguments.releaseVersionArg.name] as String; + + final Release release; + final List patches; + try { + release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + patches = await codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: release.id, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch patches for release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + + if (isJsonMode) { + emitJsonSuccess({ + 'patches': patches.map((p) => p.toJson()).toList(), + }); + return ExitCode.success.code; + } + + if (patches.isEmpty) { + logger.info('No patches found.'); + return ExitCode.success.code; + } + + for (final patch in patches) { + final id = patch.id; + final number = lightCyan.wrap('#${patch.number}'); + final channel = patch.channel != null + ? ' track: ${patch.channel}' + : ' [no track]'; + final rolledBack = patch.isRolledBack ? ' [rolled back]' : ''; + logger.info('$id $number$channel$rolledBack'); + } + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart b/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart index 5ea6c358..5147fd91 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/promote_command.dart @@ -5,6 +5,7 @@ import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/deployment_track.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; @@ -41,8 +42,22 @@ class PromoteCommand extends ShorebirdCommand { @override Future run() async { + // Deprecated commands don't grow new surface area. Refuse --json with + // a structured envelope that points to the replacement command, instead + // of leaking a free-form deprecation warning to stdout. + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: + 'shorebird patches promote is deprecated and does not support ' + '--json output.', + hint: 'Use `shorebird patches set-track --track=stable` instead.', + ); + return ExitCode.usage.code; + } + logger.warn( - '''This command is deprecated and will be removed in a future release. Use `shorebird patches set-channel --channel=stable` instead.''', + '''This command is deprecated and will be removed in a future release. Use `shorebird patches set-track --track=stable` instead.''', ); try { diff --git a/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart b/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart index c3abf3b3..1682e60a 100644 --- a/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patches/set_track_command.dart @@ -2,30 +2,25 @@ import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// {@template set_track_command} -/// Sets the channel of a patch. +/// Sets the track of a patch. /// /// Sample usage: /// ```sh /// shorebird patches set-track --release=1.0.0+1 --patch=1 --track=beta /// ``` /// -/// {@endtemplate +/// {@endtemplate} class SetTrackCommand extends ShorebirdCommand { /// {@macro set_track_command} SetTrackCommand() { argParser - ..addOption( - 'flavor', - help: 'The product flavor to use when building the app.', - ) ..addOption( 'release', help: CommonArguments.patchReleaseVersionDescription, @@ -33,7 +28,7 @@ class SetTrackCommand extends ShorebirdCommand { ) ..addOption( 'patch', - help: 'The patch number to set the channel for (ex: "1").', + help: 'The patch number to set the track for (e.g. "1").', mandatory: true, ) ..addOption( @@ -43,6 +38,14 @@ class SetTrackCommand extends ShorebirdCommand { '("stable", "beta", "staging", or any custom track name ' 'up to ${CommonArguments.trackNameMaxLength} characters).', mandatory: true, + ) + ..addOption( + CommonArguments.appIdArg.name, + help: CommonArguments.appIdArg.description, + ) + ..addOption( + CommonArguments.flavorArg.name, + help: 'The product flavor to use when building the app.', ); } @@ -50,27 +53,32 @@ class SetTrackCommand extends ShorebirdCommand { String get name => 'set-track'; @override - String get description => 'Sets the track of a patch.'; + String get description => + 'Sets the track of a patch.\n\n' + 'Example output:\n' + ' Patch 1 on release 1.0.0+1 is now in channel stable!\n\n' + '${ShorebirdCommand.jsonHint('shorebird patches set-track --release 1.0.0+1 --patch 1 --track stable --app-id --json')}'; @override Future run() async { - try { - await shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - ); - } on PreconditionFailedException catch (error) { - return error.exitCode.code; - } + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; final releaseVersion = results['release'] as String; final patchNumber = int.parse(results['patch'] as String); - final flavor = results.findOption('flavor', argParser: argParser); - final appId = shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); final targetChannel = results['track'] as String; if (targetChannel.isEmpty || targetChannel.length > CommonArguments.trackNameMaxLength) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: + 'Track name must be between 1 and ' + '${CommonArguments.trackNameMaxLength} characters.', + ); + return ExitCode.usage.code; + } logger.err( 'Track name must be between 1 and ' '${CommonArguments.trackNameMaxLength} characters.', @@ -78,29 +86,70 @@ class SetTrackCommand extends ShorebirdCommand { return ExitCode.usage.code; } - final release = await codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: releaseVersion, - ); - final patches = await codePushClientWrapper.getReleasePatches( - appId: appId, - releaseId: release.id, - ); + final Release release; + final List patches; + try { + release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + patches = await codePushClientWrapper.getReleasePatches( + appId: appId, + releaseId: release.id, + ); + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch patches for release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; + } + if (patches.isEmpty) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: 'No patches found for release "$releaseVersion".', + ); + return ExitCode.usage.code; + } logger.err('No patches found for release $releaseVersion'); return ExitCode.usage.code; } - final patchToPromote = patches.firstWhereOrNull( - (patch) => patch.number == patchNumber, - ); - if (patchToPromote == null) { + final patch = patches.firstWhereOrNull((p) => p.number == patchNumber); + if (patch == null) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: + 'No patch found with number $patchNumber ' + 'for release "$releaseVersion".', + ); + return ExitCode.usage.code; + } logger ..err('No patch found with number $patchNumber') ..info( - '''Available patches: ${patches.map((patch) => patch.number).join(', ')}''', + 'Available patches: ${patches.map((p) => p.number).join(', ')}', ); + return ExitCode.usage.code; + } + if (patch.channel == targetChannel) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.usageError, + message: 'Patch $patchNumber is already in channel "$targetChannel".', + ); + return ExitCode.usage.code; + } + logger.err( + 'Patch ${patch.number} is already in channel $targetChannel', + ); return ExitCode.usage.code; } @@ -109,15 +158,26 @@ class SetTrackCommand extends ShorebirdCommand { name: targetChannel, ); if (channel == null) { - final shouldCreateChannel = logger.confirm( - '''No channel named ${lightCyan.wrap(targetChannel)} found. Do you want to create it?''', + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.interactivePromptRequired, + message: 'Channel "$targetChannel" does not exist.', + hint: + 'Create it by publishing a patch with --track=$targetChannel, ' + 'or run without --json to create it interactively.', + ); + return ExitCode.usage.code; + } + final shouldCreate = logger.confirm( + 'No channel named ${lightCyan.wrap(targetChannel)} found. ' + 'Do you want to create it?', hint: 'Pass --track= to use an existing channel. ' 'Channels are auto-created when a patch is published with ' '--track=; set-track itself has no flag to skip this ' 'confirmation.', ); - if (!shouldCreateChannel) { + if (!shouldCreate) { return ExitCode.success.code; } @@ -127,21 +187,36 @@ class SetTrackCommand extends ShorebirdCommand { ); } - if (patchToPromote.channel == targetChannel) { - logger.err( - 'Patch ${patchToPromote.number} is already in channel $targetChannel', + try { + await codePushClientWrapper.promotePatch( + appId: appId, + patchId: patch.id, + channel: channel, ); - return ExitCode.usage.code; + } on ProcessExit catch (e) { + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.softwareError, + message: + 'Failed to set track for patch $patchNumber ' + 'of release "$releaseVersion".', + ); + return e.exitCode; + } + rethrow; } - await codePushClientWrapper.promotePatch( - appId: appId, - patchId: patchToPromote.id, - channel: channel, - ); + if (isJsonMode) { + emitJsonSuccess({ + 'release_version': releaseVersion, + 'patch_number': patchNumber, + 'track': targetChannel, + }); + return ExitCode.success.code; + } logger.success( - '''Patch ${patchToPromote.number} on release $releaseVersion is now in channel $targetChannel!''', + 'Patch $patchNumber on release $releaseVersion is now in channel $targetChannel!', ); return ExitCode.success.code; diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart b/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart index 4170214b..fb6a500c 100644 --- a/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart +++ b/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart @@ -1,13 +1,9 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -53,29 +49,12 @@ class ReleasesInfoCommand extends ShorebirdCommand { ' ios: draft\n' ' macos: active\n' ' windows: active\n\n' - 'Pass --json (global flag) for machine-readable output with all fields:\n' - ' shorebird releases info --release-version 1.0.0+1 --app-id --json'; + '${ShorebirdCommand.jsonHint('shorebird releases info --release-version 1.0.0+1 --app-id --json')}'; @override Future run() async { - final explicitAppId = results[CommonArguments.appIdArg.name] as String?; - - try { - await shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: explicitAppId == null, - ); - } on PreconditionFailedException catch (error) { - return error.exitCode.code; - } - - final flavor = results.findOption( - CommonArguments.flavorArg.name, - argParser: argParser, - ); - final appId = - explicitAppId ?? - shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; final releaseVersion = results[CommonArguments.releaseVersionArg.name] as String; diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart b/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart index 90bf404e..ce178a51 100644 --- a/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart +++ b/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart @@ -1,13 +1,9 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/common_arguments.dart'; -import 'package:shorebird_cli/src/config/config.dart'; -import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; -import 'package:shorebird_cli/src/shorebird_env.dart'; -import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -42,29 +38,12 @@ class ReleasesListCommand extends ShorebirdCommand { 'List releases for an app.\n\n' 'Example output (one line per release):\n' ' 42 1.0.0+1 android: active, ios: draft 3.27.0\n\n' - 'Pass --json (global flag) for machine-readable output with all fields:\n' - ' shorebird releases list --app-id --json'; + '${ShorebirdCommand.jsonHint('shorebird releases list --app-id --json')}'; @override Future run() async { - final explicitAppId = results[CommonArguments.appIdArg.name] as String?; - - try { - await shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: explicitAppId == null, - ); - } on PreconditionFailedException catch (error) { - return error.exitCode.code; - } - - final flavor = results.findOption( - CommonArguments.flavorArg.name, - argParser: argParser, - ); - final appId = - explicitAppId ?? - shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + final (:appId, :errorCode) = await resolveAppId(); + if (errorCode != null) return errorCode; final platformFilter = ReleasePlatform.maybeFromJson( results['platform'] as String?, diff --git a/packages/shorebird_cli/lib/src/shorebird_command.dart b/packages/shorebird_cli/lib/src/shorebird_command.dart index 0c0845a5..15a4ae8e 100644 --- a/packages/shorebird_cli/lib/src/shorebird_command.dart +++ b/packages/shorebird_cli/lib/src/shorebird_command.dart @@ -5,9 +5,14 @@ import 'package:args/command_runner.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/config/shorebird_yaml.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/interactive_mode.dart' as interactive_mode; import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// Signature for a function which takes a list of bytes and returns a hash. @@ -87,6 +92,39 @@ abstract class ShorebirdCommand extends Command { JsonResult.success(data: data, command: fullCommandName).write(); } + /// Suffix appended to command descriptions to advertise `--json` mode. + /// + /// [example] should be a complete example invocation, e.g.: + /// `'shorebird releases list --app-id --json'` + static String jsonHint(String example) => + 'Pass --json (global flag) for machine-readable output with all fields:\n' + ' $example'; + + /// Resolves the app ID from `--app-id` or `shorebird.yaml`, validating + /// preconditions in the process. + /// + /// Returns `(appId: , errorCode: null)` on success, or + /// `(appId: '', errorCode: )` if precondition validation failed. + Future<({String appId, int? errorCode})> resolveAppId() async { + final explicitAppId = results[CommonArguments.appIdArg.name] as String?; + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: explicitAppId == null, + ); + } on PreconditionFailedException catch (error) { + return (appId: '', errorCode: error.exitCode.code); + } + final flavor = results.findOption( + CommonArguments.flavorArg.name, + argParser: argParser, + ); + final appId = + explicitAppId ?? + shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + return (appId: appId, errorCode: null); + } + /// Emits a JSON error envelope to stdout. /// /// Only call this when [isJsonMode] is true. diff --git a/packages/shorebird_cli/test/src/commands/patches/patches_info_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/patches_info_command_test.dart new file mode 100644 index 00000000..cda77db9 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patches/patches_info_command_test.dart @@ -0,0 +1,457 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/patches/patches_info_command.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(PatchesInfoCommand, () { + const appId = 'test-app-id'; + const releaseVersion = '1.0.0+1'; + const releaseId = 42; + const patchNumber = 3; + const shorebirdYaml = ShorebirdYaml(appId: appId); + final release = Release( + id: releaseId, + appId: appId, + version: releaseVersion, + flutterRevision: 'abc123', + flutterVersion: '3.27.0', + displayName: releaseVersion, + platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active}, + createdAt: DateTime(2026, 1, 15), + updatedAt: DateTime(2026, 1, 16), + ); + const patch = ReleasePatch( + id: 10, + number: patchNumber, + channel: 'stable', + isRolledBack: false, + artifacts: [], + notes: 'A test patch.', + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdEnv shorebirdEnv; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late Progress progress; + late PatchesInfoCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + progress = MockProgress(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdValidator = MockShorebirdValidator(); + command = runWithOverrides(PatchesInfoCommand.new) + ..testArgResults = argResults; + + when(() => logger.progress(any())).thenReturn(progress); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['app-id']).thenReturn(null); + when(() => argResults['flavor']).thenReturn(null); + when(() => argResults['release-version']).thenReturn(releaseVersion); + when( + () => argResults['patch-number'], + ).thenReturn(patchNumber.toString()); + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenAnswer((_) async {}); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [patch]); + }); + + test('has correct description', () { + expect( + command.description, + startsWith('Show details for a specific patch.'), + ); + }); + + group('when validation fails', () { + final exception = ShorebirdNotInitializedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenThrow(exception); + }); + + test('returns the precondition failure exit code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + group('when --app-id is provided', () { + setUp(() { + when(() => argResults['app-id']).thenReturn('explicit-app-id'); + }); + + test('does not require shorebird to be initialized', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + test('fetches patches for the explicit app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: 'explicit-app-id', + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + + group('when --app-id is not provided', () { + test('requires shorebird to be initialized', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + ), + ).called(1); + }); + + test('fetches patches using app id from shorebird.yaml', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ), + ).called(1); + }); + + group('when --flavor is provided', () { + const flavor = 'staging'; + const flavoredAppId = 'flavored-app-id'; + const flavoredYaml = ShorebirdYaml( + appId: appId, + flavors: {flavor: flavoredAppId}, + ); + + setUp(() { + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(flavoredYaml); + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults.wasParsed('flavor')).thenReturn(true); + when( + () => codePushClientWrapper.getRelease( + appId: flavoredAppId, + releaseVersion: releaseVersion, + ), + ).thenAnswer((_) async => release); + }); + + test('fetches patches for the flavored app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: flavoredAppId, + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + }); + + group('when the patch number is not found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 99, + number: 99, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ), + ], + ); + }); + + test('prints an error and returns usage exit code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + any(that: contains('No patch found with number $patchNumber')), + ), + ).called(1); + verify( + () => logger.info(any(that: contains('Available patches'))), + ).called(1); + }); + }); + + group('human-readable output', () { + test('prints labelled patch fields in order', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info('ID: 10')).called(1); + verify(() => logger.info('Number: $patchNumber')).called(1); + verify(() => logger.info('Track: stable')).called(1); + verify(() => logger.info('Rolled back: no')).called(1); + verify(() => logger.info('Notes: A test patch.')).called(1); + }); + + group('when patch has artifacts', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + ReleasePatch( + id: 10, + number: patchNumber, + channel: 'stable', + isRolledBack: false, + artifacts: [ + PatchArtifact( + id: 1, + patchId: 10, + arch: 'arm64-v8a', + platform: ReleasePlatform.android, + hash: 'abc123', + size: 1258291, + createdAt: DateTime(2026, 1, 15), + ), + ], + ), + ], + ); + }); + + test('prints column-padded artifact line w/ formatted size', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info('Artifacts:')).called(1); + verify( + () => logger.info(' android arm64-v8a 1.20 MB'), + ).called(1); + }); + }); + + group('when notes is null', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 10, + number: patchNumber, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ), + ], + ); + }); + + test('does not print Notes line', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verifyNever(() => logger.info(any(that: contains('Notes:')))); + }); + }); + + group('when patch is rolled back', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 10, + number: patchNumber, + channel: 'stable', + isRolledBack: true, + artifacts: [], + ), + ], + ); + }); + + test('prints "Rolled back: yes"', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('Rolled back: yes'))), + ).called(1); + }); + }); + }); + + group('when API fetch fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'fetch_failed', + ); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with patch details', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['patch'], isA>()); + final patchData = data['patch'] as Map; + expect(patchData['number'], patchNumber); + }); + + group('when the patch number is not found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => []); + }); + + test('emits JSON error envelope and returns usage exit code', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/patches/patches_list_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/patches_list_command_test.dart new file mode 100644 index 00000000..c566b278 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patches/patches_list_command_test.dart @@ -0,0 +1,374 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/patches/patches_list_command.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../helpers.dart'; +import '../../mocks.dart'; + +void main() { + group(PatchesListCommand, () { + const appId = 'test-app-id'; + const releaseVersion = '1.0.0+1'; + const releaseId = 42; + const shorebirdYaml = ShorebirdYaml(appId: appId); + final release = Release( + id: releaseId, + appId: appId, + version: releaseVersion, + flutterRevision: 'abc123', + flutterVersion: '3.27.0', + displayName: releaseVersion, + platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active}, + createdAt: DateTime(2026, 1, 15), + updatedAt: DateTime(2026, 1, 16), + ); + const patch = ReleasePatch( + id: 7, + number: 1, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ); + + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late ShorebirdEnv shorebirdEnv; + late ShorebirdValidator shorebirdValidator; + late ShorebirdLogger logger; + late Progress progress; + late PatchesListCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + progress = MockProgress(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdValidator = MockShorebirdValidator(); + command = runWithOverrides(PatchesListCommand.new) + ..testArgResults = argResults; + + when(() => logger.progress(any())).thenReturn(progress); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['app-id']).thenReturn(null); + when(() => argResults['flavor']).thenReturn(null); + when(() => argResults['release-version']).thenReturn(releaseVersion); + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenAnswer((_) async {}); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => [patch]); + }); + + test('has correct description', () { + expect(command.description, startsWith('List patches for a release.')); + }); + + group('when validation fails', () { + final exception = ShorebirdNotInitializedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenThrow(exception); + }); + + test('returns the precondition failure exit code', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(exception.exitCode.code)); + }); + }); + + group('when --app-id is provided', () { + setUp(() { + when(() => argResults['app-id']).thenReturn('explicit-app-id'); + }); + + test('does not require shorebird to be initialized', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + test('fetches patches for the explicit app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: 'explicit-app-id', + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + + group('when --app-id is not provided', () { + test('requires shorebird to be initialized', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + ), + ).called(1); + }); + + test('fetches patches using app id from shorebird.yaml', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ), + ).called(1); + }); + + group('when --flavor is provided', () { + const flavor = 'staging'; + const flavoredAppId = 'flavored-app-id'; + const flavoredYaml = ShorebirdYaml( + appId: appId, + flavors: {flavor: flavoredAppId}, + ); + + setUp(() { + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(flavoredYaml); + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults.wasParsed('flavor')).thenReturn(true); + when( + () => codePushClientWrapper.getRelease( + appId: flavoredAppId, + releaseVersion: releaseVersion, + ), + ).thenAnswer((_) async => release); + }); + + test('fetches patches for the flavored app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: flavoredAppId, + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + }); + + group('when there are no patches', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => []); + }); + + test('prints a message', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify(() => logger.info('No patches found.')).called(1); + }); + }); + + group('human-readable output', () { + test('prints each patch with id, number, and channel', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info( + any(that: allOf(contains('7'), contains('#1'), contains('stable'))), + ), + ).called(1); + }); + + group('when patch has no track', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 2, + number: 2, + isRolledBack: false, + artifacts: [], + ), + ], + ); + }); + + test('indicates the patch has no track', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('[no track]'))), + ).called(1); + }); + }); + + group('when patch is rolled back', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 2, + number: 2, + channel: 'stable', + isRolledBack: true, + artifacts: [], + ), + ], + ); + }); + + test('indicates the patch is rolled back', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('[rolled back]'))), + ).called(1); + }); + }); + }); + + group('when API fetch fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'fetch_failed', + ); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with patches list', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['patches'], isA>()); + expect((data['patches'] as List).length, 1); + }); + + test('does not use a progress spinner', () async { + final captured = []; + await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + verifyNever(() => logger.progress(any())); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart index 3f41f211..af887e7d 100644 --- a/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patches/promote_command_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:args/args.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -6,6 +8,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/patches/patches.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/deployment_track.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; @@ -13,6 +16,7 @@ import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; import 'package:test/test.dart'; import '../../fakes.dart'; +import '../../helpers.dart'; import '../../mocks.dart'; void main() { @@ -60,6 +64,7 @@ void main() { body, values: { codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), loggerRef.overrideWith(() => logger), shorebirdEnvRef.overrideWith(() => shorebirdEnv), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), @@ -131,7 +136,7 @@ void main() { expect(result, equals(ExitCode.success.code)); verify( () => logger.warn( - '''This command is deprecated and will be removed in a future release. Use `shorebird patches set-channel --channel=stable` instead.''', + '''This command is deprecated and will be removed in a future release. Use `shorebird patches set-track --track=stable` instead.''', ), ).called(1); }); @@ -234,5 +239,43 @@ void main() { ).called(1); }); }); + + group('--json', () { + test( + 'refuses with structured envelope and points to set-track', + () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + final error = decoded['error'] as Map; + expect(error['code'], 'usage_error'); + expect(error['hint'], contains('set-track')); + verifyNever( + () => codePushClientWrapper.promotePatch( + appId: any(named: 'appId'), + patchId: any(named: 'patchId'), + channel: any(named: 'channel'), + ), + ); + }, + ); + }); }); } diff --git a/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart b/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart index f48e466e..f47c0349 100644 --- a/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patches/set_track_command_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:args/args.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -5,39 +7,43 @@ import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/patches/set_track_command.dart'; import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/src/base/process.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; import '../../fakes.dart'; +import '../../helpers.dart'; import '../../mocks.dart'; void main() { group(SetTrackCommand, () { const appId = 'app-id'; + const releaseVersion = '1.0.0'; + const patchNumber = 1; const shorebirdYaml = ShorebirdYaml(appId: appId); - const patchNumberArg = 1; final release = Release( id: 0, appId: appId, - version: '1.0.0', + version: releaseVersion, flutterRevision: 'flutter-revision', flutterVersion: 'flutter-version', - displayName: '1.0.0', + displayName: releaseVersion, platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active}, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); const patch = ReleasePatch( id: 0, - number: patchNumberArg, + number: patchNumber, channel: 'stable', isRolledBack: false, artifacts: [], ); - const newChannel = Channel( + const targetChannel = Channel( id: 1, appId: appId, name: 'new-channel', @@ -48,7 +54,6 @@ void main() { late ShorebirdEnv shorebirdEnv; late ShorebirdValidator shorebirdValidator; late ShorebirdLogger logger; - late SetTrackCommand command; R runWithOverrides(R Function() body) { @@ -56,6 +61,7 @@ void main() { body, values: { codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => false), loggerRef.overrideWith(() => logger), shorebirdEnvRef.overrideWith(() => shorebirdEnv), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), @@ -76,11 +82,11 @@ void main() { when(() => argResults.wasParsed(any())).thenReturn(false); when(() => argResults.rest).thenReturn([]); - when(() => argResults['release']).thenReturn('1.0.0'); - when( - () => argResults['patch'], - ).thenReturn(patchNumberArg.toString()); - when(() => argResults['track']).thenReturn(newChannel.name); + when(() => argResults['app-id']).thenReturn(null); + when(() => argResults['flavor']).thenReturn(null); + when(() => argResults['release']).thenReturn(releaseVersion); + when(() => argResults['patch']).thenReturn(patchNumber.toString()); + when(() => argResults['track']).thenReturn(targetChannel.name); when( () => shorebirdValidator.validatePreconditions( @@ -107,13 +113,13 @@ void main() { appId: any(named: 'appId'), name: any(named: 'name'), ), - ).thenAnswer((_) async => newChannel); + ).thenAnswer((_) async => targetChannel); when( () => codePushClientWrapper.createChannel( appId: any(named: 'appId'), name: any(named: 'name'), ), - ).thenAnswer((_) async => newChannel); + ).thenAnswer((_) async => targetChannel); when( () => codePushClientWrapper.promotePatch( appId: any(named: 'appId'), @@ -122,19 +128,21 @@ void main() { ), ).thenAnswer((_) async => {}); - command = SetTrackCommand()..testArgResults = argResults; + command = runWithOverrides(SetTrackCommand.new) + ..testArgResults = argResults; }); test('name is correct', () { expect(command.name, 'set-track'); }); - test('description is correct', () { - expect(command.description, 'Sets the track of a patch.'); + test('has correct description', () { + expect(command.description, startsWith('Sets the track of a patch.')); }); group('when validation fails', () { final exception = ShorebirdNotInitializedException(); + setUp(() { when( () => shorebirdValidator.validatePreconditions( @@ -147,6 +155,43 @@ void main() { test('exits with exit code from validation error', () async { final result = await runWithOverrides(command.run); expect(result, equals(exception.exitCode.code)); + }); + }); + + group('when --app-id is provided', () { + setUp(() { + when(() => argResults['app-id']).thenReturn('explicit-app-id'); + when( + () => codePushClientWrapper.getRelease( + appId: 'explicit-app-id', + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + }); + + test('does not require shorebird to be initialized', () async { + await runWithOverrides(command.run); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + ), + ).called(1); + }); + + test('uses the explicit app id', () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.getRelease( + appId: 'explicit-app-id', + releaseVersion: releaseVersion, + ), + ).called(1); + }); + }); + + group('when --app-id is not provided', () { + test('requires shorebird to be initialized', () async { + await runWithOverrides(command.run); verify( () => shorebirdValidator.validatePreconditions( checkUserIsAuthenticated: true, @@ -194,16 +239,16 @@ void main() { ).thenAnswer((_) async => []); }); - test('exits with code 70', () async { + test('exits with usage error', () async { final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.usage.code)); verify( - () => logger.err('No patches found for release 1.0.0'), + () => logger.err('No patches found for release $releaseVersion'), ).called(1); }); }); - group('when no patch matching arg values is found', () { + group('when no matching patch is found', () { setUp(() { when( () => codePushClientWrapper.getReleasePatches( @@ -214,7 +259,7 @@ void main() { (_) async => [ const ReleasePatch( id: 1, - number: patchNumberArg + 1, + number: patchNumber + 1, channel: 'stable', isRolledBack: false, artifacts: [], @@ -223,16 +268,47 @@ void main() { ); }); - test('exits with code 70', () async { + test('exits with usage error', () async { final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.usage.code)); verify( - () => logger.err('No patch found with number 1'), + () => logger.err('No patch found with number $patchNumber'), ).called(1); }); }); - group('when no channel with the specified name is found', () { + group('when patch is already in the specified channel', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + ReleasePatch( + id: 0, + number: patchNumber, + channel: targetChannel.name, + isRolledBack: false, + artifacts: const [], + ), + ], + ); + }); + + test('exits with usage error', () async { + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + 'Patch $patchNumber is already in channel ${targetChannel.name}', + ), + ).called(1); + }); + }); + + group('when channel does not exist', () { setUp(() { when( () => codePushClientWrapper.maybeGetChannel( @@ -246,43 +322,36 @@ void main() { }); test('prompts to create the channel', () async { - final result = await runWithOverrides(command.run); - expect(result, equals(ExitCode.success.code)); + await runWithOverrides(command.run); verify( () => logger.confirm( - '''No channel named ${lightCyan.wrap(newChannel.name)} found. Do you want to create it?''', + any(that: contains(targetChannel.name)), hint: any(named: 'hint'), ), ).called(1); }); - group('when user confirms to create the channel', () { + group('when user confirms channel creation', () { setUp(() { when( () => logger.confirm(any(), hint: any(named: 'hint')), ).thenReturn(true); }); - test('creates the channel', () async { + test('creates the channel and promotes the patch', () async { final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.success.code)); verify( () => codePushClientWrapper.createChannel( - appId: any(named: 'appId'), - name: any(named: 'name'), + appId: appId, + name: targetChannel.name, ), ).called(1); }); }); - group('when user declines to create the channel', () { - setUp(() { - when( - () => logger.confirm(any(), hint: any(named: 'hint')), - ).thenReturn(false); - }); - - test('exits with code 70', () async { + group('when user declines channel creation', () { + test('exits with success without promoting', () async { final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.success.code)); verifyNever( @@ -291,55 +360,327 @@ void main() { name: any(named: 'name'), ), ); + verifyNever( + () => codePushClientWrapper.promotePatch( + appId: any(named: 'appId'), + patchId: any(named: 'patchId'), + channel: any(named: 'channel'), + ), + ); }); }); }); - group('when patch is already in the specified channel', () { - setUp(() { - final patch = ReleasePatch( - id: 0, - number: patchNumberArg, - channel: newChannel.name, - isRolledBack: false, - artifacts: const [], - ); - when( - () => codePushClientWrapper.getReleasePatches( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - ), - ).thenAnswer((_) async => [patch]); - }); - - test('exits with code 70', () async { - final result = await runWithOverrides(command.run); - expect(result, equals(ExitCode.usage.code)); - verify( - () => logger.err( - 'Patch ${patch.number} is already in channel ${newChannel.name}', - ), - ).called(1); - }); - }); - - group('when patch is not in the specified channel', () { - test('promotes the patch to the specified channel', () async { + group('when patch is promoted successfully', () { + test('promotes patch and logs success', () async { final result = await runWithOverrides(command.run); expect(result, equals(ExitCode.success.code)); verify( () => codePushClientWrapper.promotePatch( appId: appId, patchId: patch.id, - channel: newChannel, + channel: targetChannel, ), ).called(1); verify( () => logger.success( - '''Patch ${patch.number} on release ${release.version} is now in channel ${newChannel.name}!''', + 'Patch $patchNumber on release $releaseVersion ' + 'is now in channel ${targetChannel.name}!', ), ).called(1); }); }); + + group('when API fetch fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits fetch_failed JSON error', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'fetch_failed', + ); + }); + }); + + group('when promotePatch fails', () { + setUp(() { + when( + () => codePushClientWrapper.promotePatch( + appId: any(named: 'appId'), + patchId: any(named: 'patchId'), + channel: any(named: 'channel'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('in human-readable mode, rethrows ProcessExit', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA(isA()), + ); + }); + + test('in --json mode, emits software_error JSON error', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'software_error', + ); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test( + 'emits JSON success with release_version, patch_number, track', + () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['release_version'], releaseVersion); + expect(data['patch_number'], patchNumber); + expect(data['track'], targetChannel.name); + }, + ); + + group('when channel does not exist', () { + setUp(() { + when( + () => codePushClientWrapper.maybeGetChannel( + appId: any(named: 'appId'), + name: any(named: 'name'), + ), + ).thenAnswer((_) async => null); + }); + + test('emits interactive_prompt_required error', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'interactive_prompt_required', + ); + }); + }); + + group('when patch is not found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer((_) async => []); + }); + + test('emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + + group('when track name is empty', () { + setUp(() { + when(() => argResults['track']).thenReturn(''); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + + group('when track name exceeds max length', () { + setUp(() { + when(() => argResults['track']).thenReturn('a' * 129); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + + group('when no patch with given number is found', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + const ReleasePatch( + id: 1, + number: patchNumber + 1, + channel: 'stable', + isRolledBack: false, + artifacts: [], + ), + ], + ); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + + group('when patch is already in target channel', () { + setUp(() { + when( + () => codePushClientWrapper.getReleasePatches( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + ), + ).thenAnswer( + (_) async => [ + ReleasePatch( + id: 0, + number: patchNumber, + channel: targetChannel.name, + isRolledBack: false, + artifacts: const [], + ), + ], + ); + }); + + test('emits usage_error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.usage.code)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + expect( + (decoded['error'] as Map)['code'], + 'usage_error', + ); + }); + }); + }); }); }