feat(shorebird_cli): add patches list/info; --json for set-track (#3740)

This commit is contained in:
nickshorebird
2026-05-05 13:35:43 -04:00
committed by GitHub
parent 09517b4d89
commit c0b104d272
13 changed files with 1707 additions and 168 deletions
@@ -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';
@@ -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());
}
@@ -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 <id> --json')}';
@override
Future<int> 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<ReleasePatch> 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;
}
}
@@ -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 <id> --json')}';
@override
Future<int> 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<ReleasePatch> 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;
}
}
@@ -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<int> 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 {
@@ -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 <id> --json')}';
@override
Future<int> 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<ReleasePatch> 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=<existing-channel> to use an existing channel. '
'Channels are auto-created when a patch is published with '
'--track=<name>; 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;
@@ -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 <id> --json';
'${ShorebirdCommand.jsonHint('shorebird releases info --release-version 1.0.0+1 --app-id <id> --json')}';
@override
Future<int> 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;
@@ -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 <id> --json';
'${ShorebirdCommand.jsonHint('shorebird releases list --app-id <id> --json')}';
@override
Future<int> 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?,
@@ -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<int> {
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 <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: <id>, errorCode: null)` on success, or
/// `(appId: '', errorCode: <code>)` 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.
@@ -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>(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<ProcessExit>()),
);
});
test('in --json mode, emits JSON error envelope', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['code'],
'fetch_failed',
);
});
});
group('--json', () {
R runJsonMode<R>(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 = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'success');
final data = decoded['data'] as Map<String, dynamic>;
expect(data['patch'], isA<Map<String, dynamic>>());
final patchData = data['patch'] as Map<String, dynamic>;
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 = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['code'],
'usage_error',
);
});
});
});
});
}
@@ -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>(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<ProcessExit>()),
);
});
test('in --json mode, emits JSON error envelope', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['code'],
'fetch_failed',
);
});
});
group('--json', () {
R runJsonMode<R>(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 = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'success');
final data = decoded['data'] as Map<String, dynamic>;
expect(data['patches'], isA<List<dynamic>>());
expect((data['patches'] as List<dynamic>).length, 1);
});
test('does not use a progress spinner', () async {
final captured = <String>[];
await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
verifyNever(() => logger.progress(any()));
});
});
});
}
@@ -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 = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
final error = decoded['error'] as Map<String, dynamic>;
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'),
),
);
},
);
});
});
}
@@ -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>(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<ProcessExit>()),
);
});
test('in --json mode, emits fetch_failed JSON error', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['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<ProcessExit>()),
);
});
test('in --json mode, emits software_error JSON error', () async {
final captured = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['code'],
'software_error',
);
});
});
group('--json', () {
R runJsonMode<R>(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 = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'success');
final data = decoded['data'] as Map<String, dynamic>;
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 = <String>[];
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<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['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 = <String>[];
final result = await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
expect(result, equals(ExitCode.usage.code));
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['code'],
'usage_error',
);
});
});
group('when track name is empty', () {
setUp(() {
when(() => argResults['track']).thenReturn('');
});
test('emits usage_error envelope', () async {
final captured = <String>[];
final result = await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
expect(result, equals(ExitCode.usage.code));
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['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 = <String>[];
final result = await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
expect(result, equals(ExitCode.usage.code));
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['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 = <String>[];
final result = await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
expect(result, equals(ExitCode.usage.code));
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['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 = <String>[];
final result = await captureStdout(
() => runJsonMode(command.run),
captured: captured,
);
expect(result, equals(ExitCode.usage.code));
final decoded = jsonDecode(captured.first) as Map<String, dynamic>;
expect(decoded['status'], 'error');
expect(
(decoded['error'] as Map<String, dynamic>)['code'],
'usage_error',
);
});
});
});
});
}