Skip to content

Commit

Permalink
Showing 12 changed files with 528 additions and 104 deletions.
2 changes: 2 additions & 0 deletions packages/shorebird_cli/bin/shorebird.dart
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command_runner.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/executables/idevicesyslog.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/os.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
@@ -38,6 +39,7 @@ Future<void> main(List<String> args) async {
engineConfigRef,
gitRef,
gradlewRef,
idevicesyslogRef,
iosDeployRef,
javaRef,
loggerRef,
41 changes: 29 additions & 12 deletions packages/shorebird_cli/lib/src/commands/preview_command.dart
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -349,30 +350,46 @@ class PreviewCommand extends ShorebirdCommand {
return ExitCode.software.code;
}

final deviceId = results['device-id'] as String?;

try {
final shouldUseDeviceCtl = await devicectl.isSupported(
deviceId: deviceId,
);
final deviceLocateProgress = logger.progress('Locating device for run');
final AppleDevice? deviceForLaunch;
// Try to find a device using devicectl first. If that fails, fall back to
// ios-deploy.
if (deviceId != null) {
final deviceCtlDevices = await devicectl.listAvailableIosDevices();
deviceForLaunch = deviceCtlDevices.firstWhereOrNull(
(device) => device.udid == deviceId,
);
} else {
deviceForLaunch = await devicectl.deviceForLaunch();
}

final shouldUseDeviceCtl = deviceForLaunch != null;
final progressCompleteMessage = deviceForLaunch != null
? 'Using device ${deviceForLaunch.name}'
: null;
deviceLocateProgress.complete(progressCompleteMessage);

final int exitCode;
final int installExitCode;
if (shouldUseDeviceCtl) {
logger.detail('Using devicectl to install and launch.');
exitCode = await devicectl.installAndLaunchApp(
logger.detail(
'Using devicectl to install and launch on device $deviceId.',
);
installExitCode = await devicectl.installAndLaunchApp(
runnerAppDirectory: runnerDirectory,
deviceId: deviceId,
device: deviceForLaunch,
);
} else {
logger.detail('Using ios-deploy to install and launch.');
exitCode = await iosDeploy.installAndLaunchApp(
installExitCode = await iosDeploy.installAndLaunchApp(
bundlePath: runnerDirectory.path,
deviceId: deviceId,
);
}

return exitCode;
} catch (_) {
return installExitCode;
} catch (error, stackTrace) {
logger.detail('Error launching app. $error $stackTrace');
return ExitCode.software.code;
}
}
Original file line number Diff line number Diff line change
@@ -14,7 +14,6 @@ part 'apple_device.g.dart';
/// {@macro apple_device}
class AppleDevice {
const AppleDevice({
required this.identifier,
required this.deviceProperties,
required this.hardwareProperties,
required this.connectionProperties,
@@ -23,10 +22,6 @@ class AppleDevice {
/// Creates an [AppleDevice] from JSON.
static AppleDevice fromJson(Json json) => _$AppleDeviceFromJson(json);

/// The device's unique identifier of the form
/// DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.
final String identifier;

/// Information about the device itself.
final DeviceProperties deviceProperties;

@@ -54,15 +49,27 @@ class AppleDevice {
/// [ConnectionProperties.tunnelState] for more information about known
/// tunnelState values and what they (seem to) represent.
bool get isAvailable => connectionProperties.tunnelState != 'unavailable';

/// The device's unique identifier of the form 12345678-1234567890ABCDEF
String get udid => hardwareProperties.udid;

/// Whether the device is connected via USB.
bool get isWired => connectionProperties.transportType == 'wired';

@override
String toString() => '$name ($osVersionString ${hardwareProperties.udid})';
}

@JsonSerializable(createToJson: false, fieldRename: FieldRename.none)
class HardwareProperties {
const HardwareProperties({required this.platform});
const HardwareProperties({required this.platform, required this.udid});

/// The device's platform (e.g., "iOS").
final String platform;

/// The unique identifier of this device
final String udid;

static HardwareProperties fromJson(Json json) =>
_$HardwarePropertiesFromJson(json);
}
@@ -83,7 +90,11 @@ class DeviceProperties {

@JsonSerializable(createToJson: false, fieldRename: FieldRename.none)
class ConnectionProperties {
const ConnectionProperties({required this.tunnelState});
const ConnectionProperties({required this.tunnelState, this.transportType});

/// How the device is connected. Values seen in development include
/// "localNetwork" and "wired". Will be absent if the device is not connected.
final String? transportType;

/// The device's connection state. Values seen in development (as devicectl
/// is seemingly undocumented) include:

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 24 additions & 31 deletions packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/devicectl/nserror.dart';
import 'package:shorebird_cli/src/executables/idevicesyslog.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/process.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
@@ -66,28 +67,22 @@ class Devicectl {

/// Returns the first available iOS device, or the device with the given
/// [deviceId] if provided. Devices that are running iOS <17 are not
/// "CoreDevice"s and are not visible to devicectl.
Future<AppleDevice?> _deviceForLaunch({String? deviceId}) async {
/// "CoreDevice"s and are not visible to devicectl. Returns null if devicectl
/// is not available or no devices are found.
Future<AppleDevice?> deviceForLaunch({String? deviceId}) async {
if (!await _isAvailable()) {
return null;
}

final devices = await listAvailableIosDevices();

if (deviceId != null) {
return devices.firstWhereOrNull((d) => d.identifier == deviceId);
return devices.firstWhereOrNull((d) => d.udid == deviceId);
} else {
return devices.firstOrNull;
}
}

/// Whether we should use `devicectl` to install and launch the app on the
/// device with the given [deviceId], or the first available device we find if
/// [deviceId] is not provided.
Future<bool> isSupported({String? deviceId}) async {
if (!await _isAvailable()) {
return false;
}

return await _deviceForLaunch(deviceId: deviceId) != null;
}

/// Installs the given [runnerApp] on the device with the given [deviceId].
///
/// Returns the bundle ID of the installed app.
@@ -169,27 +164,25 @@ class Devicectl {
}
}

/// Installs and launches the given [runnerAppDirectory] on the device with
/// the given [deviceId]. If no [deviceId] is provided, the first available
/// device returned by [listAvailableIosDevices] will be used.
/// Installs and launches the given [runnerAppDirectory] on [device]. [device]
/// should be obtained using [listAvailableIosDevices]. After successfully
/// launching the app, this method will start a logger process to capture
/// logs from the device.
Future<int> installAndLaunchApp({
required Directory runnerAppDirectory,
String? deviceId,
required AppleDevice device,
}) async {
final deviceProgress = logger.progress('Finding device for run');
final device = await _deviceForLaunch(deviceId: deviceId);
if (device == null) {
deviceProgress.fail('No devices found');
return ExitCode.software.code;
}
deviceProgress.complete();

final installProgress = logger.progress('Installing app');

// Start the logger before launching the app to ensure we capture all
// logs. Starting the logger process after launching the app can result
// in missing some shorebird logs.
final loggerExitCodeFuture = idevicesyslog.startLogger(device: device);

final String bundleId;
try {
bundleId = await installApp(
deviceId: device.identifier,
deviceId: device.udid,
runnerApp: runnerAppDirectory,
);
} catch (error) {
@@ -200,16 +193,16 @@ class Devicectl {

final launchProgress = logger.progress('Launching app');
try {
await launchApp(
deviceId: device.identifier,
bundleId: bundleId,
);
await launchApp(deviceId: device.udid, bundleId: bundleId);
} catch (error) {
launchProgress.fail('Failed to launch app: $error');
return ExitCode.software.code;
}
launchProgress.complete();

final loggerExitCode = await loggerExitCodeFuture;
logger.detail('idevicesyslog exited with code $loggerExitCode');

return ExitCode.success.code;
}

Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ export 'bundletool.dart';
export 'devicectl/devicectl.dart';
export 'git.dart';
export 'gradlew.dart';
export 'idevicesyslog.dart';
export 'ios_deploy.dart';
export 'java.dart';
export 'xcodebuild.dart';
110 changes: 110 additions & 0 deletions packages/shorebird_cli/lib/src/executables/idevicesyslog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import 'dart:convert';
import 'dart:io';

import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/process.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';

/// A reference to a [IDeviceSysLog] instance.
final idevicesyslogRef = create(IDeviceSysLog.new);

/// The [IDeviceSysLog] instance available in the current zone.
IDeviceSysLog get idevicesyslog => read(idevicesyslogRef);

/// {@template idevicesyslog}
/// A wrapper around the `idevicesyslog` executable.
/// {@endtemplate}
class IDeviceSysLog {
/// The location of the libimobiledevice library, which contains
/// idevicesyslog.
static Directory get libimobiledeviceDirectory => Directory(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'artifacts',
'libimobiledevice',
),
);

/// The location of the idevicesyslog executable.
static File get idevicesyslogExecutable => File(
p.join(libimobiledeviceDirectory.path, 'idevicesyslog'),
);

/// The libraries that idevicesyslog depends on.
@visibleForTesting
static const deps = [
'libimobiledevice',
'usbmuxd',
'libplist',
'openssl',
'ios-deploy',
];

/// idevicesyslog has Flutter-provided dependencies, so we need to tell the
/// dynamic linker where to find them.
String get _dyldPathEntry => deps
.map(
(dep) => p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'artifacts',
dep,
),
)
.join(':');

/// idevicesyslog tails all logs produced by the device (similar to what is
/// shown in Console.app). This is very noisy and we only want to show logs
/// that are produced by the app. These log lines are of the form:
/// Nov 10 14:46:57 Runner(Flutter)[1044] <Notice>: flutter: hello
static RegExp appLogLineRegex = RegExp(r'\(Flutter\)\[\d+\] <Notice>: (.*)$');

/// Starts an instance of idevicesyslog for the given device ID. Returns the
/// exit code of the process.
///
/// stdout and stderr are parsed for lines matching [appLogLineRegex], and
/// those lines are logged at an info level.
Future<int> startLogger({required AppleDevice device}) async {
logger.detail(
'launching idevicesyslog with DYLD_LIBRARY_PATH=$_dyldPathEntry',
);

final loggerProcess = await process.start(
idevicesyslogExecutable.path,
[
'-u',
device.udid,
// If the device is not connected via USB, we need to specify the
// network flag.
if (!device.isWired) '--network',
],
environment: {
'DYLD_LIBRARY_PATH': _dyldPathEntry,
},
);

loggerProcess.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen(_parseLogLine);
loggerProcess.stderr
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen(_parseLogLine);
return loggerProcess.exitCode;
}

void _parseLogLine(String line) {
final matches = appLogLineRegex.allMatches(line);
if (matches.isNotEmpty) {
logger.info(matches.first.group(1));
}
}
}
72 changes: 63 additions & 9 deletions packages/shorebird_cli/test/src/commands/preview_command_test.dart
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -32,6 +33,7 @@ void main() {
const releaseId = 42;

late AppMetadata app;
late AppleDevice appleDevice;
late ArgResults argResults;
late ArtifactManager artifactManager;
late Auth auth;
@@ -64,6 +66,19 @@ void main() {
}

setUpAll(() {
registerFallbackValue(
const AppleDevice(
deviceProperties: DeviceProperties(name: 'iPhone 12'),
hardwareProperties: HardwareProperties(
platform: 'iOS',
udid: '12345678-1234567890ABCDEF',
),
connectionProperties: ConnectionProperties(
transportType: 'wired',
tunnelState: 'disconnected',
),
),
);
registerFallbackValue(Directory(''));
registerFallbackValue(File(''));
registerFallbackValue(MockHttpClient());
@@ -74,6 +89,7 @@ void main() {

setUp(() {
app = MockAppMetadata();
appleDevice = MockAppleDevice();
argResults = MockArgResults();
artifactManager = MockArtifactManager();
auth = MockAuth();
@@ -705,6 +721,8 @@ void main() {
setUp(() {
devicectl = MockDevicectl();
iosDeploy = MockIOSDeploy();

when(() => appleDevice.name).thenReturn('iPhone 12');
when(() => argResults['platform']).thenReturn(releasePlatform.name);
when(
() => artifactManager.downloadFile(
@@ -718,13 +736,13 @@ void main() {
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer((_) async {});
when(
() => devicectl.isSupported(deviceId: any(named: 'deviceId')),
).thenAnswer((_) async => false);

when(() => devicectl.deviceForLaunch(deviceId: any(named: 'deviceId')))
.thenAnswer((_) async => null);
when(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: any(named: 'runnerAppDirectory'),
deviceId: any(named: 'deviceId'),
device: any(named: 'device'),
),
).thenAnswer((_) async => ExitCode.success.code);
when(
@@ -816,16 +834,52 @@ void main() {
);
});

test('uses devicectl if devicectl says to', () async {
when(
() => devicectl.isSupported(deviceId: any(named: 'deviceId')),
).thenAnswer((_) async => true);
group('when device-id arg is provided', () {
const devicectlDeviceId = '12345';
setUp(() {
when(() => appleDevice.udid).thenReturn(devicectlDeviceId);
when(() => devicectl.listAvailableIosDevices())
.thenAnswer((_) async => [appleDevice]);
});

test('uses matching devicectl device if found', () async {
when(() => argResults['device-id'])
.thenAnswer((_) => devicectlDeviceId);

setupShorebirdYaml();
await runWithOverrides(command.run);

verifyNever(
() => iosDeploy.installAndLaunchApp(bundlePath: runnerPath()),
);
});

test(
'falls back to ios-deploy if no devicectl devices have matching id',
() async {
when(() => argResults['device-id'])
.thenAnswer((_) => 'not-a-device-id');
setupShorebirdYaml();
await runWithOverrides(command.run);

verify(
() => iosDeploy.installAndLaunchApp(
bundlePath: runnerPath(),
deviceId: 'not-a-device-id',
),
).called(1);
});
});

test('uses devicectl if devicectl returns a usable device', () async {
when(() => devicectl.deviceForLaunch(deviceId: any(named: 'deviceId')))
.thenAnswer((_) async => appleDevice);
setupShorebirdYaml();
await runWithOverrides(command.run);
verify(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: any(named: 'runnerAppDirectory'),
deviceId: any(named: 'deviceId'),
device: any(named: 'device'),
),
).called(1);
verifyNever(
Original file line number Diff line number Diff line change
@@ -4,25 +4,42 @@ import 'package:test/test.dart';

void main() {
group(AppleDevice, () {
group('osVersion', () {
const identifier = 'DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF';
const deviceName = "Joe's iPhone";
const connectionProperties =
ConnectionProperties(tunnelState: 'disconnected');
const hardwareProperties = HardwareProperties(platform: 'iOS');
const udid = '12345678-1234567890ABCDEF';
const deviceName = "Joe's iPhone";
const connectionProperties = ConnectionProperties(
transportType: 'wired',
tunnelState: 'disconnected',
);
const deviceProperties = DeviceProperties(
name: deviceName,
osVersionNumber: '17.1',
);
const hardwareProperties = HardwareProperties(
platform: 'iOS',
udid: udid,
);

late AppleDevice device;
late AppleDevice device;

group('when version string is null', () {
setUp(() {
device = const AppleDevice(
identifier: identifier,
deviceProperties: DeviceProperties(name: deviceName),
hardwareProperties: hardwareProperties,
connectionProperties: connectionProperties,
);
});
setUp(() {
device = const AppleDevice(
deviceProperties: deviceProperties,
hardwareProperties: hardwareProperties,
connectionProperties: connectionProperties,
);
});

group('toString', () {
test('includes name, OS version, and UDID', () {
expect(
device.toString(),
equals('$deviceName (${deviceProperties.osVersionNumber} $udid)'),
);
});
});

group('osVersion', () {
group('when version string is null', () {
test('returns null', () {
expect(device.osVersion, isNull);
});
@@ -31,7 +48,6 @@ void main() {
group('when version string not parseable', () {
setUp(() {
device = const AppleDevice(
identifier: identifier,
deviceProperties: DeviceProperties(
name: deviceName,
osVersionNumber: 'unparseable version number',
@@ -49,7 +65,6 @@ void main() {
group('when version string is valid', () {
setUp(() {
device = const AppleDevice(
identifier: identifier,
deviceProperties: DeviceProperties(
name: deviceName,
osVersionNumber: '1.2.3',
@@ -64,5 +79,41 @@ void main() {
});
});
});

group('isWired', () {
group('when connectionProperties.transportType is "wired"', () {
setUp(() {
device = const AppleDevice(
deviceProperties: deviceProperties,
hardwareProperties: hardwareProperties,
connectionProperties: ConnectionProperties(
tunnelState: 'disconnected',
transportType: 'wired',
),
);
});

test('returns true', () {
expect(device.isWired, isTrue);
});
});

group('when connectionProperties.transportType is "network"', () {
setUp(() {
device = const AppleDevice(
deviceProperties: deviceProperties,
hardwareProperties: hardwareProperties,
connectionProperties: ConnectionProperties(
tunnelState: 'disconnected',
transportType: 'network',
),
);
});

test('returns false', () {
expect(device.isWired, isFalse);
});
});
});
});
}
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/process.dart';
@@ -20,6 +21,7 @@ void main() {
late ExitCode exitCode;
late String jsonOutput;

late AppleDevice device;
late ShorebirdProcess process;
late ShorebirdProcessResult processResult;
late Devicectl devicectl;
@@ -28,16 +30,36 @@ void main() {
return runScoped(
body,
values: {
idevicesyslogRef.overrideWith(() => idevicesyslog),
processRef.overrideWith(() => process),
},
);
}

setUpAll(() {
registerFallbackValue(
const AppleDevice(
deviceProperties: DeviceProperties(name: 'iPhone 12'),
hardwareProperties: HardwareProperties(
platform: 'iOS',
udid: '12345678-1234567890ABCDEF',
),
connectionProperties: ConnectionProperties(
transportType: 'wired',
tunnelState: 'disconnected',
),
),
);
});

setUp(() {
device = MockAppleDevice();
process = MockShorebirdProcess();
processResult = MockShorebirdProcessResult();
devicectl = Devicectl();

when(() => device.udid).thenReturn(deviceId);

when(() => process.run(any(), any())).thenAnswer((invocation) async {
final processRunArgs =
invocation.positionalArguments.last as List<String>;
@@ -52,46 +74,46 @@ void main() {
when(() => processResult.exitCode).thenAnswer((_) => exitCode.code);
});

group('isSupported', () {
test('returns false if devicectl is not available', () async {
group('deviceForLaunch', () {
test('returns null if devicectl is not available', () async {
exitCode = ExitCode.software;
expect(
await runWithOverrides(() => devicectl.isSupported()),
isFalse,
await runWithOverrides(() => devicectl.deviceForLaunch()),
isNull,
);
});

test(
'returns false if no CoreDevice with the given deviceID can be found',
test('returns null if no CoreDevice with the given deviceID can be found',
() async {
exitCode = ExitCode.success;
jsonOutput =
File('$fixturesPath/device_list_success.json').readAsStringSync();
expect(
await runWithOverrides(
() => devicectl.isSupported(deviceId: 'fake device id'),
() => devicectl.deviceForLaunch(deviceId: 'fake device id'),
),
isFalse,
isNull,
);
});

test('returns false if no CoreDevice can be found', () async {
test('returns null if no CoreDevice can be found', () async {
exitCode = ExitCode.success;
jsonOutput = File('$fixturesPath/device_list_success_empty.json')
.readAsStringSync();
expect(
await runWithOverrides(() => devicectl.isSupported()),
isFalse,
await runWithOverrides(() => devicectl.deviceForLaunch()),
isNull,
);
});

test("returns true if device's OS version is 17 or greater", () async {
test("returns a device if device's OS version is 17 or greater",
() async {
exitCode = ExitCode.success;
jsonOutput =
File('$fixturesPath/device_list_success.json').readAsStringSync();
expect(
await runWithOverrides(() => devicectl.isSupported()),
isTrue,
await runWithOverrides(() => devicectl.deviceForLaunch()),
isNotNull,
);
});
});
@@ -286,6 +308,7 @@ void main() {
});

group('installAndLaunchApp', () {
late IDeviceSysLog idevicesyslog;
late Logger logger;
late Progress progress;

@@ -297,16 +320,20 @@ void main() {
return runScoped(
body,
values: {
idevicesyslogRef.overrideWith(() => idevicesyslog),
loggerRef.overrideWith(() => logger),
processRef.overrideWith(() => process),
},
);
}

setUp(() {
idevicesyslog = MockIDeviceSysLog();
logger = MockLogger();
progress = MockProgress();

when(() => idevicesyslog.startLogger(device: any(named: 'device')))
.thenAnswer((_) async => ExitCode.success.code);
when(() => logger.progress(any())).thenReturn(progress);
when(() => process.run(any(), any(that: contains('list'))))
.thenAnswer((invocation) async {
@@ -358,7 +385,7 @@ void main() {
await runWithOverrides(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: Directory.systemTemp.createTempSync(),
deviceId: deviceId,
device: device,
),
),
equals(ExitCode.software.code),
@@ -381,7 +408,7 @@ void main() {
await runWithOverrides(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: Directory.systemTemp.createTempSync(),
deviceId: deviceId,
device: device,
),
),
equals(ExitCode.software.code),
@@ -405,7 +432,7 @@ void main() {
await runWithOverrides(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: Directory.systemTemp.createTempSync(),
deviceId: deviceId,
device: device,
),
),
equals(ExitCode.software.code),
@@ -429,7 +456,7 @@ void main() {
await runWithOverrides(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: Directory.systemTemp.createTempSync(),
deviceId: deviceId,
device: device,
),
),
equals(ExitCode.success.code),
@@ -497,14 +524,11 @@ void main() {
final devices =
await runWithOverrides(devicectl.listAvailableIosDevices);
expect(devices, hasLength(1));
final device = devices.first;
expect(device.name, equals('Bryan Oltman’s iPhone'));
expect(
device.identifier,
equals('DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF'),
);
expect(device.osVersionString, equals('17.0.2'));
expect(device.platform, equals('iOS'));
final outputDevice = devices.first;
expect(outputDevice.name, equals('Bryan Oltman’s iPhone'));
expect(outputDevice.udid, equals('11111111-1111111111111111'));
expect(outputDevice.osVersionString, equals('17.0.2'));
expect(outputDevice.platform, equals('iOS'));
});
});
});
155 changes: 155 additions & 0 deletions packages/shorebird_cli/test/src/executables/idevicesyslog_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import 'dart:convert';

import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/idevicesyslog.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/process.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:test/test.dart';

import '../mocks.dart';

void main() {
group(IDeviceSysLog, () {
const deviceUdid = '12345678-1234567890ABCDEF';

late AppleDevice device;
late Directory flutterDirectory;
late ShorebirdEnv shorebirdEnv;
late ShorebirdProcess process;
late Logger logger;
late Process loggerProcess;
late String idevicesyslogPath;
late IDeviceSysLog idevicesyslog;
var stdoutOutput = '';
var stderrOutput = '';

R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
loggerRef.overrideWith(() => logger),
processRef.overrideWith(() => process),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
},
);
}

setUp(() {
device = MockAppleDevice();
flutterDirectory = Directory.systemTemp.createTempSync();
process = MockShorebirdProcess();
logger = MockLogger();
loggerProcess = MockProcess();
shorebirdEnv = MockShorebirdEnv();
idevicesyslog = IDeviceSysLog();

when(() => device.udid).thenReturn(deviceUdid);
when(() => device.isWired).thenReturn(true);
when(
() => process.start(
any(),
any(),
environment: any(named: 'environment'),
),
).thenAnswer((_) async {
return loggerProcess;
});
when(() => loggerProcess.stdout).thenAnswer(
(_) => Stream.value(utf8.encode(stdoutOutput)),
);
when(() => loggerProcess.stderr).thenAnswer(
(_) => Stream.value(utf8.encode(stderrOutput)),
);
when(() => loggerProcess.exitCode).thenAnswer((_) async => 0);

when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory);

idevicesyslogPath =
runWithOverrides(() => IDeviceSysLog.idevicesyslogExecutable.path);
});

group('startLogger', () {
late String expectedDyldLibraryPathString;

setUp(() {
expectedDyldLibraryPathString = IDeviceSysLog.deps
.map(
(dep) => p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'artifacts',
dep,
),
)
.join(':');
});

group('when device is wired', () {
setUp(() {
when(() => device.isWired).thenReturn(true);
});

test('runs idevicesyslog with the correct arguments', () async {
await runWithOverrides(
() => idevicesyslog.startLogger(device: device),
);

verify(
() => process.start(
idevicesyslogPath,
['-u', deviceUdid],
environment: {'DYLD_LIBRARY_PATH': expectedDyldLibraryPathString},
),
);
});
});

group('when device is connected via network', () {
setUp(() {
when(() => device.isWired).thenReturn(false);
});

test('runs idevicesyslog with the correct arguments', () async {
await runWithOverrides(
() => idevicesyslog.startLogger(device: device),
);

verify(
() => process.start(
idevicesyslogPath,
['-u', deviceUdid, '--network'],
environment: {'DYLD_LIBRARY_PATH': expectedDyldLibraryPathString},
),
);
});
});

test('logs stdout lines matching appLogLineRegex at info level',
() async {
stdoutOutput = '''
Nov 9 17:58:47 backboardd(QuartzCore)[51460] <Error>: IQCollectable client message err=0x10000004 : (ipc/send) timed out
Nov 10 14:46:57 Runner(Flutter)[1044] <Notice>: flutter: hello from stdout
Nov 10 17:58:47 kernel(Sandbox)[0] <Error>: Sandbox: Runner(52662) deny(1) iokit-get-properties iokit-class:AGXAcceleratorG14P property:CFBundleIdentifier
''';
stderrOutput = '''
Nov 9 17:58:47 backboardd(QuartzCore)[51460] <Error>: IQCollectable client message err=0x10000004 : (ipc/send) timed out
Nov 10 14:46:57 Runner(Flutter)[1044] <Notice>: flutter: hello from stderr
Nov 10 17:58:47 kernel(Sandbox)[0] <Error>: Sandbox: Runner(52662) deny(1) iokit-get-properties iokit-class:AGXAcceleratorG14P property:CFBundleIdentifier
''';
await runWithOverrides(
() => idevicesyslog.startLogger(device: device),
);

verify(() => logger.info('flutter: hello from stdout')).called(1);
verify(() => logger.info('flutter: hello from stderr')).called(1);
});
});
});
}
5 changes: 5 additions & 0 deletions packages/shorebird_cli/test/src/mocks.dart
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import 'package:shorebird_cli/src/cache.dart' show Cache;
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/os/os.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
@@ -39,6 +40,8 @@ class MockAndroidStudio extends Mock implements AndroidStudio {}

class MockAppMetadata extends Mock implements AppMetadata {}

class MockAppleDevice extends Mock implements AppleDevice {}

class MockArchiveDiffer extends Mock implements ArchiveDiffer {}

class MockArgResults extends Mock implements ArgResults {}
@@ -69,6 +72,8 @@ class MockGradlew extends Mock implements Gradlew {}

class MockHttpClient extends Mock implements http.Client {}

class MockIDeviceSysLog extends Mock implements IDeviceSysLog {}

class MockIOSDeploy extends Mock implements IOSDeploy {}

class MockIOSink extends Mock implements IOSink {}

0 comments on commit 5e0b941

Please sign in to comment.