feat(shorebird_cli): Add --json output for agentic CLI consumption (#3717)

Co-authored-by: Nick Weatherley <nick@Nicks-MacBook-Pro.local>
This commit is contained in:
nickshorebird
2026-04-28 09:35:32 -04:00
committed by GitHub
parent cfc18286f8
commit c77f86f04c
13 changed files with 1257 additions and 20 deletions
+9
View File
@@ -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
+1
View File
@@ -73,6 +73,7 @@ words:
- longpaths
- lproj
- madd # From ./packages/redis_client
- mbps
- mget # From ./packages/redis_client
- metadatas
- mktemp
@@ -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<int> 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<int> _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 = <Map<String, dynamic>>[];
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<String, dynamic>? 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<String?> _tryGetFlutterVersion() async {
try {
return await shorebirdFlutter.getVersionString();
@@ -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<int> 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<String> 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(
@@ -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 = <String>[];
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> Function() toJson;
/// Writes this result to stdout as a single JSON line.
void write() {
io.stdout.writeln(jsonEncode(toJson()));
}
}
@@ -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<int> {
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<int> {
);
}
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<int> {
() => runCommand(topLevelResults),
values: {
engineConfigRef.overrideWith(() => engineConfig),
isJsonModeRef.overrideWith(() => jsonMode),
processRef.overrideWith(() => process),
shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts),
},
@@ -187,10 +198,23 @@ ${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();
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');
@@ -199,16 +223,33 @@ ${lightCyan.wrap('shorebird release android -- --no-pub lib/main.dart')}''';
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) {
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();
}
@@ -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<int> {
/// [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 = <String>[];
Command<int>? 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<String, dynamic> 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}
@@ -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<String> stdoutOutput;
setUpAll(() {
registerFallbackValue(Uri());
});
R runJsonWithOverrides<R>(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<String, dynamic>;
expect(json['status'], equals('success'));
final data = json['data'] as Map<String, dynamic>;
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<String, dynamic>;
expect(toolchain, containsPair('android_studio', isNull));
expect(toolchain, containsPair('android_sdk', isNull));
final network = data['network'] as List<dynamic>;
expect(network, isNotEmpty);
final validators = data['validators'] as List<dynamic>;
expect(validators, hasLength(1));
final v = validators.first as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final toolchain = data['android_toolchain'] as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final network = (data['network'] as List<dynamic>)
.cast<Map<String, dynamic>>();
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final validators = (data['validators'] as List<dynamic>)
.cast<Map<String, dynamic>>();
expect(validators.first['ok'], isFalse);
final issues = (validators.first['issues'] as List<dynamic>)
.cast<Map<String, dynamic>>();
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final validators = data['validators'] as List<dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final speedTest = data['speed_test'] as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final speedTest = data['speed_test'] as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
final toolchain = data['android_toolchain'] as Map<String, dynamic>;
expect(toolchain['gradle_version'], isNull);
});
});
});
}
@@ -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<String> stdoutOutput;
R runJsonWithOverrides<R>(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<String, dynamic>;
expect(json['status'], equals('success'));
final data = json['data'] as Map<String, dynamic>;
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<String, dynamic>;
final data = json['data'] as Map<String, dynamic>;
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<String, dynamic>;
expect(json['status'], equals('error'));
final error = json['error'] as Map<String, dynamic>;
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()));
});
});
});
}
@@ -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<T> captureStdout<T>(
Future<T> Function() body, {
required List<String> 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<String> 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<void> 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<int> data) => baseStdOut.add(data);
@override
void addError(Object error, [StackTrace? stackTrace]) =>
baseStdOut.addError(error, stackTrace);
@override
Future<void> addStream(Stream<List<int>> stream) =>
baseStdOut.addStream(stream);
@override
Future<void> close() => baseStdOut.close();
@override
Future<void> flush() => baseStdOut.flush();
@override
void write(Object? object) => baseStdOut.write(object);
@override
void writeAll(Iterable<dynamic> 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);
}
}
@@ -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: <String, dynamic>{'releases': <dynamic>[]},
command: 'doctor',
);
final json = result.toJson();
expect(json['status'], equals('success'));
expect(json['data'], equals({'releases': <dynamic>[]}));
final meta = json['meta'] as Map<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
expect(error['code'], equals('software_error'));
expect(error['message'], equals('Not authenticated.'));
expect(error.containsKey('hint'), isFalse);
final meta = json['meta'] as Map<String, dynamic>;
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<String, dynamic>;
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'));
});
});
}
@@ -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<String> stdoutOutput;
setUp(() {
stdoutOutput = [];
});
/// Runs [body] while capturing stdout writes into [stdoutOutput].
Future<T> captureStdout<T>(Future<T> 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<String, dynamic>;
expect(json['status'], equals('error'));
final error = json['error'] as Map<String, dynamic>;
expect(error['code'], equals('process_exit'));
final meta = json['meta'] as Map<String, dynamic>;
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<String, dynamic>;
expect(json['status'], equals('error'));
final error = json['error'] as Map<String, dynamic>;
expect(error['code'], equals('software_error'));
expect(error.containsKey('hint'), isFalse);
final meta = json['meta'] as Map<String, dynamic>;
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<String, dynamic>;
expect(json['status'], equals('error'));
final error = json['error'] as Map<String, dynamic>;
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<String, dynamic>;
expect(json['status'], equals('success'));
final data = json['data'] as Map<String, dynamic>;
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<int> 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<String> 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<void> 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<int> data) => baseStdOut.add(data);
@override
void addError(Object error, [StackTrace? stackTrace]) =>
baseStdOut.addError(error, stackTrace);
@override
Future<void> addStream(Stream<List<int>> stream) =>
baseStdOut.addStream(stream);
@override
Future<void> close() => baseStdOut.close();
@override
Future<void> flush() => baseStdOut.flush();
@override
void write(Object? object) => baseStdOut.write(object);
@override
void writeAll(Iterable<dynamic> 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);
}
}
+5
View File
@@ -219,7 +219,12 @@ function shared::execute() {
exit 1
fi
# 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