diff --git a/bin/shorebird b/bin/shorebird index 161bf431..355131dd 100755 --- a/bin/shorebird +++ b/bin/shorebird @@ -19,6 +19,15 @@ if [[ ! -d "$BIN_DIR/cache/flutter/$FLUTTER_VERSION/bin" ]]; then rm -f "$BIN_DIR/cache/shorebird.stamp" fi +# When --json is passed, tell shared.sh to redirect bootstrap output +# (flutter --version, pub get, git) to stderr so stdout is pure JSON. +for arg in "$@"; do + if [[ "$arg" == "--json" ]]; then + export SHOREBIRD_JSON_MODE=true + break + fi +done + source "$BIN_DIR/../third_party/flutter/bin/internal/shared.sh" # We currently depend on a forked (3.7.8 stable) Flutter shared.sh script diff --git a/cspell.config.yaml b/cspell.config.yaml index 4064d9dd..a3e967b9 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -73,6 +73,7 @@ words: - longpaths - lproj - madd # From ./packages/redis_client + - mbps - mget # From ./packages/redis_client - metadatas - mktemp diff --git a/packages/shorebird_cli/lib/src/commands/doctor_command.dart b/packages/shorebird_cli/lib/src/commands/doctor_command.dart index c409e1cc..b4437d2e 100644 --- a/packages/shorebird_cli/lib/src/commands/doctor_command.dart +++ b/packages/shorebird_cli/lib/src/commands/doctor_command.dart @@ -5,11 +5,13 @@ import 'package:shorebird_cli/src/android_sdk.dart'; import 'package:shorebird_cli/src/android_studio.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; +import 'package:shorebird_cli/src/http_client/http_client.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/network_checker.dart'; import 'package:shorebird_cli/src/shorebird_command.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; import 'package:shorebird_cli/src/version.dart'; /// {@template doctor_command} @@ -43,6 +45,8 @@ class DoctorCommand extends ShorebirdCommand { @override Future run() async { + if (isJsonMode) return _runJson(); + final verbose = results['verbose'] == true; final shouldFix = results['fix'] == true; final flutterVersion = await _tryGetFlutterVersion(); @@ -138,6 +142,98 @@ Android Toolchain return ExitCode.success.code; } + Future _runJson() async { + final flutterVersion = await _tryGetFlutterVersion(); + + String? javaVersion; + if (java.executable != null) { + javaVersion = java.version; + } + + String? gradleVersion; + if (gradlew.exists(Directory.current.path)) { + try { + gradleVersion = await gradlew.version(Directory.current.path); + } on Exception { + // Gradle version detection can fail — report as null. + } + } + + // Direct HTTP checks — avoids networkChecker.checkReachability which + // logs to the terminal instead of returning structured data. + final networkResults = await Future.wait( + NetworkChecker.urlsToCheck.map((url) async { + try { + await httpClient.get(url); + return {'url': '$url', 'reachable': true}; + } on Exception { + return {'url': '$url', 'reachable': false}; + } + }), + ); + + // Direct validator calls — avoids doctor.runValidators which logs + // to the terminal instead of returning structured data. + final validatorResults = >[]; + for (final validator in doctor.initAndDoctorValidators) { + if (!validator.canRunInCurrentContext()) continue; + final issues = await validator.validate(); + validatorResults.add({ + 'name': validator.description, + 'ok': !issues.any( + (i) => i.severity == ValidationIssueSeverity.error, + ), + 'issues': issues + .map( + (i) => { + 'severity': i.severity.name, + 'message': i.message, + }, + ) + .toList(), + }); + } + + Map? speedTest; + if (results['verbose'] == true) { + double? uploadMbPerSec; + double? downloadMbPerSec; + try { + uploadMbPerSec = await networkChecker.performGCPUploadSpeedTest(); + } on Exception { + // Report as null on failure. + } + try { + downloadMbPerSec = await networkChecker.performGCPDownloadSpeedTest(); + } on Exception { + // Report as null on failure. + } + speedTest = { + 'upload_megabytes_per_sec': uploadMbPerSec, + 'download_megabytes_per_sec': downloadMbPerSec, + }; + } + + emitJsonSuccess({ + 'shorebird_version': packageVersion, + 'flutter_version': flutterVersion, + 'flutter_revision': shorebirdEnv.flutterRevision, + 'engine_revision': shorebirdEnv.shorebirdEngineRevision, + 'android_toolchain': { + 'android_studio': androidStudio.path, + 'android_sdk': androidSdk.path, + 'adb': androidSdk.adbPath, + 'java_home': java.home, + 'java_version': javaVersion, + 'gradle_version': gradleVersion, + }, + 'network': networkResults, + if (speedTest != null) 'speed_test': speedTest, + 'validators': validatorResults, + }); + return ExitCode.success.code; + } + Future _tryGetFlutterVersion() async { try { return await shorebirdFlutter.getVersionString(); diff --git a/packages/shorebird_cli/lib/src/commands/flutter/versions/flutter_versions_list_command.dart b/packages/shorebird_cli/lib/src/commands/flutter/versions/flutter_versions_list_command.dart index b8bcf70e..f67ca509 100644 --- a/packages/shorebird_cli/lib/src/commands/flutter/versions/flutter_versions_list_command.dart +++ b/packages/shorebird_cli/lib/src/commands/flutter/versions/flutter_versions_list_command.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.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_flutter.dart'; @@ -21,7 +22,9 @@ class FlutterVersionsListCommand extends ShorebirdCommand { @override Future run() async { - final progress = logger.progress('Fetching Flutter versions'); + final progress = isJsonMode + ? null + : logger.progress('Fetching Flutter versions'); String? currentVersion; try { @@ -33,13 +36,28 @@ class FlutterVersionsListCommand extends ShorebirdCommand { final List versions; try { versions = await shorebirdFlutter.getVersions(); - progress.cancel(); + progress?.cancel(); } on Exception catch (error) { - progress.fail('Failed to fetch Flutter versions.'); + if (isJsonMode) { + emitJsonError( + code: JsonErrorCode.fetchFailed, + message: 'Failed to fetch Flutter versions: $error', + ); + return ExitCode.software.code; + } + progress?.fail('Failed to fetch Flutter versions.'); logger.err('$error'); return ExitCode.software.code; } + if (isJsonMode) { + emitJsonSuccess({ + 'current_version': currentVersion, + 'versions': versions.reversed.toList(), + }); + return ExitCode.success.code; + } + logger.info('📦 Flutter Versions'); for (final version in versions.reversed) { logger.info( diff --git a/packages/shorebird_cli/lib/src/json_output.dart b/packages/shorebird_cli/lib/src/json_output.dart new file mode 100644 index 00000000..805cefe2 --- /dev/null +++ b/packages/shorebird_cli/lib/src/json_output.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:args/args.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/version.dart'; + +/// A reference to whether JSON output mode is active. +final isJsonModeRef = create(() => false); + +/// Whether JSON output mode is active in the current zone. +bool get isJsonMode => read(isJsonModeRef); + +/// Builds the full command name from [ArgResults] by walking the command +/// chain (e.g. "doctor" for `shorebird doctor`). +String commandNameFromResults(ArgResults topLevelResults) { + final parts = []; + var command = topLevelResults.command; + while (command != null) { + final name = command.name; + if (name != null) parts.add(name); + command = command.command; + } + return parts.isEmpty ? 'shorebird' : parts.join(' '); +} + +/// The status field in a JSON output envelope. +enum JsonStatus { + /// The command completed successfully. + success, + + /// The command failed. + error, +} + +/// Machine-readable error codes for JSON output. +enum JsonErrorCode { + /// A process exited with a non-zero exit code. + processExit('process_exit'), + + /// The CLI was invoked with invalid arguments. + usageError('usage_error'), + + /// An unhandled exception occurred. + softwareError('software_error'), + + /// A network fetch or data retrieval failed. + fetchFailed('fetch_failed'); + + const JsonErrorCode(this.code); + + /// The wire-format string (e.g. "process_exit"). + final String code; +} + +/// {@template json_meta} +/// Metadata included in every JSON output envelope. +/// {@endtemplate} +class JsonMeta { + /// {@macro json_meta} + const JsonMeta({required this.version, required this.command}); + + /// The CLI version that produced this output. + final String version; + + /// The full command name (e.g. "doctor"). + final String command; + + /// Serializes this metadata to a JSON-compatible map. + Map toJson() => { + 'version': version, + 'command': command, + }; +} + +/// {@template json_error} +/// Structured error information for JSON output. +/// {@endtemplate} +class JsonError { + /// {@macro json_error} + const JsonError({required this.code, required this.message, this.hint}); + + /// A machine-readable error code. + final JsonErrorCode code; + + /// A human-readable error description. + final String message; + + /// An optional actionable recovery step + /// (e.g. "Run: shorebird login:ci"). + final String? hint; + + /// Serializes this error to a JSON-compatible map. + Map toJson() => { + 'code': code.code, + 'message': message, + if (hint != null) 'hint': hint, + }; +} + +/// {@template json_result} +/// Structured result envelope for `--json` CLI output. +/// +/// Every JSON response follows this shape: +/// ```json +/// { +/// "status": "success" | "error", +/// "data": { ... }, // present on success +/// "error": { ... }, // present on error +/// "meta": { "version": "...", "command": "..." } +/// } +/// ``` +/// {@endtemplate} +class JsonResult { + const JsonResult._({required this.toJson}); + + /// Creates a success result with the given [data]. + /// + /// The `command` is the full command name (e.g. "doctor") and is + /// injected into the `meta` block automatically. + factory JsonResult.success({ + required Map data, + required String command, + }) { + final meta = JsonMeta(version: packageVersion, command: command); + return JsonResult._( + toJson: () => { + 'status': JsonStatus.success.name, + 'data': data, + 'meta': meta.toJson(), + }, + ); + } + + /// Creates an error result. + /// + /// [code] is a machine-readable [JsonErrorCode]. + /// `message` is a human-readable description. + /// `hint` is an optional actionable recovery step. + /// `command` is injected into the `meta` block automatically. + factory JsonResult.error({ + required JsonErrorCode code, + required String message, + required String command, + String? hint, + }) { + final error = JsonError(code: code, message: message, hint: hint); + final meta = JsonMeta(version: packageVersion, command: command); + return JsonResult._( + toJson: () => { + 'status': JsonStatus.error.name, + 'error': error.toJson(), + 'meta': meta.toJson(), + }, + ); + } + + /// Serializes this result to a JSON-compatible map. + final Map Function() toJson; + + /// Writes this result to stdout as a single JSON line. + void write() { + io.stdout.writeln(jsonEncode(toJson())); + } +} diff --git a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart index 872a9ec9..2016d04b 100644 --- a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart +++ b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart @@ -7,6 +7,7 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; import 'package:shorebird_cli/src/engine_config.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/shorebird_artifacts.dart'; @@ -38,15 +39,15 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { ShorebirdCliCommandRunner() : super(executableName, description) { argParser ..addFlag('version', negatable: false, help: 'Print the current version.') + ..addFlag( + 'json', + negatable: false, + help: 'Output results in JSON format.', + ) ..addFlag( 'verbose', abbr: 'v', help: 'Noisy logging, including all shell commands executed.', - callback: (verbose) { - if (verbose) { - logger.level = Level.verbose; - } - }, ) ..addOption( 'local-engine-src-path', @@ -124,6 +125,15 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { ); } + final jsonMode = topLevelResults['json'] == true; + + // In JSON mode, suppress verbose logging — it writes to stdout and + // would corrupt the JSON output. Verbose output still goes to the + // log file via ShorebirdLogger.detail. + if (!jsonMode && topLevelResults['verbose'] == true) { + logger.level = Level.verbose; + } + final process = ShorebirdProcess(); final shorebirdArtifacts = engineConfig.localEngineSrcPath != null ? const ShorebirdLocalEngineArtifacts() @@ -132,6 +142,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { () => runCommand(topLevelResults), values: { engineConfigRef.overrideWith(() => engineConfig), + isJsonModeRef.overrideWith(() => jsonMode), processRef.overrideWith(() => process), shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), }, @@ -187,28 +198,58 @@ ${lightCyan.wrap('shorebird release android -- --no-pub lib/main.dart')}'''; return ExitCode.success.code; } + final commandName = commandNameFromResults(topLevelResults); + // Run the command or show version int? exitCode; if (topLevelResults['version'] == true) { final flutterVersion = await _tryGetFlutterVersion(); - final shorebirdFlutterPrefix = StringBuffer('Flutter'); - if (flutterVersion != null) { - shorebirdFlutterPrefix.write(' $flutterVersion'); - } - logger.info(''' + if (isJsonMode) { + JsonResult.success( + data: { + 'shorebird_version': packageVersion, + 'flutter_version': flutterVersion, + 'flutter_revision': shorebirdEnv.flutterRevision, + 'engine_revision': shorebirdEnv.shorebirdEngineRevision, + }, + command: 'version', + ).write(); + } else { + final shorebirdFlutterPrefix = StringBuffer('Flutter'); + if (flutterVersion != null) { + shorebirdFlutterPrefix.write(' $flutterVersion'); + } + logger.info(''' Shorebird $packageVersion • git@github.com:shorebirdtech/shorebird.git $shorebirdFlutterPrefix • revision ${shorebirdEnv.flutterRevision} Engine • revision ${shorebirdEnv.shorebirdEngineRevision}'''); + } exitCode = ExitCode.success.code; } else { try { exitCode = await super.runCommand(topLevelResults); } on ProcessExit catch (error) { exitCode = error.exitCode; + if (isJsonMode && error.exitCode != ExitCode.success.code) { + JsonResult.error( + code: JsonErrorCode.processExit, + message: 'Process exited with code ${error.exitCode}.', + command: commandName, + ).write(); + } } on UsageException catch (e) { - logger - ..err(e.message) - ..info(e.usage); + if (isJsonMode) { + JsonResult.error( + code: JsonErrorCode.usageError, + message: e.message, + hint: 'Run: shorebird $commandName --help', + command: commandName, + ).write(); + } else { + logger + ..err(e.message) + ..info(e.usage); + } // When on an usage exception we don't need to show the "if you aren't // sure" message, so we do an early return here. return ExitCode.usage.code; @@ -217,6 +258,13 @@ Engine • revision ${shorebirdEnv.shorebirdEngineRevision}'''); // the user a friendly message. // ignore: avoid_catches_without_on_clauses } catch (error, stackTrace) { + if (isJsonMode) { + JsonResult.error( + code: JsonErrorCode.softwareError, + message: '$error', + command: commandName, + ).write(); + } logger ..err('$error') ..detail('$stackTrace'); @@ -225,7 +273,8 @@ Engine • revision ${shorebirdEnv.shorebirdEngineRevision}'''); } // `runCommand` returns null in when the --help flag is passed. - if (exitCode != null && + if (!isJsonMode && + exitCode != null && exitCode != ExitCode.success.code && logger.level != Level.verbose) { final fileAnIssue = link( @@ -243,7 +292,8 @@ ${currentRunLogFile.absolute.path} '''); } - if (topLevelResults.command?.name != UpgradeCommand.commandName) { + if (!isJsonMode && + topLevelResults.command?.name != UpgradeCommand.commandName) { await _checkForUpdates(); } diff --git a/packages/shorebird_cli/lib/src/shorebird_command.dart b/packages/shorebird_cli/lib/src/shorebird_command.dart index b88f2814..62ac3d48 100644 --- a/packages/shorebird_cli/lib/src/shorebird_command.dart +++ b/packages/shorebird_cli/lib/src/shorebird_command.dart @@ -4,6 +4,8 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/shorebird_cli_command_runner.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -47,6 +49,46 @@ abstract class ShorebirdCommand extends Command { /// [ArgResults] for the current command. ArgResults get results => testArgResults ?? argResults!; + + /// Whether the `--json` global flag was passed. + /// + /// Reads from the [isJsonModeRef] scoped dependency, which is set by the + /// command runner based on the parsed `--json` flag. + bool get isJsonMode => read(isJsonModeRef); + + /// The full command name including parent commands (e.g. "releases list"). + String get fullCommandName { + final parts = []; + Command? current = this; + while (current != null) { + parts.insert(0, current.name); + current = current.parent; + } + return parts.join(' '); + } + + /// Emits a JSON success envelope with the given [data] to stdout. + /// + /// Only call this when [isJsonMode] is true. + void emitJsonSuccess(Map data) { + JsonResult.success(data: data, command: fullCommandName).write(); + } + + /// Emits a JSON error envelope to stdout. + /// + /// Only call this when [isJsonMode] is true. + void emitJsonError({ + required JsonErrorCode code, + required String message, + String? hint, + }) { + JsonResult.error( + code: code, + message: message, + hint: hint, + command: fullCommandName, + ).write(); + } } /// {@template shorebird_proxy_command} diff --git a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart index 1ab8163d..bec69e6f 100644 --- a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; +import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:scoped_deps/scoped_deps.dart'; @@ -9,6 +11,8 @@ import 'package:shorebird_cli/src/android_studio.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; +import 'package:shorebird_cli/src/http_client/http_client.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/network_checker.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; @@ -17,6 +21,7 @@ import 'package:shorebird_cli/src/validators/validators.dart'; import 'package:shorebird_cli/src/version.dart'; import 'package:test/test.dart'; +import '../helpers.dart'; import '../mocks.dart'; void main() { @@ -47,6 +52,7 @@ void main() { androidSdkRef.overrideWith(() => androidSdk), doctorRef.overrideWith(() => doctor), gradlewRef.overrideWith(() => gradlew), + isJsonModeRef.overrideWith(() => false), javaRef.overrideWith(() => java), loggerRef.overrideWith(() => logger), networkCheckerRef.overrideWith(() => networkChecker), @@ -396,5 +402,307 @@ Android Toolchain () => doctor.runValidators([validator], applyFixes: true), ).called(1); }); + + group('when --json is passed', () { + late http.Client mockHttpClient; + late List stdoutOutput; + + setUpAll(() { + registerFallbackValue(Uri()); + }); + + R runJsonWithOverrides(R Function() body) { + return runScoped( + body, + values: { + androidStudioRef.overrideWith(() => androidStudio), + androidSdkRef.overrideWith(() => androidSdk), + doctorRef.overrideWith(() => doctor), + gradlewRef.overrideWith(() => gradlew), + httpClientRef.overrideWith(() => mockHttpClient), + isJsonModeRef.overrideWith(() => true), + javaRef.overrideWith(() => java), + loggerRef.overrideWith(() => logger), + networkCheckerRef.overrideWith(() => networkChecker), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + }, + ); + } + + setUp(() { + stdoutOutput = []; + mockHttpClient = MockHttpClient(); + + when(() => mockHttpClient.get(any())).thenAnswer( + (_) async => http.Response('', 200), + ); + when(() => java.executable).thenReturn(null); + when(() => validator.canRunInCurrentContext()).thenReturn(true); + when(() => validator.description).thenReturn('Test Validator'); + when(() => validator.validate()).thenAnswer((_) async => []); + + command = runJsonWithOverrides(DoctorCommand.new) + ..testArgResults = argResults; + }); + + test('emits JSON success with version info and diagnostics', () async { + const flutterVersion = '3.22.2'; + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => flutterVersion); + + final exitCode = await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + expect(exitCode, equals(ExitCode.success.code)); + expect(stdoutOutput, isNotEmpty); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('success')); + + final data = json['data'] as Map; + expect(data['shorebird_version'], equals(packageVersion)); + expect(data['flutter_version'], equals(flutterVersion)); + expect(data['flutter_revision'], equals(shorebirdFlutterRevision)); + expect(data['engine_revision'], equals(shorebirdEngineRevision)); + + final toolchain = data['android_toolchain'] as Map; + expect(toolchain, containsPair('android_studio', isNull)); + expect(toolchain, containsPair('android_sdk', isNull)); + + final network = data['network'] as List; + expect(network, isNotEmpty); + + final validators = data['validators'] as List; + expect(validators, hasLength(1)); + final v = validators.first as Map; + expect(v['name'], equals('Test Validator')); + expect(v['ok'], isTrue); + expect(v['issues'], isEmpty); + }); + + test('emits null flutter_version when lookup fails', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenThrow(Exception('oops')); + + final exitCode = await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + expect(exitCode, equals(ExitCode.success.code)); + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + expect(data['flutter_version'], isNull); + }); + + test('includes android toolchain info', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + when(() => androidStudio.path).thenReturn('/path/to/studio'); + when(() => androidSdk.path).thenReturn('/path/to/sdk'); + when(() => androidSdk.adbPath).thenReturn('/path/to/adb'); + when(() => java.home).thenReturn('/path/to/java'); + when(() => java.executable).thenReturn('/path/to/java/bin/java'); + when(() => java.version).thenReturn('17.0.9'); + when(() => gradlew.exists(any())).thenReturn(true); + when(() => gradlew.version(any())).thenAnswer((_) async => '8.0'); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final toolchain = data['android_toolchain'] as Map; + expect(toolchain['android_studio'], equals('/path/to/studio')); + expect(toolchain['android_sdk'], equals('/path/to/sdk')); + expect(toolchain['adb'], equals('/path/to/adb')); + expect(toolchain['java_home'], equals('/path/to/java')); + expect(toolchain['java_version'], equals('17.0.9')); + expect(toolchain['gradle_version'], equals('8.0')); + }); + + test('reports network reachability per URL', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + + // First URL succeeds, second fails. + var callCount = 0; + when(() => mockHttpClient.get(any())).thenAnswer((_) async { + callCount++; + if (callCount == 2) throw Exception('unreachable'); + return http.Response('', 200); + }); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final network = (data['network'] as List) + .cast>(); + expect(network[0]['reachable'], isTrue); + expect(network[1]['reachable'], isFalse); + }); + + test('includes validator issues with severity', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + when(() => validator.validate()).thenAnswer( + (_) async => [ + ValidationIssue.error(message: 'Missing permission'), + ValidationIssue.warning(message: 'Consider upgrading'), + ], + ); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final validators = (data['validators'] as List) + .cast>(); + expect(validators.first['ok'], isFalse); + final issues = (validators.first['issues'] as List) + .cast>(); + expect(issues[0]['severity'], equals('error')); + expect(issues[0]['message'], equals('Missing permission')); + expect(issues[1]['severity'], equals('warning')); + expect(issues[1]['message'], equals('Consider upgrading')); + }); + + test('skips validators that cannot run in current context', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + when(() => validator.canRunInCurrentContext()).thenReturn(false); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final validators = data['validators'] as List; + expect(validators, isEmpty); + }); + + test( + 'does not call logger-based networkChecker or doctor.runValidators', + () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + verifyNever(() => networkChecker.checkReachability()); + verifyNever( + () => doctor.runValidators( + any(), + applyFixes: any(named: 'applyFixes'), + ), + ); + verifyNever(() => logger.info(any())); + }, + ); + + test('includes speed_test when --verbose is passed', () async { + when(() => argResults['verbose']).thenReturn(true); + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + when( + () => networkChecker.performGCPUploadSpeedTest(), + ).thenAnswer((_) async => 1.23); + when( + () => networkChecker.performGCPDownloadSpeedTest(), + ).thenAnswer((_) async => 4.56); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final speedTest = data['speed_test'] as Map; + expect(speedTest['upload_megabytes_per_sec'], equals(1.23)); + expect(speedTest['download_megabytes_per_sec'], equals(4.56)); + }); + + test('omits speed_test when --verbose is not passed', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + expect(data.containsKey('speed_test'), isFalse); + }); + + test('reports null speed_test values when tests fail', () async { + when(() => argResults['verbose']).thenReturn(true); + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + when( + () => networkChecker.performGCPUploadSpeedTest(), + ).thenThrow(Exception('upload failed')); + when( + () => networkChecker.performGCPDownloadSpeedTest(), + ).thenThrow(Exception('download failed')); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final speedTest = data['speed_test'] as Map; + expect(speedTest['upload_megabytes_per_sec'], isNull); + expect(speedTest['download_megabytes_per_sec'], isNull); + }); + + test('reports null gradle_version when detection fails', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '3.22.2'); + when(() => gradlew.exists(any())).thenReturn(true); + when(() => gradlew.version(any())).thenThrow(Exception('gradle fail')); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + final toolchain = data['android_toolchain'] as Map; + expect(toolchain['gradle_version'], isNull); + }); + }); }); } diff --git a/packages/shorebird_cli/test/src/commands/flutter/versions/flutter_versions_list_command_test.dart b/packages/shorebird_cli/test/src/commands/flutter/versions/flutter_versions_list_command_test.dart index 1087ce1f..f56e8dc9 100644 --- a/packages/shorebird_cli/test/src/commands/flutter/versions/flutter_versions_list_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/flutter/versions/flutter_versions_list_command_test.dart @@ -1,13 +1,16 @@ +import 'dart:convert'; import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/json_output.dart'; import 'package:shorebird_cli/src/logging/logging.dart'; import 'package:shorebird_cli/src/shorebird_flutter.dart'; import 'package:test/test.dart'; +import '../../../helpers.dart'; import '../../../mocks.dart'; void main() { @@ -21,6 +24,7 @@ void main() { return runScoped( body, values: { + isJsonModeRef.overrideWith(() => false), loggerRef.overrideWith(() => logger), shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), }, @@ -114,5 +118,107 @@ void main() { () => logger.info(lightCyan.wrap('✓ 1.0.0')), ]); }); + + group('when --json is passed', () { + late List stdoutOutput; + + R runJsonWithOverrides(R Function() body) { + return runScoped( + body, + values: { + isJsonModeRef.overrideWith(() => true), + loggerRef.overrideWith(() => logger), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + }, + ); + } + + setUp(() { + stdoutOutput = []; + command = runJsonWithOverrides(FlutterVersionsListCommand.new); + }); + + test('emits JSON success with versions and current_version', () async { + const versions = ['1.0.0', '1.0.1']; + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '1.0.0'); + when( + () => shorebirdFlutter.getVersions(), + ).thenAnswer((_) async => versions); + + final exitCode = await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + expect(exitCode, equals(ExitCode.success.code)); + expect(stdoutOutput, isNotEmpty); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('success')); + final data = json['data'] as Map; + expect(data['current_version'], equals('1.0.0')); + expect(data['versions'], equals(['1.0.1', '1.0.0'])); + verifyNever(() => logger.info(any())); + }); + + test('emits null current_version when getVersionString throws', () async { + const versions = ['1.0.0', '1.0.1']; + when(() => shorebirdFlutter.getVersionString()).thenThrow( + const ProcessException('flutter', ['--version']), + ); + when( + () => shorebirdFlutter.getVersions(), + ).thenAnswer((_) async => versions); + + final exitCode = await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + expect(exitCode, equals(ExitCode.success.code)); + final json = jsonDecode(stdoutOutput.first) as Map; + final data = json['data'] as Map; + expect(data['current_version'], isNull); + }); + + test('emits JSON error when getVersions fails', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '1.0.0'); + when( + () => shorebirdFlutter.getVersions(), + ).thenThrow(Exception('network error')); + + final exitCode = await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + expect(exitCode, equals(ExitCode.software.code)); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('error')); + final error = json['error'] as Map; + expect(error['code'], equals('fetch_failed')); + verifyNever(() => logger.info(any())); + verifyNever(() => logger.err(any())); + }); + + test('does not create a progress spinner', () async { + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => '1.0.0'); + when( + () => shorebirdFlutter.getVersions(), + ).thenAnswer((_) async => ['1.0.0']); + + await captureStdout( + () => runJsonWithOverrides(command.run), + captured: stdoutOutput, + ); + + verifyNever(() => logger.progress(any())); + }); + }); }); } diff --git a/packages/shorebird_cli/test/src/helpers.dart b/packages/shorebird_cli/test/src/helpers.dart index bfbb73fb..f063fc7b 100644 --- a/packages/shorebird_cli/test/src/helpers.dart +++ b/packages/shorebird_cli/test/src/helpers.dart @@ -1,7 +1,98 @@ +import 'dart:convert'; import 'dart:io'; + import 'package:path/path.dart' as p; File createTempFile(String name) { return File(p.join(Directory.systemTemp.createTempSync().path, name)) ..createSync(); } + +/// Runs [body] while capturing stdout writes into [captured]. +/// +/// Used to verify JSON output from commands that write to stdout. +Future captureStdout( + Future Function() body, { + required List captured, +}) async { + final realStdout = stdout; + return IOOverrides.runZoned( + body, + stdout: () => CapturingStdout(baseStdOut: realStdout, captured: captured), + ); +} + +/// A [Stdout] wrapper that captures [writeln] calls into [captured]. +class CapturingStdout implements Stdout { + /// Creates a [CapturingStdout] that delegates to [baseStdOut]. + CapturingStdout({required this.baseStdOut, required this.captured}); + + /// The underlying [Stdout] to delegate to. + final Stdout baseStdOut; + + /// Lines captured from [writeln] calls. + final List captured; + + @override + Encoding get encoding => baseStdOut.encoding; + + @override + set encoding(Encoding value) => baseStdOut.encoding = value; + + @override + String get lineTerminator => baseStdOut.lineTerminator; + + @override + set lineTerminator(String value) => baseStdOut.lineTerminator = value; + + @override + Future get done => baseStdOut.done; + + @override + bool get hasTerminal => baseStdOut.hasTerminal; + + @override + IOSink get nonBlocking => baseStdOut.nonBlocking; + + @override + bool get supportsAnsiEscapes => baseStdOut.supportsAnsiEscapes; + + @override + int get terminalColumns => baseStdOut.terminalColumns; + + @override + int get terminalLines => baseStdOut.terminalLines; + + @override + void add(List data) => baseStdOut.add(data); + + @override + void addError(Object error, [StackTrace? stackTrace]) => + baseStdOut.addError(error, stackTrace); + + @override + Future addStream(Stream> stream) => + baseStdOut.addStream(stream); + + @override + Future close() => baseStdOut.close(); + + @override + Future flush() => baseStdOut.flush(); + + @override + void write(Object? object) => baseStdOut.write(object); + + @override + void writeAll(Iterable objects, [String sep = '']) => + baseStdOut.writeAll(objects, sep); + + @override + void writeCharCode(int charCode) => baseStdOut.writeCharCode(charCode); + + @override + void writeln([Object? object = '']) { + captured.add(object.toString()); + baseStdOut.writeln(object); + } +} diff --git a/packages/shorebird_cli/test/src/json_output_test.dart b/packages/shorebird_cli/test/src/json_output_test.dart new file mode 100644 index 00000000..85b8afe5 --- /dev/null +++ b/packages/shorebird_cli/test/src/json_output_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:args/args.dart'; +import 'package:shorebird_cli/src/json_output.dart'; +import 'package:shorebird_cli/src/version.dart'; +import 'package:test/test.dart'; + +void main() { + group(JsonResult, () { + group('success', () { + test('serializes correctly', () { + final result = JsonResult.success( + data: {'releases': []}, + command: 'doctor', + ); + final json = result.toJson(); + expect(json['status'], equals('success')); + expect(json['data'], equals({'releases': []})); + final meta = json['meta'] as Map; + expect(meta['version'], equals(packageVersion)); + expect(meta['command'], equals('doctor')); + expect(json.containsKey('error'), isFalse); + }); + + test('produces valid JSON', () { + final result = JsonResult.success( + data: {'key': 'value'}, + command: 'doctor', + ); + final encoded = jsonEncode(result.toJson()); + final decoded = jsonDecode(encoded) as Map; + expect(decoded['status'], equals('success')); + }); + }); + + group('error', () { + test('serializes correctly without hint', () { + final result = JsonResult.error( + code: JsonErrorCode.softwareError, + message: 'Not authenticated.', + command: 'doctor', + ); + final json = result.toJson(); + expect(json['status'], equals('error')); + final error = json['error'] as Map; + expect(error['code'], equals('software_error')); + expect(error['message'], equals('Not authenticated.')); + expect(error.containsKey('hint'), isFalse); + final meta = json['meta'] as Map; + expect(meta['version'], equals(packageVersion)); + expect(meta['command'], equals('doctor')); + expect(json.containsKey('data'), isFalse); + }); + + test('serializes correctly with hint', () { + final result = JsonResult.error( + code: JsonErrorCode.usageError, + message: 'Not authenticated.', + hint: 'Run: shorebird login:ci', + command: 'doctor', + ); + final json = result.toJson(); + final error = json['error'] as Map; + expect(error['hint'], equals('Run: shorebird login:ci')); + }); + }); + }); + + group('commandNameFromResults', () { + test('returns shorebird when no command is present', () { + final parser = ArgParser()..addFlag('version'); + final results = parser.parse(['--version']); + expect(commandNameFromResults(results), equals('shorebird')); + }); + }); + + group(JsonMeta, () { + test('serializes correctly', () { + const meta = JsonMeta(version: '1.0.0', command: 'doctor'); + expect(meta.toJson(), equals({'version': '1.0.0', 'command': 'doctor'})); + }); + }); + + group(JsonError, () { + test('serializes correctly without hint', () { + const error = JsonError( + code: JsonErrorCode.softwareError, + message: 'test message', + ); + final json = error.toJson(); + expect(json['code'], equals('software_error')); + expect(json['message'], equals('test message')); + expect(json.containsKey('hint'), isFalse); + }); + + test('serializes correctly with hint', () { + const error = JsonError( + code: JsonErrorCode.usageError, + message: 'test message', + hint: 'try this', + ); + expect(error.toJson()['hint'], equals('try this')); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart b/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart index 78adcc8d..02999b66 100644 --- a/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_cli_command_runner_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; @@ -434,6 +435,162 @@ Engine • revision $shorebirdEngineRevision'''), }); }); }); + + group('--json', () { + late List stdoutOutput; + + setUp(() { + stdoutOutput = []; + }); + + /// Runs [body] while capturing stdout writes into [stdoutOutput]. + Future captureStdout(Future Function() body) async { + // Capture the real stdout before entering the override zone to + // avoid infinite recursion. + final realStdout = stdout; + return IOOverrides.runZoned( + body, + stdout: () => _CapturingStdout( + baseStdOut: realStdout, + captured: stdoutOutput, + ), + ); + } + + group('on ProcessExit with non-zero exit code', () { + test('emits JSON error envelope', () async { + commandRunner.addCommand(_TestCommand(ExitCode.unavailable)); + final result = await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', 'test']), + ), + ); + expect(result, equals(ExitCode.unavailable.code)); + + // Should have emitted JSON to stdout. + expect(stdoutOutput, isNotEmpty); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('error')); + final error = json['error'] as Map; + expect(error['code'], equals('process_exit')); + final meta = json['meta'] as Map; + expect(meta['version'], equals(packageVersion)); + expect(meta['command'], equals('test')); + }); + + test('suppresses "file an issue" message', () async { + commandRunner.addCommand(_TestCommand(ExitCode.unavailable)); + await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', 'test']), + ), + ); + verifyNever( + () => logger.info( + any( + that: contains( + '''If you aren't sure why this command failed''', + ), + ), + ), + ); + }); + }); + + group('on ProcessExit with zero exit code', () { + test('does not emit JSON error', () async { + commandRunner.addCommand(_TestCommand(ExitCode.success)); + final result = await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', 'test']), + ), + ); + expect(result, equals(ExitCode.success.code)); + expect( + stdoutOutput.where((line) => line.contains('"status"')), + isEmpty, + ); + }); + }); + + group('on software error', () { + test('emits JSON error envelope', () async { + commandRunner.addCommand(_ThrowingCommand()); + final result = await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', 'throwing']), + ), + ); + expect(result, equals(ExitCode.software.code)); + + expect(stdoutOutput, isNotEmpty); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('error')); + final error = json['error'] as Map; + expect(error['code'], equals('software_error')); + expect(error.containsKey('hint'), isFalse); + final meta = json['meta'] as Map; + expect(meta['command'], equals('throwing')); + }); + }); + + group('on UsageException', () { + test('emits JSON error envelope with hint', () async { + final result = await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', 'nonexistent']), + ), + ); + expect(result, equals(ExitCode.usage.code)); + + expect(stdoutOutput, isNotEmpty); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('error')); + final error = json['error'] as Map; + expect(error['code'], equals('usage_error')); + expect(error.containsKey('hint'), isTrue); + verifyNever(() => logger.err(any())); + }); + }); + + group('--version', () { + test('emits JSON success with version info', () async { + const flutterVersionString = '3.22.2'; + when( + () => shorebirdFlutter.getVersionString(), + ).thenAnswer((_) async => flutterVersionString); + + final result = await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', '--version']), + ), + ); + + expect(result, equals(ExitCode.success.code)); + expect(stdoutOutput, isNotEmpty); + final json = jsonDecode(stdoutOutput.first) as Map; + expect(json['status'], equals('success')); + final data = json['data'] as Map; + expect(data['shorebird_version'], equals(packageVersion)); + expect(data['flutter_version'], equals(flutterVersionString)); + expect(data['flutter_revision'], equals(flutterRevision)); + expect(data['engine_revision'], equals(shorebirdEngineRevision)); + verifyNever(() => logger.info(any())); + }); + }); + + test('does not check for updates', () async { + commandRunner.addCommand(_TestCommand(ExitCode.success)); + await captureStdout( + () => runWithOverrides( + () => commandRunner.run(['--json', 'test']), + ), + ); + + verifyNever(() => shorebirdVersion.isTrackingStable()); + verifyNever(() => shorebirdVersion.isLatest()); + }); + }); }); } @@ -453,3 +610,87 @@ class _TestCommand extends ShorebirdCommand { throw ProcessExit(exitCode.code); } } + +class _ThrowingCommand extends ShorebirdCommand { + @override + String get name => 'throwing'; + + @override + String get description => 'A command that throws'; + + @override + Future run() async { + throw StateError('something went wrong'); + } +} + +/// A minimal [Stdout] that captures [writeln] calls. +class _CapturingStdout implements Stdout { + _CapturingStdout({required this.baseStdOut, required this.captured}); + + final Stdout baseStdOut; + final List captured; + + @override + Encoding get encoding => baseStdOut.encoding; + + @override + set encoding(Encoding value) => baseStdOut.encoding = value; + + @override + String get lineTerminator => baseStdOut.lineTerminator; + + @override + set lineTerminator(String value) => baseStdOut.lineTerminator = value; + + @override + Future get done => baseStdOut.done; + + @override + bool get hasTerminal => baseStdOut.hasTerminal; + + @override + IOSink get nonBlocking => baseStdOut.nonBlocking; + + @override + bool get supportsAnsiEscapes => baseStdOut.supportsAnsiEscapes; + + @override + int get terminalColumns => baseStdOut.terminalColumns; + + @override + int get terminalLines => baseStdOut.terminalLines; + + @override + void add(List data) => baseStdOut.add(data); + + @override + void addError(Object error, [StackTrace? stackTrace]) => + baseStdOut.addError(error, stackTrace); + + @override + Future addStream(Stream> stream) => + baseStdOut.addStream(stream); + + @override + Future close() => baseStdOut.close(); + + @override + Future flush() => baseStdOut.flush(); + + @override + void write(Object? object) => baseStdOut.write(object); + + @override + void writeAll(Iterable objects, [String sep = '']) => + baseStdOut.writeAll(objects, sep); + + @override + void writeCharCode(int charCode) => baseStdOut.writeCharCode(charCode); + + @override + void writeln([Object? object = '']) { + captured.add(object.toString()); + baseStdOut.writeln(object); + } +} diff --git a/third_party/flutter/bin/internal/shared.sh b/third_party/flutter/bin/internal/shared.sh index ea8a9ebe..57ca8339 100755 --- a/third_party/flutter/bin/internal/shared.sh +++ b/third_party/flutter/bin/internal/shared.sh @@ -20,7 +20,7 @@ function update_flutter { SHOREBIRD_ENGINE_VERSION=`cat "$FLUTTER_PATH/bin/internal/engine.version"` echo "Shorebird Engine • revision $SHOREBIRD_ENGINE_VERSION" # Install Shorebird Flutter Artifacts - FLUTTER_STORAGE_BASE_URL=https://download.shorebird.dev $FLUTTER_PATH/bin/flutter --version + FLUTTER_STORAGE_BASE_URL=https://download.shorebird.dev $FLUTTER_PATH/bin/flutter --version } function pub_get_with_retry { @@ -219,7 +219,12 @@ function shared::execute() { exit 1 fi - upgrade_shorebird 7< "$PROG_NAME" + # In JSON mode, redirect bootstrap output to stderr so stdout is pure JSON. + if [[ "$SHOREBIRD_JSON_MODE" == "true" ]]; then + upgrade_shorebird 7< "$PROG_NAME" 1>&2 + else + upgrade_shorebird 7< "$PROG_NAME" + fi BIN_NAME="$(basename "$PROG_NAME")" case "$BIN_NAME" in