From bb1807c35e38b3790c37ccaf7a2717050ed75579 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 20 Nov 2024 12:58:30 -0500 Subject: [PATCH] fix: improve error output for failed shorebird preview on iOS (#2634) --- .../src/executables/devicectl/devicectl.dart | 2 + .../src/executables/devicectl/nserror.dart | 80 ++++++++- .../src/executables/devicectl/nserror.g.dart | 31 +++- .../fixtures/devicectl/install_failure.json | 53 ++++++ .../executables/devicectl/devicectl_test.dart | 20 ++- .../executables/devicectl/nserror_test.dart | 161 ++++++++++++++++++ 6 files changed, 334 insertions(+), 13 deletions(-) create mode 100644 packages/shorebird_cli/test/fixtures/devicectl/install_failure.json create mode 100644 packages/shorebird_cli/test/src/executables/devicectl/nserror_test.dart diff --git a/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart b/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart index bd2b0679d..8e3959173 100644 --- a/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart +++ b/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart @@ -301,6 +301,8 @@ class Devicectl { } return rootError.userInfo.localizedFailureReason?.string ?? + rootError.userInfo.localizedDescription?.string ?? + rootError.userInfo.description?.string ?? 'unknown failure reason'; } } diff --git a/packages/shorebird_cli/lib/src/executables/devicectl/nserror.dart b/packages/shorebird_cli/lib/src/executables/devicectl/nserror.dart index 35d34d909..71822e611 100644 --- a/packages/shorebird_cli/lib/src/executables/devicectl/nserror.dart +++ b/packages/shorebird_cli/lib/src/executables/devicectl/nserror.dart @@ -1,5 +1,6 @@ // ignore_for_file: public_member_api_docs +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -10,8 +11,8 @@ part 'nserror.g.dart'; /// /// See https://developer.apple.com/documentation/foundation/nserror. /// {@endtemplate} -@JsonSerializable(createToJson: false, fieldRename: FieldRename.none) -class NSError { +@JsonSerializable(fieldRename: FieldRename.none) +class NSError extends Equatable { /// {@macro nserror} const NSError({ required this.code, @@ -25,16 +26,37 @@ class NSError { /// Creates an [NSError] from JSON. static NSError fromJson(Json json) => _$NSErrorFromJson(json); + + Json toJson() => _$NSErrorToJson(this); + + @override + String toString() => ''' +NSError( + code: $code, + domain: $domain, + userInfo: $userInfo +)'''; + + @override + List get props => [ + code, + domain, + userInfo, + ]; } -@JsonSerializable(createToJson: false, fieldRename: FieldRename.none) -class UserInfo { +@JsonSerializable(fieldRename: FieldRename.none) +class UserInfo extends Equatable { const UserInfo({ + this.description, this.localizedDescription, this.localizedFailureReason, this.underlyingError, }); + @JsonKey(name: 'NSDescription') + final StringContainer? description; + @JsonKey(name: 'NSLocalizedDescription') final StringContainer? localizedDescription; @@ -44,24 +66,64 @@ class UserInfo { @JsonKey(name: 'NSUnderlyingError') final NSUnderlyingError? underlyingError; + static const nullInfo = UserInfo(); + static UserInfo fromJson(Json json) => _$UserInfoFromJson(json); + + Json toJson() => _$UserInfoToJson(this); + + @override + String toString() => ''' +UserInfo( + description: $description, + localizedDescription: $localizedDescription, + localizedFailureReason: $localizedFailureReason, + underlyingError: $underlyingError +)'''; + + @override + List get props => [ + description, + localizedDescription, + localizedFailureReason, + underlyingError, + ]; } -@JsonSerializable(createToJson: false, fieldRename: FieldRename.none) -class StringContainer { - const StringContainer({required this.string}); +@JsonSerializable(fieldRename: FieldRename.none) +class StringContainer extends Equatable { + const StringContainer(this.string); final String string; static StringContainer fromJson(Json json) => _$StringContainerFromJson(json); + + Json toJson() => _$StringContainerToJson(this); + + @override + String toString() => string; + + @override + List get props => [string]; } -@JsonSerializable(createToJson: false, fieldRename: FieldRename.none) -class NSUnderlyingError { +@JsonSerializable(fieldRename: FieldRename.none) +class NSUnderlyingError extends Equatable { const NSUnderlyingError({required this.error}); final NSError? error; static NSUnderlyingError fromJson(Json json) => _$NSUnderlyingErrorFromJson(json); + + Json toJson() => _$NSUnderlyingErrorToJson(this); + + @override + String toString() => ''' +NSUnderlyingError( + $error +)'''; + + @override + List get props => [error]; } diff --git a/packages/shorebird_cli/lib/src/executables/devicectl/nserror.g.dart b/packages/shorebird_cli/lib/src/executables/devicectl/nserror.g.dart index 97f68b86e..c018a8975 100644 --- a/packages/shorebird_cli/lib/src/executables/devicectl/nserror.g.dart +++ b/packages/shorebird_cli/lib/src/executables/devicectl/nserror.g.dart @@ -22,11 +22,22 @@ NSError _$NSErrorFromJson(Map json) => $checkedCreate( }, ); +Map _$NSErrorToJson(NSError instance) => { + 'code': instance.code, + 'domain': instance.domain, + 'userInfo': instance.userInfo.toJson(), + }; + UserInfo _$UserInfoFromJson(Map json) => $checkedCreate( 'UserInfo', json, ($checkedConvert) { final val = UserInfo( + description: $checkedConvert( + 'NSDescription', + (v) => v == null + ? null + : StringContainer.fromJson(v as Map)), localizedDescription: $checkedConvert( 'NSLocalizedDescription', (v) => v == null @@ -46,24 +57,37 @@ UserInfo _$UserInfoFromJson(Map json) => $checkedCreate( return val; }, fieldKeyMap: const { + 'description': 'NSDescription', 'localizedDescription': 'NSLocalizedDescription', 'localizedFailureReason': 'NSLocalizedFailureReason', 'underlyingError': 'NSUnderlyingError' }, ); +Map _$UserInfoToJson(UserInfo instance) => { + 'NSDescription': instance.description?.toJson(), + 'NSLocalizedDescription': instance.localizedDescription?.toJson(), + 'NSLocalizedFailureReason': instance.localizedFailureReason?.toJson(), + 'NSUnderlyingError': instance.underlyingError?.toJson(), + }; + StringContainer _$StringContainerFromJson(Map json) => $checkedCreate( 'StringContainer', json, ($checkedConvert) { final val = StringContainer( - string: $checkedConvert('string', (v) => v as String), + $checkedConvert('string', (v) => v as String), ); return val; }, ); +Map _$StringContainerToJson(StringContainer instance) => + { + 'string': instance.string, + }; + NSUnderlyingError _$NSUnderlyingErrorFromJson(Map json) => $checkedCreate( 'NSUnderlyingError', @@ -79,3 +103,8 @@ NSUnderlyingError _$NSUnderlyingErrorFromJson(Map json) => return val; }, ); + +Map _$NSUnderlyingErrorToJson(NSUnderlyingError instance) => + { + 'error': instance.error?.toJson(), + }; diff --git a/packages/shorebird_cli/test/fixtures/devicectl/install_failure.json b/packages/shorebird_cli/test/fixtures/devicectl/install_failure.json new file mode 100644 index 000000000..5305c67bf --- /dev/null +++ b/packages/shorebird_cli/test/fixtures/devicectl/install_failure.json @@ -0,0 +1,53 @@ +{ + "error": { + "code": 1, + "domain": "com.apple.CoreDevice.ControlChannelConnectionError", + "userInfo": { + "NSLocalizedDescription": { + "string": "Internal logic error: Connection was invalidated" + }, + "NSUnderlyingError": { + "error": { + "code": 0, + "domain": "com.apple.CoreDevice.ControlChannelConnectionError", + "userInfo": { + "NSLocalizedDescription": { + "string": "Transport error" + }, + "NSUnderlyingError": { + "error": { + "code": 60, + "domain": "Network.NWError", + "userInfo": { + "NSDescription": { + "string": "Operation timed out" + } + } + } + } + } + } + } + } + }, + "info": { + "arguments": [ + "devicectl", + "device", + "install", + "app", + "--device", + "00008110-000265A10E92801E", + "/Users/bryanoltman/shorebirdtech/_shorebird/shorebird/bin/cache/previews/3c2ca65a-6401-491a-8942-c9921ba43cec/ios_1.0.0+2_508316.app", + "--json-output", + "/var/folders/64/dj6krpq1093dmx08dy4r1cwh0000gn/T/AKL8Wl/devicectl.out.json" + ], + "commandType": "devicectl.device.install.app", + "environment": { + "TERM": "xterm-256color" + }, + "jsonVersion": 2, + "outcome": "failed", + "version": "397.24" + } +} diff --git a/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart b/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart index aac12e97b..8a7001cd6 100644 --- a/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart +++ b/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart @@ -398,9 +398,9 @@ void main() { deviceListJsonOutput = File( '$fixturesPath/device_list_success.json', ).readAsStringSync(); - // I was not able to get this command to fail, so just use an empty - // string as the output. - installJsonOutput = ''; + installJsonOutput = File( + '$fixturesPath/install_failure.json', + ).readAsStringSync(); }); test('returns exit code 70 ', () async { @@ -413,6 +413,10 @@ void main() { ), equals(ExitCode.software.code), ); + + verify( + () => progress.fail(any(that: contains('Operation timed out'))), + ).called(1); }); }); @@ -437,6 +441,16 @@ void main() { ), equals(ExitCode.software.code), ); + + verify( + () => progress.fail( + any( + that: contains( + '''Unable to launch dev.shorebird.ios-test because the device was not, or could not be, unlocked.''', + ), + ), + ), + ).called(1); }); }); diff --git a/packages/shorebird_cli/test/src/executables/devicectl/nserror_test.dart b/packages/shorebird_cli/test/src/executables/devicectl/nserror_test.dart new file mode 100644 index 000000000..46b945777 --- /dev/null +++ b/packages/shorebird_cli/test/src/executables/devicectl/nserror_test.dart @@ -0,0 +1,161 @@ +import 'package:shorebird_cli/src/executables/devicectl/nserror.dart'; +import 'package:test/test.dart'; + +void main() { + group(NSError, () { + test('(de)serialization', () { + const error = NSError( + code: 1, + domain: 'com.example', + userInfo: UserInfo( + description: StringContainer('description'), + localizedDescription: StringContainer('localizedDescription'), + localizedFailureReason: StringContainer('localizedFailureReason'), + ), + ); + + final json = error.toJson(); + final decoded = NSError.fromJson(json); + + expect(decoded, error); + }); + + group('toString', () { + test('returns a string representation of the error', () { + const error = NSError( + code: 1, + domain: 'com.example', + userInfo: UserInfo( + description: StringContainer('description'), + localizedDescription: StringContainer('localizedDescription'), + localizedFailureReason: StringContainer('localizedFailureReason'), + ), + ); + + expect( + error.toString(), + ''' +NSError( + code: 1, + domain: com.example, + userInfo: UserInfo( + description: description, + localizedDescription: localizedDescription, + localizedFailureReason: localizedFailureReason, + underlyingError: null +) +)''', + ); + }); + }); + }); + + group(UserInfo, () { + test('(de)serialization', () { + const userInfo = UserInfo( + description: StringContainer('description'), + localizedDescription: StringContainer('localizedDescription'), + localizedFailureReason: StringContainer('localizedFailureReason'), + ); + + final json = userInfo.toJson(); + final decoded = UserInfo.fromJson(json); + + expect(decoded, userInfo); + }); + + group('toString', () { + test('returns a string representation of the user info', () { + const userInfo = UserInfo( + description: StringContainer('description'), + localizedDescription: StringContainer('localizedDescription'), + localizedFailureReason: StringContainer('localizedFailureReason'), + ); + + expect( + userInfo.toString(), + ''' +UserInfo( + description: description, + localizedDescription: localizedDescription, + localizedFailureReason: localizedFailureReason, + underlyingError: null +)''', + ); + }); + }); + }); + + group(StringContainer, () { + test('(de)serialization', () { + const stringContainer = StringContainer('string'); + + final json = stringContainer.toJson(); + final decoded = StringContainer.fromJson(json); + + expect(decoded, stringContainer); + }); + + group('toString', () { + test('returns a string representation of the string container', () { + const stringContainer = StringContainer('string'); + + expect(stringContainer.toString(), 'string'); + }); + }); + }); + + group(NSUnderlyingError, () { + test('(de)serialization', () { + const underlyingError = NSUnderlyingError( + error: NSError( + code: 1, + domain: 'com.example', + userInfo: UserInfo( + description: StringContainer('description'), + localizedDescription: StringContainer('localizedDescription'), + localizedFailureReason: StringContainer('localizedFailureReason'), + ), + ), + ); + + final json = underlyingError.toJson(); + final decoded = NSUnderlyingError.fromJson(json); + + expect(decoded, underlyingError); + }); + + group('toString', () { + test('returns a string representation of the underlying error', () { + const underlyingError = NSUnderlyingError( + error: NSError( + code: 1, + domain: 'com.example', + userInfo: UserInfo( + description: StringContainer('description'), + localizedDescription: StringContainer('localizedDescription'), + localizedFailureReason: StringContainer('localizedFailureReason'), + ), + ), + ); + + expect( + underlyingError.toString(), + ''' +NSUnderlyingError( + NSError( + code: 1, + domain: com.example, + userInfo: UserInfo( + description: description, + localizedDescription: localizedDescription, + localizedFailureReason: localizedFailureReason, + underlyingError: null +) +) +)''', + ); + }); + }); + }); +}