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:
@@ -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'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user