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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+21
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+106
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user