feat(shorebird_cli): add patches list/info; --json for set-track (#3740)
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user