From c1a53a34e0cb559fc138c32fd7d2087dc1d8f25e Mon Sep 17 00:00:00 2001 From: Slideep Date: Tue, 19 May 2026 14:56:08 +0300 Subject: [PATCH 1/3] Add device inventory query support --- Luotsi.Cli.Tests/AdbReadinessTests.cs | 246 ++++++++++++++++++ Luotsi.Cli.Tests/TestSupport.cs | 3 + Luotsi.Cli/Cli/CliOptions.cs | 1 + Luotsi.Cli/Cli/Help.cs | 2 + .../Cli/Routing/AppCommandDispatcher.cs | 4 +- .../Cli/Routing/AppCommandFamilyRouter.cs | 3 +- Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs | 85 ++++++ .../Cli/Routing/DeviceSelectorResolver.cs | 42 +++ .../Cli/Routing/DeviceStatusResolver.cs | 25 ++ .../Infrastructure/Devices/DeviceInventory.cs | 106 ++++++++ Luotsi.Cli/Models/CommandResults.cs | 16 ++ docs/commands.md | 1 + 12 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs create mode 100644 Luotsi.Cli/Cli/Routing/DeviceSelectorResolver.cs create mode 100644 Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs create mode 100644 Luotsi.Cli/Infrastructure/Devices/DeviceInventory.cs diff --git a/Luotsi.Cli.Tests/AdbReadinessTests.cs b/Luotsi.Cli.Tests/AdbReadinessTests.cs index 188a94e..63b58b1 100644 --- a/Luotsi.Cli.Tests/AdbReadinessTests.cs +++ b/Luotsi.Cli.Tests/AdbReadinessTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Luotsi.Cli.Artifacts; using Luotsi.Cli.Cli; using Luotsi.Cli.Hosts.Android; @@ -116,6 +117,251 @@ public async Task RunAsync_Passes_AdbTimeout_Environment_To_AdbClientFactory() Assert.Equal(TimeSpan.FromSeconds(7), Assert.Single(adbFactory.CommandTimeouts)); } + [Fact] + public async Task RunAsync_Devices_Reports_Derived_Device_Inventory() + { + var adb = new FakeAdbClient(); + adb.EnqueueRunResult(new ProcessResult(0, """ + List of devices attached + USB123 device usb:1 product:oriole model:Pixel_6 device:oriole transport_id:1 + 192.168.0.44:5555 device product:panther model:Pixel_7 device:panther + emulator-5554 offline product:sdk_gphone64_x86_64 model:Android_SDK_built_for_x86_64 device:emu64 + """, string.Empty)); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + AdbClientFactory = new FakeAdbClientFactory(adb) + }); + + var exitCode = await app.RunAsync(["devices"]); + using var output = console.ParseSingleOutputAsJson(); + var devices = output.RootElement.GetProperty("data").GetProperty("devices"); + + Assert.Equal(0, exitCode); + Assert.Equal(["devices", "-l"], adb.RunCommands.Single()); + Assert.Equal("online", devices[0].GetProperty("state").GetString()); + Assert.Equal("usb", devices[0].GetProperty("transport").GetString()); + Assert.Equal("wifi", devices[1].GetProperty("transport").GetString()); + Assert.Equal("emulator", devices[2].GetProperty("type").GetString()); + Assert.Equal("unavailable", devices[2].GetProperty("availability").GetString()); + } + + [Fact] + public async Task RunAsync_DeviceStatus_Writes_State_For_Selected_Device() + { + var host = new FakeDeviceHost + { + PreflightTemplate = new PreflightResult("Pixel 9", "16", "36", "focus", null, null, "fingerprint", "arm64-v8a", "SER123") + }; + host.ConnectedDevices.Add(new DeviceInfo("SER123", "device", "product:panther model:Pixel_9 device:panther")); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["device-status", "--device", "SER123"]); + using var output = console.ParseSingleOutputAsJson(); + var device = output.RootElement.GetProperty("data").GetProperty("device"); + var readiness = output.RootElement.GetProperty("data").GetProperty("readiness"); + + Assert.Equal(0, exitCode); + Assert.Equal("SER123", device.GetProperty("serial").GetString()); + Assert.Equal("online", device.GetProperty("state").GetString()); + Assert.Equal("physical", device.GetProperty("type").GetString()); + Assert.Equal("Pixel_9", device.GetProperty("model").GetString()); + Assert.Equal("16", readiness.GetProperty("android_release").GetString()); + Assert.Equal("36", readiness.GetProperty("sdk").GetString()); + Assert.Equal("focus", readiness.GetProperty("current_focus").GetString()); + } + + [Fact] + public async Task RunAsync_DeviceStatus_Reuses_Inventory_Metadata_When_Available() + { + var host = new FakeDeviceHost + { + PreflightTemplate = new PreflightResult("Pixel 7", "16", "36", "focus", null, null, "fingerprint", "arm64-v8a", "192.168.0.44:5555") + }; + host.ConnectedDevices.Add(new DeviceInfo("192.168.0.44:5555", "device", "product:panther model:Pixel_7 device:panther")); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["device-status", "--device", "192.168.0.44:5555"]); + using var output = console.ParseSingleOutputAsJson(); + var device = output.RootElement.GetProperty("data").GetProperty("device"); + + Assert.Equal(0, exitCode); + Assert.Equal("wifi", device.GetProperty("transport").GetString()); + Assert.Equal("panther", device.GetProperty("product").GetString()); + Assert.Equal("panther", device.GetProperty("device").GetString()); + } + + [Fact] + public async Task RunAsync_DeviceStatus_Writes_Exact_Command_Envelope() + { + var started = DateTimeOffset.Parse("2026-05-15T12:00:00Z"); + var host = new FakeDeviceHost + { + PreflightTemplate = new PreflightResult("Pixel 9", "16", "36", "focus", null, null, "fingerprint", "arm64-v8a", "SER123") + }; + host.ConnectedDevices.Add(new DeviceInfo("SER123", "device", "product:panther model:Pixel_9 device:panther")); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = started.ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["device-status", "--device", "SER123"]); + + var expectedJson = JsonSerializer.Serialize(new + { + ok = true, + command = "device-status", + started_at = started, + ended_at = started, + data = new + { + device = new + { + serial = "SER123", + state = "online", + transport = "unknown", + type = "physical", + model = "Pixel_9", + product = "panther", + device = "panther", + details = "product:panther model:Pixel_9 device:panther", + availability = "available" + }, + readiness = new + { + model = "Pixel 9", + android_release = "16", + sdk = "36", + current_focus = "focus", + fingerprint = "fingerprint", + abi = "arm64-v8a", + serial = "SER123" + } + }, + artifacts = new + { + artifact_root = Path.Combine(Path.Combine("/tmp", "luotsi"), "20260515-120000-device-status"), + poll_artifacts = "final" + }, + schema = ResultSchemas.CommandEnvelope, + duration_ms = 0 + }); + + Assert.Equal(0, exitCode); + Assert.Equal(expectedJson, Assert.Single(console.OutputLines)); + } + + [Fact] + public async Task RunAsync_DeviceStatus_Requires_Selected_Device_To_Exist_In_Inventory() + { + var host = new FakeDeviceHost + { + PreflightTemplate = new PreflightResult("Pixel 9", "16", "36", "focus", null, null, "fingerprint", "arm64-v8a", "SER123") + }; + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["device-status", "--device", "SER123"]); + using var output = console.ParseSingleOutputAsJson(); + + Assert.Equal(1, exitCode); + Assert.Contains("was not present in `adb devices -l` output", output.RootElement.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task RunAsync_DeviceQuery_Selects_Exactly_One_Device_Before_Command_Host_Creation() + { + var host = new FakeDeviceHost(); + host.ConnectedDevices.Add(new DeviceInfo("USB123", "device", "product:oriole model:Pixel_6 device:oriole")); + host.ConnectedDevices.Add(new DeviceInfo("192.168.0.44:5555", "device", "product:panther model:Pixel_7 device:panther")); + var factory = new FakeDeviceHostFactory(host); + var app = new App(new AppDependencies + { + Console = new FakeConsole(), + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = factory + }); + + var exitCode = await app.RunAsync(["preflight", "--device-query", "transport=wifi,model=Pixel_7"]); + + Assert.Equal(0, exitCode); + Assert.Equal(2, factory.CreateCallCount); + Assert.Null(factory.Configurations[0].DeviceSerial); + Assert.Equal("192.168.0.44:5555", factory.Configurations[1].DeviceSerial); + Assert.Equal([null], host.CommandPreflightRequests); + } + + [Fact] + public async Task RunAsync_DeviceQuery_Multiple_Matches_Returns_Usage_Error() + { + var host = new FakeDeviceHost(); + host.ConnectedDevices.Add(new DeviceInfo("USB123", "device", "product:oriole model:Pixel_6 device:oriole")); + host.ConnectedDevices.Add(new DeviceInfo("USB456", "device", "product:panther model:Pixel_7 device:panther")); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["preflight", "--device-query", "type=physical"]); + using var output = console.ParseSingleOutputAsJson(); + + Assert.Equal(2, exitCode); + Assert.Contains("matched multiple devices", output.RootElement.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task RunAsync_Devices_With_DeviceQuery_Returns_Usage_Error() + { + var host = new FakeDeviceHost(); + host.ConnectedDevices.Add(new DeviceInfo("USB123", "device", "product:oriole model:Pixel_6 device:oriole")); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["devices", "--device-query", "model=Pixel_6"]); + using var output = console.ParseSingleOutputAsJson(); + + Assert.Equal(2, exitCode); + Assert.Contains("not supported with `devices`", output.RootElement.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); + } + [Fact] public async Task WaitForDeviceAsync_Pings_When_Device_Is_Selected() { diff --git a/Luotsi.Cli.Tests/TestSupport.cs b/Luotsi.Cli.Tests/TestSupport.cs index ae8af7a..bc30419 100644 --- a/Luotsi.Cli.Tests/TestSupport.cs +++ b/Luotsi.Cli.Tests/TestSupport.cs @@ -419,9 +419,12 @@ internal sealed class FakeDeviceHostFactory(IDeviceHost deviceHost) : IDeviceHos { public int CreateCallCount { get; private set; } + public List Configurations { get; } = []; + public IDeviceHost Create(DeviceHostConfiguration configuration, ArtifactSession artifacts) { CreateCallCount++; + Configurations.Add(configuration); return deviceHost; } } diff --git a/Luotsi.Cli/Cli/CliOptions.cs b/Luotsi.Cli/Cli/CliOptions.cs index 1f8ce81..39d0eab 100644 --- a/Luotsi.Cli/Cli/CliOptions.cs +++ b/Luotsi.Cli/Cli/CliOptions.cs @@ -17,6 +17,7 @@ public sealed class CliOptions { "adb", "devices", + "device-status", "device-wait", "preflight", "screen-state", diff --git a/Luotsi.Cli/Cli/Help.cs b/Luotsi.Cli/Cli/Help.cs index b749a06..afd2f83 100644 --- a/Luotsi.Cli/Cli/Help.cs +++ b/Luotsi.Cli/Cli/Help.cs @@ -21,6 +21,7 @@ dotnet run --project Luotsi.Cli -- [options] Commands: devices + device-status [--device | --device-query ] adb server-status adb version adb features @@ -76,6 +77,7 @@ record --output [--time-limit-sec 30] Common options: --device + --device-query exact-match clauses: state=online,type=physical,model=Pixel_9 --adb --platform --adb-timeout-sec (default 120, 0 disables; env LUOTSI_ADB_TIMEOUT_SEC) diff --git a/Luotsi.Cli/Cli/Routing/AppCommandDispatcher.cs b/Luotsi.Cli/Cli/Routing/AppCommandDispatcher.cs index 0d5f0ca..12c3c8e 100644 --- a/Luotsi.Cli/Cli/Routing/AppCommandDispatcher.cs +++ b/Luotsi.Cli/Cli/Routing/AppCommandDispatcher.cs @@ -1,6 +1,7 @@ using Luotsi.Cli.Cli.View; using Luotsi.Cli.Errors; using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Infrastructure.Devices; using Luotsi.Cli.Models; namespace Luotsi.Cli.Cli.Routing; @@ -22,7 +23,8 @@ public async Task ExecuteAsync(string command, CliOptions options, strin return command switch { "adb" => await _adbSubcommandDispatcher.ExecuteAsync(options, RequireAdbCommandHost(runner, command)).ConfigureAwait(false), - "devices" => await runner.GetDevicesAsync().ConfigureAwait(false), + "devices" => DeviceInventory.FromDeviceList(await runner.GetDevicesAsync().ConfigureAwait(false)), + "device-status" => await DeviceStatusResolver.ReadAsync(runner, RequireAdbCommandHost(runner, command)).ConfigureAwait(false), "device-wait" or "wait-for-device" => await RequireAdbCommandHost(runner, command).WaitForDeviceAsync(options.Int("timeout-sec", CliDefaults.DefaultTimeoutSeconds)).ConfigureAwait(false), "preflight" => await RequireAdbCommandHost(runner, command).PreflightAsync(options.Get("package")).ConfigureAwait(false), "wireless" => await GetWirelessHost(runner).EnableWirelessAsync(options.Get("host"), options.Int("port", 5555)).ConfigureAwait(false), diff --git a/Luotsi.Cli/Cli/Routing/AppCommandFamilyRouter.cs b/Luotsi.Cli/Cli/Routing/AppCommandFamilyRouter.cs index 5492718..36083de 100644 --- a/Luotsi.Cli/Cli/Routing/AppCommandFamilyRouter.cs +++ b/Luotsi.Cli/Cli/Routing/AppCommandFamilyRouter.cs @@ -61,7 +61,8 @@ public async Task DispatchAsync(AppExecutionContext context) } default: - context.Runner = _dependencies.DeviceHostLauncher.Create(options, adbExecutable, artifacts); + var deviceSelector = await DeviceSelectorResolver.ResolveAsync(options, adbExecutable, artifacts, options.Command, _dependencies.DeviceHostLauncher).ConfigureAwait(false); + context.Runner = _dependencies.DeviceHostLauncher.Create(options, adbExecutable, artifacts, deviceSelector); return await _dependencies.CommandHost.RunCommandAsync(options, started, adbExecutable, context.Runner, artifacts).ConfigureAwait(false); } } diff --git a/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs b/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs new file mode 100644 index 0000000..f5ca2b8 --- /dev/null +++ b/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs @@ -0,0 +1,85 @@ +using Luotsi.Cli.Errors; +using Luotsi.Cli.Models; + +namespace Luotsi.Cli.Cli.Routing; + +internal sealed class DeviceQuery(string rawQuery) +{ + private readonly IReadOnlyList _clauses = Parse(rawQuery); + + public string RawQuery { get; } = string.IsNullOrWhiteSpace(rawQuery) ? throw new UsageException("--device-query must be non-empty.") : rawQuery; + + public bool Matches(DeviceState state) + { + ArgumentNullException.ThrowIfNull(state); + return _clauses.All(clause => clause.Matches(state)); + } + + private static IReadOnlyList Parse(string rawQuery) + { + if (string.IsNullOrWhiteSpace(rawQuery)) + { + throw new UsageException("--device-query must be non-empty."); + } + + return rawQuery + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(DeviceQueryClause.Parse) + .ToArray(); + } +} + +internal sealed record DeviceQueryClause(string Key, string Value) +{ + public static DeviceQueryClause Parse(string text) + { + var parts = text.Split('=', 2, StringSplitOptions.TrimEntries); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) + { + throw new UsageException("--device-query clauses must use key=value syntax separated by commas."); + } + + var key = parts[0].ToLowerInvariant(); + if (key is not ("serial" or "state" or "status" or "transport" or "type" or "model" or "product" or "device" or "availability")) + { + throw new UsageException($"Unsupported --device-query key '{parts[0]}'. Supported keys: serial, state, status, transport, type, model, product, device, availability."); + } + + return new DeviceQueryClause(key, parts[1]); + } + + public bool Matches(DeviceState state) + { + var actual = Key switch + { + "serial" => state.Serial, + "state" or "status" => state.State, + "transport" => state.Transport, + "type" => state.Type, + "model" => state.Model, + "product" => state.Product, + "device" => state.Device, + "availability" => state.Availability, + _ => null + }; + + return string.Equals(actual, Value, StringComparison.OrdinalIgnoreCase); + } +} + +internal static class DeviceQuerySelector +{ + public static DeviceState Select(DeviceInventoryResult inventory, string query) + { + ArgumentNullException.ThrowIfNull(inventory); + + var parsed = new DeviceQuery(query); + var matches = inventory.Devices.Where(parsed.Matches).ToArray(); + return matches.Length switch + { + 1 => matches[0], + 0 => throw new UsageException($"--device-query '{query}' matched no devices."), + _ => throw new UsageException($"--device-query '{query}' matched multiple devices: {string.Join(", ", matches.Select(static device => device.Serial ?? ""))}. Add another query clause or pass --device.") + }; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Cli/Routing/DeviceSelectorResolver.cs b/Luotsi.Cli/Cli/Routing/DeviceSelectorResolver.cs new file mode 100644 index 0000000..6939444 --- /dev/null +++ b/Luotsi.Cli/Cli/Routing/DeviceSelectorResolver.cs @@ -0,0 +1,42 @@ +using Luotsi.Cli.Artifacts; +using Luotsi.Cli.Cli.Hosting; +using Luotsi.Cli.Errors; +using Luotsi.Cli.Infrastructure.Devices; + +namespace Luotsi.Cli.Cli.Routing; + +internal static class DeviceSelectorResolver +{ + public static async Task ResolveAsync(CliOptions options, string adbExecutable, ArtifactSession artifacts, string? command, DeviceHostLauncher deviceHostLauncher) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(artifacts); + ArgumentNullException.ThrowIfNull(deviceHostLauncher); + + var query = options.Get("device-query"); + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + if (string.Equals(command, "devices", StringComparison.OrdinalIgnoreCase)) + { + throw new UsageException("--device-query selects one target device and is not supported with `devices`. Use `device-status --device-query ` for a single-device status."); + } + + if (!string.IsNullOrWhiteSpace(options.Get("device"))) + { + throw new UsageException("Use either --device or --device-query, not both."); + } + + var inventoryHost = deviceHostLauncher.Create(options, adbExecutable, artifacts, deviceSelector: null); + var inventory = DeviceInventory.FromDeviceList(await inventoryHost.GetDevicesAsync().ConfigureAwait(false)); + var selected = DeviceQuerySelector.Select(inventory, query); + if (string.IsNullOrWhiteSpace(selected.Serial)) + { + throw new UsageException($"--device-query '{query}' selected a device without a serial."); + } + + return selected.Serial; + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs b/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs new file mode 100644 index 0000000..da51452 --- /dev/null +++ b/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs @@ -0,0 +1,25 @@ +using Luotsi.Cli.Infrastructure.Contracts; +using Luotsi.Cli.Infrastructure.Devices; +using Luotsi.Cli.Models; + +namespace Luotsi.Cli.Cli.Routing; + +internal static class DeviceStatusResolver +{ + public static async Task ReadAsync(IDeviceHost runner, IAdbCommandHost adbCommandHost) + { + ArgumentNullException.ThrowIfNull(runner); + ArgumentNullException.ThrowIfNull(adbCommandHost); + + var readiness = await adbCommandHost.ReadPreflightAsync(null).ConfigureAwait(false); + var inventory = DeviceInventory.FromDeviceList(await runner.GetDevicesAsync().ConfigureAwait(false)); + var matchedDevice = inventory.Devices.FirstOrDefault(device => string.Equals(device.Serial, readiness.Serial, StringComparison.OrdinalIgnoreCase)); + + if (matchedDevice is null) + { + throw new InvalidOperationException($"Selected device '{readiness.Serial}' was not present in `adb devices -l` output."); + } + + return new DeviceStatusResult(matchedDevice, readiness); + } +} \ No newline at end of file diff --git a/Luotsi.Cli/Infrastructure/Devices/DeviceInventory.cs b/Luotsi.Cli/Infrastructure/Devices/DeviceInventory.cs new file mode 100644 index 0000000..031def4 --- /dev/null +++ b/Luotsi.Cli/Infrastructure/Devices/DeviceInventory.cs @@ -0,0 +1,106 @@ +using Luotsi.Cli.Models; + +namespace Luotsi.Cli.Infrastructure.Devices; + +internal static class DeviceInventory +{ + public static DeviceInventoryResult FromDeviceList(DeviceListResult list) + { + ArgumentNullException.ThrowIfNull(list); + return new DeviceInventoryResult(list.Devices.Select(ToState).ToArray()); + } + + public static DeviceState ToState(DeviceInfo device) + { + ArgumentNullException.ThrowIfNull(device); + + var details = ParseDetails(device.Details); + var serial = string.IsNullOrWhiteSpace(device.Serial) ? null : device.Serial; + var state = string.IsNullOrWhiteSpace(device.Status) ? "unknown" : device.Status.Trim(); + var transport = GetTransport(serial, details); + var type = GetType(serial, details); + var availability = string.Equals(state, "device", StringComparison.OrdinalIgnoreCase) + ? "available" + : "unavailable"; + + return new DeviceState( + serial, + NormalizeState(state), + transport, + type, + GetDetail(details, "model"), + GetDetail(details, "product"), + GetDetail(details, "device"), + device.Details, + availability, + GetRecommendedFix(state)); + } + + private static IReadOnlyDictionary ParseDetails(string details) + { + if (string.IsNullOrWhiteSpace(details)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return details + .Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(static part => part.Split(':', 2)) + .Where(static pair => pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0])) + .GroupBy(static pair => pair[0], StringComparer.OrdinalIgnoreCase) + .ToDictionary(static group => group.Key, static group => group.First()[1], StringComparer.OrdinalIgnoreCase); + } + + private static string? GetDetail(IReadOnlyDictionary details, string key) => + details.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + + private static string GetTransport(string? serial, IReadOnlyDictionary details) + { + if (!string.IsNullOrWhiteSpace(serial) && serial.Contains(':', StringComparison.Ordinal)) + { + return "wifi"; + } + + if (!string.IsNullOrWhiteSpace(serial) && serial.StartsWith("emulator-", StringComparison.OrdinalIgnoreCase)) + { + return "emulator"; + } + + if (details.ContainsKey("usb")) + { + return "usb"; + } + + return "unknown"; + } + + private static string GetType(string? serial, IReadOnlyDictionary details) + { + if (!string.IsNullOrWhiteSpace(serial) && serial.StartsWith("emulator-", StringComparison.OrdinalIgnoreCase)) + { + return "emulator"; + } + + var model = GetDetail(details, "model"); + if (!string.IsNullOrWhiteSpace(model) && model.Contains("emulator", StringComparison.OrdinalIgnoreCase)) + { + return "emulator"; + } + + return "physical"; + } + + private static string NormalizeState(string state) => + string.Equals(state, "device", StringComparison.OrdinalIgnoreCase) ? "online" : state.ToLowerInvariant(); + + private static string? GetRecommendedFix(string state) => + state.ToLowerInvariant() switch + { + "offline" => "Run `adb reconnect offline`, reconnect USB, or reconnect wireless ADB.", + "unauthorized" => "Authorize the device debugging prompt, then rerun the command.", + "no permissions" => "Fix host USB permissions for adb access.", + _ => null + }; +} diff --git a/Luotsi.Cli/Models/CommandResults.cs b/Luotsi.Cli/Models/CommandResults.cs index 9f712b8..47483fb 100644 --- a/Luotsi.Cli/Models/CommandResults.cs +++ b/Luotsi.Cli/Models/CommandResults.cs @@ -8,6 +8,22 @@ public sealed record DeviceInfo(string? Serial, string? Status, string Details); public sealed record DeviceListResult(IReadOnlyList Devices); +public sealed record DeviceInventoryResult(IReadOnlyList Devices); + +public sealed record DeviceStatusResult(DeviceState Device, PreflightResult Readiness); + +public sealed record DeviceState( + string? Serial, + string State, + string Transport, + string Type, + string? Model, + string? Product, + string? Device, + string Details, + string Availability, + string? RecommendedFix); + // Preflight public sealed record PreflightResult( string Model, diff --git a/docs/commands.md b/docs/commands.md index e23259b..4cdf10c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -17,6 +17,7 @@ luotsi [--device ] [--platform android] [--adb ] [--adb-timeout-se | Command | Description | |---|---| | `devices` | List adb-visible devices | +| `device-status (--device | --device-query )` | Read selected device inventory metadata plus current readiness details | | `adb server-status` | Host ADB server status | | `adb version` | ADB binary version | | `adb features --device ` | ADB feature set for a device | From cf84191bbc2924afd2f388a09d7cb105c403d21a Mon Sep 17 00:00:00 2001 From: Slideep Date: Tue, 19 May 2026 15:36:42 +0300 Subject: [PATCH 2/3] Address PR review feedback --- Luotsi.Cli.Tests/AdbReadinessTests.cs | 70 ++++++++++++++++++- Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs | 9 ++- .../Cli/Routing/DeviceStatusResolver.cs | 3 +- .../DeviceInventorySelectionException.cs | 10 +++ Luotsi.Cli/Hosts/Android/DeviceRunner.cs | 6 +- docs/commands.md | 2 +- 6 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 Luotsi.Cli/Errors/DeviceInventorySelectionException.cs diff --git a/Luotsi.Cli.Tests/AdbReadinessTests.cs b/Luotsi.Cli.Tests/AdbReadinessTests.cs index 63b58b1..5fe731b 100644 --- a/Luotsi.Cli.Tests/AdbReadinessTests.cs +++ b/Luotsi.Cli.Tests/AdbReadinessTests.cs @@ -149,6 +149,32 @@ List of devices attached Assert.Equal("unavailable", devices[2].GetProperty("availability").GetString()); } + [Fact] + public async Task RunAsync_Devices_Preserves_MultiWord_Device_State() + { + var adb = new FakeAdbClient(); + adb.EnqueueRunResult(new ProcessResult(0, """ + List of devices attached + USB123 no permissions usb:1 product:oriole model:Pixel_6 device:oriole transport_id:1 + """, string.Empty)); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + AdbClientFactory = new FakeAdbClientFactory(adb) + }); + + var exitCode = await app.RunAsync(["devices"]); + using var output = console.ParseSingleOutputAsJson(); + var device = output.RootElement.GetProperty("data").GetProperty("devices")[0]; + + Assert.Equal(0, exitCode); + Assert.Equal("no permissions", device.GetProperty("state").GetString()); + Assert.Equal("Fix host USB permissions for adb access.", device.GetProperty("recommended_fix").GetString()); + } + [Fact] public async Task RunAsync_DeviceStatus_Writes_State_For_Selected_Device() { @@ -261,7 +287,7 @@ public async Task RunAsync_DeviceStatus_Writes_Exact_Command_Envelope() }, artifacts = new { - artifact_root = Path.Combine(Path.Combine("/tmp", "luotsi"), "20260515-120000-device-status"), + artifact_root = Path.Combine("/tmp", "luotsi", "20260515-120000-device-status"), poll_artifacts = "final" }, schema = ResultSchemas.CommandEnvelope, @@ -292,6 +318,7 @@ public async Task RunAsync_DeviceStatus_Requires_Selected_Device_To_Exist_In_Inv using var output = console.ParseSingleOutputAsJson(); Assert.Equal(1, exitCode); + Assert.Equal("configuration_error", output.RootElement.GetProperty("error").GetProperty("category").GetString()); Assert.Contains("was not present in `adb devices -l` output", output.RootElement.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); } @@ -319,6 +346,47 @@ public async Task RunAsync_DeviceQuery_Selects_Exactly_One_Device_Before_Command Assert.Equal([null], host.CommandPreflightRequests); } + [Fact] + public async Task RunAsync_DeviceQuery_Can_Select_MultiWord_State() + { + var host = new FakeDeviceHost(); + host.ConnectedDevices.Add(new DeviceInfo("USB123", "no permissions", "product:oriole model:Pixel_6 device:oriole")); + var factory = new FakeDeviceHostFactory(host); + var app = new App(new AppDependencies + { + Console = new FakeConsole(), + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = factory + }); + + var exitCode = await app.RunAsync(["preflight", "--device-query", "state=no permissions"]); + + Assert.Equal(0, exitCode); + Assert.Equal("USB123", factory.Configurations[1].DeviceSerial); + } + + [Fact] + public async Task RunAsync_DeviceQuery_Requires_At_Least_One_Clause() + { + var host = new FakeDeviceHost(); + host.ConnectedDevices.Add(new DeviceInfo("USB123", "device", "product:oriole model:Pixel_6 device:oriole")); + var console = new FakeConsole(); + var app = new App(new AppDependencies + { + Console = console, + FileSystem = new FakeFileSystem(), + TimeProvider = DateTimeOffset.Parse("2026-05-15T12:00:00Z").ToTimeProvider(), + DeviceHostFactory = new FakeDeviceHostFactory(host) + }); + + var exitCode = await app.RunAsync(["preflight", "--device-query", ",,,"]); + using var output = console.ParseSingleOutputAsJson(); + + Assert.Equal(2, exitCode); + Assert.Contains("at least one key=value clause", output.RootElement.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal); + } + [Fact] public async Task RunAsync_DeviceQuery_Multiple_Matches_Returns_Usage_Error() { diff --git a/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs b/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs index f5ca2b8..6840aba 100644 --- a/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs +++ b/Luotsi.Cli/Cli/Routing/DeviceQuerySelector.cs @@ -22,10 +22,17 @@ private static IReadOnlyList Parse(string rawQuery) throw new UsageException("--device-query must be non-empty."); } - return rawQuery + var clauses = rawQuery .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .Select(DeviceQueryClause.Parse) .ToArray(); + + if (clauses.Length == 0) + { + throw new UsageException("--device-query must include at least one key=value clause."); + } + + return clauses; } } diff --git a/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs b/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs index da51452..7547798 100644 --- a/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs +++ b/Luotsi.Cli/Cli/Routing/DeviceStatusResolver.cs @@ -1,3 +1,4 @@ +using Luotsi.Cli.Errors; using Luotsi.Cli.Infrastructure.Contracts; using Luotsi.Cli.Infrastructure.Devices; using Luotsi.Cli.Models; @@ -17,7 +18,7 @@ public static async Task ReadAsync(IDeviceHost runner, IAdbC if (matchedDevice is null) { - throw new InvalidOperationException($"Selected device '{readiness.Serial}' was not present in `adb devices -l` output."); + throw new DeviceInventorySelectionException(readiness.Serial); } return new DeviceStatusResult(matchedDevice, readiness); diff --git a/Luotsi.Cli/Errors/DeviceInventorySelectionException.cs b/Luotsi.Cli/Errors/DeviceInventorySelectionException.cs new file mode 100644 index 0000000..429a6bb --- /dev/null +++ b/Luotsi.Cli/Errors/DeviceInventorySelectionException.cs @@ -0,0 +1,10 @@ +using Luotsi.Cli.Scenarios; + +namespace Luotsi.Cli.Errors; + +public sealed class DeviceInventorySelectionException(string? serial) : Exception($"Selected device '{serial}' was not present in `adb devices -l` output."), ICommandFailureDetails +{ + public string CategoryOverride => "configuration_error"; + + public object? DataPayload => null; +} \ No newline at end of file diff --git a/Luotsi.Cli/Hosts/Android/DeviceRunner.cs b/Luotsi.Cli/Hosts/Android/DeviceRunner.cs index d270ea7..3854378 100644 --- a/Luotsi.Cli/Hosts/Android/DeviceRunner.cs +++ b/Luotsi.Cli/Hosts/Android/DeviceRunner.cs @@ -55,7 +55,11 @@ public async Task GetDevicesAsync() .Select(static line => { var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); - return new DeviceInfo(parts.ElementAtOrDefault(0), parts.ElementAtOrDefault(1), string.Join(' ', parts.Skip(2))); + var detailIndex = Array.FindIndex(parts, 1, static part => part.Contains(':', StringComparison.Ordinal)); + var statusEndIndex = detailIndex >= 0 ? detailIndex : parts.Length; + var status = statusEndIndex > 1 ? string.Join(' ', parts[1..statusEndIndex]) : null; + var details = detailIndex >= 0 ? string.Join(' ', parts[detailIndex..]) : string.Empty; + return new DeviceInfo(parts.ElementAtOrDefault(0), status, details); }) .ToArray(); return new DeviceListResult(devices); diff --git a/docs/commands.md b/docs/commands.md index 4cdf10c..c0905ee 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3,7 +3,7 @@ All commands run on the host machine and return a single JSON envelope unless noted as a JSONL session. ``` -luotsi [--device ] [--platform android] [--adb ] [--adb-timeout-sec ] [flags] +luotsi [--device | --device-query ] [--platform android] [--adb ] [--adb-timeout-sec ] [flags] ``` **ADB path.** If `adb` is not on `PATH` (common in WSL), pass `--adb /path/to/adb` or set `LUOTSI_ADB`. Bounded ADB commands default to a 120-second timeout; override with `--adb-timeout-sec ` or `LUOTSI_ADB_TIMEOUT_SEC`. Use `0` to disable. From 4207c08e73d743b6de1fa23fca2c40a48e724893 Mon Sep 17 00:00:00 2001 From: Slideep Date: Tue, 19 May 2026 15:41:35 +0300 Subject: [PATCH 3/3] Address follow-up review nit --- Luotsi.Cli.Tests/AdbReadinessTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Luotsi.Cli.Tests/AdbReadinessTests.cs b/Luotsi.Cli.Tests/AdbReadinessTests.cs index 5fe731b..4917d4b 100644 --- a/Luotsi.Cli.Tests/AdbReadinessTests.cs +++ b/Luotsi.Cli.Tests/AdbReadinessTests.cs @@ -287,7 +287,7 @@ public async Task RunAsync_DeviceStatus_Writes_Exact_Command_Envelope() }, artifacts = new { - artifact_root = Path.Combine("/tmp", "luotsi", "20260515-120000-device-status"), + artifact_root = $"/tmp{Path.DirectorySeparatorChar}luotsi{Path.DirectorySeparatorChar}20260515-120000-device-status", poll_artifacts = "final" }, schema = ResultSchemas.CommandEnvelope,