diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index a8d9fadd..f8643bb7 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -39,6 +39,10 @@ Future main(List 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, ); } diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases.dart b/packages/shorebird_cli/lib/src/commands/releases/releases.dart index 89150aa3..7bff7f6e 100644 --- a/packages/shorebird_cli/lib/src/commands/releases/releases.dart +++ b/packages/shorebird_cli/lib/src/commands/releases/releases.dart @@ -1,2 +1,4 @@ export 'get_apks_command.dart'; export 'releases_command.dart'; +export 'releases_info_command.dart'; +export 'releases_list_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart b/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart index fb9ad3fa..85e7c808 100644 --- a/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart +++ b/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart @@ -8,6 +8,8 @@ class ReleasesCommand extends ShorebirdCommand { /// {@macro releases_command} ReleasesCommand() { addSubcommand(GetApksCommand()); + addSubcommand(ReleasesInfoCommand()); + addSubcommand(ReleasesListCommand()); } @override diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart b/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart new file mode 100644 index 00000000..4170214b --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/releases/releases_info_command.dart @@ -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 --json'; + + @override + Future run() async { + final explicitAppId = results[CommonArguments.appIdArg.name] as String?; + + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: explicitAppId == null, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + final flavor = results.findOption( + CommonArguments.flavorArg.name, + argParser: argParser, + ); + final appId = + explicitAppId ?? + shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + + final 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; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart b/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart new file mode 100644 index 00000000..90bf404e --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/releases/releases_list_command.dart @@ -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 --json'; + + @override + Future run() async { + final explicitAppId = results[CommonArguments.appIdArg.name] as String?; + + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: explicitAppId == null, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + final flavor = results.findOption( + CommonArguments.flavorArg.name, + argParser: argParser, + ); + final appId = + explicitAppId ?? + shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + + final platformFilter = ReleasePlatform.maybeFromJson( + results['platform'] as String?, + ); + + final List 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; + } +} diff --git a/packages/shorebird_cli/lib/src/common_arguments.dart b/packages/shorebird_cli/lib/src/common_arguments.dart index 82c5a67b..abfb3bb6 100644 --- a/packages/shorebird_cli/lib/src/common_arguments.dart +++ b/packages/shorebird_cli/lib/src/common_arguments.dart @@ -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 diff --git a/packages/shorebird_cli/lib/src/logging/shorebird_logger.dart b/packages/shorebird_cli/lib/src/logging/shorebird_logger.dart index 30b4b619..5e90e61a 100644 --- a/packages/shorebird_cli/lib/src/logging/shorebird_logger.dart +++ b/packages/shorebird_cli/lib/src/logging/shorebird_logger.dart @@ -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, ); } diff --git a/packages/shorebird_cli/test/src/commands/releases/releases_info_command_test.dart b/packages/shorebird_cli/test/src/commands/releases/releases_info_command_test.dart new file mode 100644 index 00000000..1700bb57 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/releases/releases_info_command_test.dart @@ -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 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()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with release details', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['release'], isA>()); + final releaseData = data['release'] as Map; + expect(releaseData['version'], releaseVersion); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/releases/releases_list_command_test.dart b/packages/shorebird_cli/test/src/commands/releases/releases_list_command_test.dart new file mode 100644 index 00000000..0a112b5f --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/releases/releases_list_command_test.dart @@ -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 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()), + ); + }); + + test('in --json mode, emits JSON error envelope', () async { + final captured = []; + final result = await captureStdout( + () => runScoped( + command.run, + values: { + codePushClientWrapperRef.overrideWith( + () => codePushClientWrapper, + ), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ), + captured: captured, + ); + expect(result, equals(ExitCode.software.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'error'); + }); + }); + + group('--json', () { + R runJsonMode(R Function() body) { + return runScoped( + body, + values: { + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + test('emits JSON success with releases list', () async { + final captured = []; + final result = await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + expect(result, equals(ExitCode.success.code)); + expect(captured, hasLength(1)); + final decoded = jsonDecode(captured.first) as Map; + expect(decoded['status'], 'success'); + final data = decoded['data'] as Map; + expect(data['releases'], isA>()); + expect((data['releases'] as List).length, 1); + }); + + test('does not use a progress spinner', () async { + final captured = []; + await captureStdout( + () => runJsonMode(command.run), + captured: captured, + ); + verifyNever(() => logger.progress(any())); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/logging/shorebird_logger_test.dart b/packages/shorebird_cli/test/src/logging/shorebird_logger_test.dart index 094de884..f1271151 100644 --- a/packages/shorebird_cli/test/src/logging/shorebird_logger_test.dart +++ b/packages/shorebird_cli/test/src/logging/shorebird_logger_test.dart @@ -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')); });