feat(shorebird_cli): add releases list and releases info commands (#3736)

Co-authored-by: Nick Weatherley <nick@Nicks-MacBook-Pro.local>
This commit is contained in:
nickshorebird
2026-05-04 12:37:15 -04:00
committed by GitHub
parent 694a4dfcae
commit e470c32e23
10 changed files with 921 additions and 19 deletions
+5 -1
View File
@@ -39,6 +39,10 @@ Future<void> main(List<String> args) async {
() => LoggingStdout(baseStdOut: stdout, logFile: currentRunLogFile),
values: {shorebirdEnvRef},
);
final loggingStderr = runScoped(
() => LoggingStdout(baseStdOut: stderr, logFile: currentRunLogFile),
values: {shorebirdEnvRef},
);
// Write the current command to the top of the log file.
currentRunLogFile.writeAsStringSync('''
@@ -104,7 +108,7 @@ Command: shorebird ${args.join(' ')}
),
),
stdout: () => loggingStdout,
stderr: () => loggingStdout,
stderr: () => loggingStderr,
);
}
@@ -1,2 +1,4 @@
export 'get_apks_command.dart';
export 'releases_command.dart';
export 'releases_info_command.dart';
export 'releases_list_command.dart';
@@ -8,6 +8,8 @@ class ReleasesCommand extends ShorebirdCommand {
/// {@macro releases_command}
ReleasesCommand() {
addSubcommand(GetApksCommand());
addSubcommand(ReleasesInfoCommand());
addSubcommand(ReleasesListCommand());
}
@override
@@ -0,0 +1,131 @@
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';
/// {@template releases_info_command}
/// `shorebird releases info`
/// Show details for a specific release.
/// {@endtemplate}
class ReleasesInfoCommand extends ShorebirdCommand {
/// {@macro releases_info_command}
ReleasesInfoCommand() {
argParser
..addOption(
CommonArguments.releaseVersionArg.name,
help: CommonArguments.releaseVersionArg.description,
mandatory: true,
)
..addOption(
CommonArguments.appIdArg.name,
help: CommonArguments.appIdArg.description,
)
..addOption(
CommonArguments.flavorArg.name,
help: 'The product flavor to query releases for (e.g. "prod").',
);
}
@override
String get name => 'info';
@override
String get description =>
'Show details for a specific release.\n\n'
'Example output:\n'
' ID: 42\n'
' Version: 1.0.0+1\n'
' Flutter: 3.27.0\n'
' Revision: abc123def\n'
' Created: 2026-01-15\n'
' Updated: 2026-01-16\n'
' Notes: Optional release notes.\n'
' Platforms:\n'
' android: active\n'
' 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';
@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 releaseVersion =
results[CommonArguments.releaseVersionArg.name] as String;
final Release release;
try {
release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.fetchFailed,
message: 'Failed to fetch release "$releaseVersion".',
);
return e.exitCode;
}
rethrow;
}
if (isJsonMode) {
emitJsonSuccess({'release': release.toJson()});
return ExitCode.success.code;
}
logger.info('ID: ${release.id}');
logger.info('Version: ${release.version}');
if (release.flutterVersion != null) {
logger.info('Flutter: ${release.flutterVersion}');
}
logger.info('Revision: ${release.flutterRevision}');
logger
..info(
'Created: '
'${release.createdAt.toIso8601String().split('T').first}',
)
..info(
'Updated: '
'${release.updatedAt.toIso8601String().split('T').first}',
);
if (release.notes != null) {
logger.info('Notes: ${release.notes}');
}
logger.info('Platforms:');
for (final entry in release.platformStatuses.entries) {
final label = '${entry.key.value}:'.padRight(10);
logger.info(' $label${entry.value.value}');
}
return ExitCode.success.code;
}
}
@@ -0,0 +1,119 @@
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';
/// {@template releases_list_command}
/// `shorebird releases list`
/// List releases for an app.
/// {@endtemplate}
class ReleasesListCommand extends ShorebirdCommand {
/// {@macro releases_list_command}
ReleasesListCommand() {
argParser
..addOption(
CommonArguments.appIdArg.name,
help: CommonArguments.appIdArg.description,
)
..addOption(
CommonArguments.flavorArg.name,
help: 'The product flavor to list releases for (e.g. "prod").',
)
..addOption(
'platform',
allowed: ReleasePlatform.values.map((p) => p.value),
help: 'Filter to releases that have the specified platform.',
);
}
@override
String get name => 'list';
@override
String get description =>
'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';
@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 platformFilter = ReleasePlatform.maybeFromJson(
results['platform'] as String?,
);
final List<Release> releases;
try {
releases = await codePushClientWrapper.getReleases(appId: appId);
} on ProcessExit catch (e) {
if (isJsonMode) {
emitJsonError(
code: JsonErrorCode.fetchFailed,
message: 'Failed to fetch releases.',
);
return e.exitCode;
}
rethrow;
}
final filtered = platformFilter != null
? releases
.where((r) => r.platformStatuses.containsKey(platformFilter))
.toList()
: releases;
if (isJsonMode) {
emitJsonSuccess({
'releases': filtered.map((r) => r.toJson()).toList(),
});
return ExitCode.success.code;
}
if (filtered.isEmpty) {
logger.info('No releases found.');
return ExitCode.success.code;
}
for (final release in filtered) {
final platforms = release.platformStatuses.entries
.map((e) => '${e.key.value}: ${e.value.value}')
.join(', ');
final flutter = release.flutterVersion != null
? ' ${release.flutterVersion}'
: '';
logger.info(
'${release.id} ${lightCyan.wrap(release.version)} $platforms$flutter',
);
}
return ExitCode.success.code;
}
}
@@ -84,6 +84,15 @@ Entries from "--dart-define" with identical keys take precedence over entries fr
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys (iOS only).''',
);
/// An argument that allows the user to specify a Shorebird app ID directly,
/// bypassing the app ID in shorebird.yaml.
static const appIdArg = ArgumentDescriber(
name: 'app-id',
description:
'The Shorebird app ID to use. Overrides the app ID in '
'shorebird.yaml (e.g. "your-app-id").',
);
/// An argument that allows the user to specify a build flavor. You will most
/// likely want to provide a custom description for this argument that more
/// thoroughly explains what the flavor is used for.
@@ -134,7 +143,7 @@ Command that reads data from stdin and outputs a base64 signature to stdout.
/// more thoroughly explains what the release version is used for.
static const releaseVersionArg = ArgumentDescriber(
name: 'release-version',
description: 'The version of the release (e.g. "1.0.0").',
description: 'The version of the release (e.g. "1.0.0+1").',
);
/// Help text for release version arguments in patch commands, where the
@@ -6,7 +6,6 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/interactive_mode.dart';
import 'package:shorebird_cli/src/json_output.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
/// A reference to a [Logger] instance.
@@ -114,15 +113,16 @@ class ShorebirdLogger extends Logger {
/// * In an interactive context (TTY + no `--json`), defers to
/// mason_logger's animated spinner.
/// * Otherwise, emits a single static line on creation, and a "Done X" /
/// "Failed X" line on `complete`/`fail`. Output is routed to `stderr`
/// under `--json` so it doesn't corrupt the JSON envelope, and to
/// `stdout` otherwise.
/// "Failed X" line on `complete`/`fail`. Output is always routed to
/// `stderr` — progress is diagnostic, never content.
@override
Progress progress(String message, {ProgressOptions? options}) {
if (isInteractive) return super.progress(message, options: options);
if (isInteractive) {
return super.progress(message, options: options); // coverage:ignore-line
}
return _StaticProgress(
message: message,
sink: isJsonMode ? io.stderr : io.stdout,
sink: io.stderr,
level: level,
);
}
@@ -0,0 +1,318 @@
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/releases/releases_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(ReleasesInfoCommand, () {
const appId = 'test-app-id';
const releaseVersion = '1.0.0+1';
const shorebirdYaml = ShorebirdYaml(appId: appId);
final release = Release(
id: 1,
appId: appId,
version: releaseVersion,
flutterRevision: 'abc123',
flutterVersion: '3.27.0',
displayName: releaseVersion,
platformStatuses: const {
ReleasePlatform.android: ReleaseStatus.active,
ReleasePlatform.ios: ReleaseStatus.active,
},
createdAt: DateTime(2026, 1, 15),
updatedAt: DateTime(2026, 1, 16),
notes: 'Some release notes.',
);
late ArgResults argResults;
late CodePushClientWrapper codePushClientWrapper;
late ShorebirdEnv shorebirdEnv;
late ShorebirdValidator shorebirdValidator;
late ShorebirdLogger logger;
late Progress progress;
late ReleasesInfoCommand 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(ReleasesInfoCommand.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);
});
test('has correct name', () {
expect(command.name, 'info');
});
test('has correct description', () {
expect(
command.description,
startsWith('Show details for a specific 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 release 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 release 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 release for the flavored app id', () async {
await runWithOverrides(command.run);
verify(
() => codePushClientWrapper.getRelease(
appId: flavoredAppId,
releaseVersion: releaseVersion,
),
).called(1);
});
});
});
group('human-readable output', () {
test('prints release fields', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(() => logger.info(any(that: contains('ID:')))).called(1);
verify(() => logger.info(any(that: contains('1.0.0+1')))).called(1);
verify(() => logger.info(any(that: contains('3.27.0')))).called(1);
verify(() => logger.info(any(that: contains('Revision:')))).called(1);
verify(
() => logger.info(any(that: contains('Some release notes.'))),
).called(1);
verify(() => logger.info('Platforms:')).called(1);
});
group('when flutter version is null', () {
final releaseNoFlutter = Release(
id: 1,
appId: appId,
version: releaseVersion,
flutterRevision: 'abc123',
displayName: releaseVersion,
platformStatuses: const {
ReleasePlatform.android: ReleaseStatus.active,
},
createdAt: DateTime(2026, 1, 15),
updatedAt: DateTime(2026, 1, 16),
);
setUp(() {
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => releaseNoFlutter);
});
test('does not print Flutter line', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verifyNever(() => logger.info(any(that: contains('Flutter:'))));
});
});
});
group('when API fetch fails', () {
setUp(() {
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).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');
});
});
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 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['release'], isA<Map<String, dynamic>>());
final releaseData = data['release'] as Map<String, dynamic>;
expect(releaseData['version'], releaseVersion);
});
});
});
}
@@ -0,0 +1,316 @@
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/releases/releases_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(ReleasesListCommand, () {
const appId = 'test-app-id';
const shorebirdYaml = ShorebirdYaml(appId: appId);
final release = Release(
id: 1,
appId: appId,
version: '1.0.0+1',
flutterRevision: 'abc123',
flutterVersion: '3.27.0',
displayName: '1.0.0+1',
platformStatuses: const {
ReleasePlatform.android: ReleaseStatus.active,
ReleasePlatform.ios: ReleaseStatus.active,
},
createdAt: DateTime(2026, 1, 15),
updatedAt: DateTime(2026, 1, 16),
);
late ArgResults argResults;
late CodePushClientWrapper codePushClientWrapper;
late ShorebirdEnv shorebirdEnv;
late ShorebirdValidator shorebirdValidator;
late ShorebirdLogger logger;
late Progress progress;
late ReleasesListCommand 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(ReleasesListCommand.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['platform']).thenReturn(null);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
),
).thenAnswer((_) async {});
when(
() => codePushClientWrapper.getReleases(
appId: any(named: 'appId'),
),
).thenAnswer((_) async => [release]);
});
test('has correct name', () {
expect(command.name, 'list');
});
test('has correct description', () {
expect(command.description, startsWith('List releases for an app.'));
});
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 releases for the explicit app id', () async {
await runWithOverrides(command.run);
verify(
() => codePushClientWrapper.getReleases(appId: 'explicit-app-id'),
).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 releases using app id from shorebird.yaml', () async {
await runWithOverrides(command.run);
verify(
() => codePushClientWrapper.getReleases(appId: appId),
).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.getReleases(appId: flavoredAppId),
).thenAnswer((_) async => [release]);
});
test('fetches releases for the flavored app id', () async {
await runWithOverrides(command.run);
verify(
() => codePushClientWrapper.getReleases(appId: flavoredAppId),
).called(1);
});
});
});
group('when there are no releases', () {
setUp(() {
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
});
test('prints a message', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(() => logger.info('No releases found.')).called(1);
});
});
group('when --platform filter is provided', () {
final androidRelease = Release(
id: 2,
appId: appId,
version: '2.0.0',
flutterRevision: 'def456',
flutterVersion: '3.27.0',
displayName: '2.0.0',
platformStatuses: const {ReleasePlatform.android: ReleaseStatus.active},
createdAt: DateTime(2026, 2),
updatedAt: DateTime(2026, 2, 2),
);
setUp(() {
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => [release, androidRelease]);
when(() => argResults['platform']).thenReturn('ios');
});
test('only shows releases with the given platform', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
// release has ios; androidRelease does not — only one info call
verify(() => logger.info(any())).called(1);
});
});
group('human-readable output', () {
test('prints each release with id and version', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info(
any(that: allOf(contains('1'), contains('1.0.0+1'))),
),
).called(1);
});
});
group('when API fetch fails', () {
setUp(() {
when(
() => codePushClientWrapper.getReleases(
appId: any(named: 'appId'),
),
).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');
});
});
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 releases 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['releases'], isA<List<dynamic>>());
expect((data['releases'] 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()));
});
});
});
}
@@ -314,7 +314,8 @@ void main() {
() => logger.progress('fetching apps'),
hasTerminal: false,
);
expect(stdoutOutput, equals(['Starting fetching apps...']));
expect(stdoutOutput, isEmpty);
expect(stderrOutput, equals(['Starting fetching apps...']));
});
test('emits a "Done" line on complete with no update', () {
@@ -323,8 +324,9 @@ void main() {
hasTerminal: false,
);
progress.complete();
expect(stdoutOutput, isEmpty);
expect(
stdoutOutput,
stderrOutput,
equals(['Starting fetching apps...', 'Done fetching apps']),
);
});
@@ -335,7 +337,7 @@ void main() {
hasTerminal: false,
);
progress.complete('found 3 apps');
expect(stdoutOutput.last, equals('Done found 3 apps'));
expect(stderrOutput.last, equals('Done found 3 apps'));
});
test('emits a "Failed" line on fail', () {
@@ -344,7 +346,7 @@ void main() {
hasTerminal: false,
);
progress.fail('network error');
expect(stdoutOutput.last, equals('Failed network error'));
expect(stderrOutput.last, equals('Failed network error'));
});
test('emits an update line and remembers the new message', () {
@@ -354,8 +356,8 @@ void main() {
);
progress.update('still fetching');
progress.complete();
expect(stdoutOutput, contains('still fetching...'));
expect(stdoutOutput.last, equals('Done still fetching'));
expect(stderrOutput, contains('still fetching...'));
expect(stderrOutput.last, equals('Done still fetching'));
});
test('emits no carriage returns or ANSI escapes', () {
@@ -364,7 +366,7 @@ void main() {
hasTerminal: false,
);
progress.complete();
for (final line in stdoutOutput) {
for (final line in stderrOutput) {
expect(line, isNot(contains('\r')));
expect(line, isNot(contains('\u001b')));
}
@@ -372,7 +374,7 @@ void main() {
});
group('under --json', () {
test('routes static progress to stderr instead of stdout', () {
test('routes static progress to stderr', () {
final progress = runUnderScope(
() => logger.progress('fetching apps'),
hasTerminal: true,
@@ -386,15 +388,14 @@ void main() {
});
group('under --json with a TTY', () {
test('still produces static lines (no spinner)', () {
test('still produces static lines on stderr (no spinner)', () {
final progress = runUnderScope(
() => logger.progress('fetching apps'),
hasTerminal: true,
jsonMode: true,
);
progress.complete();
// Under --json progress is routed to stderr to avoid corrupting
// the JSON envelope on stdout.
expect(stdoutOutput, isEmpty);
expect(stderrOutput, contains('Starting fetching apps...'));
expect(stderrOutput, contains('Done fetching apps'));
});