Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions Luotsi.Cli.Tests/AppCommandFamilyClassifierTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Luotsi.Cli.Cli;
using Xunit;

namespace Luotsi.Cli.Tests;

public sealed class AppCommandFamilyClassifierTests
{
[Fact]
public void Classify_ProfileList_Returns_ProfileList_Family()
{
var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["profile-list"]));

Assert.Equal(AppCommandFamily.ProfileList, classification.Family);
Assert.Null(classification.ViewDiagnostic);
}

[Fact]
public void Classify_Inspect_Returns_Inspect_Family()
{
var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["inspect"]));

Assert.Equal(AppCommandFamily.Inspect, classification.Family);
Assert.Null(classification.ViewDiagnostic);
}

[Fact]
public void Classify_ViewSetup_Alias_Returns_ViewDiagnostics_Setup_Invocation()
{
var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["view", "setup", "--device", "abc"]));

Assert.Equal(AppCommandFamily.ViewDiagnostics, classification.Family);
var invocation = Assert.IsType<ViewDiagnosticInvocation>(classification.ViewDiagnostic);
Assert.Equal(ViewDiagnosticAction.Setup, invocation.Action);
Assert.Equal("view-setup", invocation.EnvelopeCommand);
Assert.True(invocation.Fix);
}

[Fact]
public void Classify_ViewDoctorFix_Returns_ViewDiagnostics_Setup_Invocation()
{
var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["view-doctor", "--device", "abc", "--fix"]));

Assert.Equal(AppCommandFamily.ViewDiagnostics, classification.Family);
var invocation = Assert.IsType<ViewDiagnosticInvocation>(classification.ViewDiagnostic);
Assert.Equal(ViewDiagnosticAction.Setup, invocation.Action);
Assert.Equal("view-doctor", invocation.EnvelopeCommand);
Assert.True(invocation.Fix);
}

[Fact]
public void Classify_Reconnect_Returns_ViewSession_Family()
{
var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["reconnect"]));

Assert.Equal(AppCommandFamily.ViewSession, classification.Family);
Assert.Null(classification.ViewDiagnostic);
}

[Fact]
public void Classify_Devices_Returns_HostedCommand_Family()
{
var classification = AppCommandFamilyClassifier.Classify(CliOptions.Parse(["devices"]));

Assert.Equal(AppCommandFamily.HostedCommand, classification.Family);
Assert.Null(classification.ViewDiagnostic);
}
}
10 changes: 10 additions & 0 deletions Luotsi.Cli.Tests/CliParsingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,14 @@ public void Parse_Skips_Known_Command_Words_When_They_Are_Option_Values()
Assert.Equal("devices", options.Get("package"));
}

[Fact]
public void Parse_Normalizes_ViewSetup_Alias_Command_And_Removes_Alias_Argument()
{
var options = CliOptions.Parse(["view", "--device", "abc", "setup", "extra"]);

Assert.Equal("view-setup", options.Command);
Assert.Equal(["extra"], options.Arguments);
Assert.Equal("abc", options.Get("device"));
}

}
41 changes: 39 additions & 2 deletions Luotsi.Cli.Tests/TestSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,37 @@ public IViewDoctor Create(IDeviceHost deviceHost)
}
}

internal sealed class FakeViewSetup(Func<ViewOptions, bool, ViewSetupResult>? resultFactory = null) : IViewSetup
{
private readonly Func<ViewOptions, bool, ViewSetupResult> _resultFactory = resultFactory ?? ((options, fix) =>
new ViewSetupResult(
true,
fix,
options.PresetName,
options,
[new ViewSetupStep("helper_install", ViewStartupPhaseStatus.Succeeded, "Installed.")],
new ViewDoctorResult(true, options.PresetName, options, [], null, [])));

public List<(ViewOptions Options, bool Fix)> Calls { get; } = [];

public Task<ViewSetupResult> SetupAsync(ViewOptions options, bool fix, CancellationToken cancellationToken = default)
{
Calls.Add((options, fix));
return Task.FromResult(_resultFactory(options, fix));
}
}

internal sealed class FakeViewSetupFactory(IViewSetup viewSetup) : IViewSetupFactory
{
public IDeviceHost? LastDeviceHost { get; private set; }

public IViewSetup Create(IDeviceHost deviceHost)
{
LastDeviceHost = deviceHost;
return viewSetup;
}
}

internal sealed class FakeViewRendererFactory(IViewRenderer renderer) : IViewRendererFactory
{
public Func<ViewInteractionRequest, Task>? LastInteractionHandler { get; private set; }
Expand All @@ -954,9 +985,10 @@ internal sealed class FakeViewRendererFactory(IViewRenderer renderer) : IViewRen
}
}

internal sealed class FakeViewTransportBootstrap(IEnumerable<object> outcomes) : IViewTransportBootstrap
internal sealed class FakeViewTransportBootstrap(IEnumerable<object> outcomes, IEnumerable<ViewStartupPhase>? startupPhases = null) : IViewTransportBootstrap
{
private readonly Queue<object> _outcomes = new(outcomes);
private readonly ViewStartupPhase[] _startupPhases = startupPhases?.ToArray() ?? [];

public FakeViewTransportBootstrap(ViewConnectionInfo connectionInfo)
: this([connectionInfo])
Expand All @@ -969,10 +1001,15 @@ public FakeViewTransportBootstrap(ViewConnectionInfo connectionInfo)

public List<ViewStartRequest> StartRequests { get; } = [];

public Task<ViewConnectionInfo> StartAsync(ViewStartRequest request, CancellationToken cancellationToken = default)
public Task<ViewConnectionInfo> StartAsync(ViewStartRequest request, Action<ViewStartupPhase>? reportPhase = null, CancellationToken cancellationToken = default)
{
StartCallCount++;
StartRequests.Add(request);
foreach (var phase in _startupPhases)
{
reportPhase?.Invoke(phase);
}

var outcome = _outcomes.Count > 1 ? _outcomes.Dequeue() : _outcomes.Peek();
if (outcome is Exception exception)
{
Expand Down
198 changes: 198 additions & 0 deletions Luotsi.Cli.Tests/ViewDoctorTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Luotsi.Cli.Cli;
using Luotsi.Cli.Hosts.Android.View;
using Luotsi.Cli.Models;
using Luotsi.Cli.View;
using Luotsi.Cli.View.Contracts;
using Luotsi.Cli.View.Diagnostics;
using Luotsi.Cli.View.Recording;
Expand Down Expand Up @@ -50,6 +51,96 @@ public async Task RunAsync_ViewDoctor_Uses_Injected_ViewDoctorFactory()
Assert.Equal(250, options.StatsIntervalMs);
}

[Fact]
public async Task RunAsync_ViewSetup_Uses_Injected_ViewSetupFactory()
{
var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind));
var console = new FakeConsole();
var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in"));
var setup = new FakeViewSetup();
var factory = new FakeViewSetupFactory(setup);
var app = new App(new AppDependencies
{
Console = console,
TimeProvider = timeProvider,
DeviceHostFactory = new FakeDeviceHostFactory(host),
ViewSetupFactory = factory
});

var exitCode = await app.RunAsync([
"view-setup",
"--device", "192.168.0.134:5555",
"--preset", "safe"]);

using var envelope = console.ParseSingleOutputAsJson();
Assert.Equal(0, exitCode);
Assert.True(envelope.RootElement.GetProperty("ok").GetBoolean());
Assert.Equal("view-setup", envelope.RootElement.GetProperty("command").GetString());
Assert.True(envelope.RootElement.GetProperty("data").GetProperty("fix").GetBoolean());
Assert.True(envelope.RootElement.GetProperty("data").GetProperty("ready").GetBoolean());
Assert.Same(host, factory.LastDeviceHost);
var call = Assert.Single(setup.Calls);
Assert.True(call.Fix);
Assert.Equal("safe", call.Options.PresetName);
}

[Fact]
public async Task RunAsync_ViewSetup_Alias_Writes_ViewSetup_Command_Envelope()
{
var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind));
var console = new FakeConsole();
var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in"));
var setup = new FakeViewSetup();
var fileSystem = new FakeFileSystem();
var app = new App(new AppDependencies
{
Console = console,
TimeProvider = timeProvider,
FileSystem = fileSystem,
DeviceHostFactory = new FakeDeviceHostFactory(host),
ViewSetupFactory = new FakeViewSetupFactory(setup)
});

var exitCode = await app.RunAsync([
"view",
"setup",
"--device", "192.168.0.134:5555",
"--artifacts", "/tmp/artifacts"]);

using var envelope = console.ParseSingleOutputAsJson();
Assert.Equal(0, exitCode);
Assert.Equal("view-setup", envelope.RootElement.GetProperty("command").GetString());
Assert.True(Assert.Single(setup.Calls).Fix);
Assert.True(fileSystem.DirectoryExists("/tmp/artifacts/20260515-120000-view-setup"));
}

[Fact]
public async Task RunAsync_ViewDoctor_Fix_Runs_Setup()
{
var timeProvider = new ManualTimeProvider(DateTimeOffset.Parse("2026-05-15T12:00:00Z", null, System.Globalization.DateTimeStyles.RoundtripKind));
var console = new FakeConsole();
var host = new FakeDeviceHost(CreateScreenState(timeProvider.GetUtcNow(), "Sign in"));
var setup = new FakeViewSetup();
var factory = new FakeViewSetupFactory(setup);
var app = new App(new AppDependencies
{
Console = console,
TimeProvider = timeProvider,
DeviceHostFactory = new FakeDeviceHostFactory(host),
ViewSetupFactory = factory
});

var exitCode = await app.RunAsync([
"view-doctor",
"--device", "192.168.0.134:5555",
"--fix"]);

using var envelope = console.ParseSingleOutputAsJson();
Assert.Equal(0, exitCode);
Assert.Equal("view-doctor", envelope.RootElement.GetProperty("command").GetString());
Assert.True(envelope.RootElement.GetProperty("data").GetProperty("fix").GetBoolean());
Assert.True(Assert.Single(setup.Calls).Fix);
}


[Fact]
Expand Down Expand Up @@ -175,5 +266,112 @@ public async Task ViewDoctor_DiagnoseAsync_Flags_Explicit_MediaProjection_Consen
Assert.Contains("fallback=none", consentCheck.Detail, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task AndroidViewHelperSetupProvisioner_ResolveOrBuildAsync_Skips_Build_When_Fix_Is_Disabled()
{
var environment = new FakeEnvironmentVariables(new Dictionary<string, string>());
var processRunner = new FakeProcessRunner();
var provisioner = new AndroidViewHelperSetupProvisioner(
new SequencedAndroidViewHelperPackageLocator(new InvalidOperationException("missing helper")),
new ViewHostPathResolver(environment),
new FakeFileSystem(),
processRunner);
var steps = new List<ViewSetupStep>();

var package = await provisioner.ResolveOrBuildAsync(fix: false, steps.Add);

Assert.Null(package);
Assert.Empty(processRunner.Calls);
Assert.Collection(
steps,
step =>
{
Assert.Equal("helper_resolve", step.Name);
Assert.Equal(ViewStartupPhaseStatus.Failed, step.Status);
},
step =>
{
Assert.Equal("helper_build", step.Name);
Assert.Equal(ViewStartupPhaseStatus.Skipped, step.Status);
});
}

[Fact]
public async Task AndroidViewHelperSetupProvisioner_ResolveOrBuildAsync_Builds_And_Reresolves_Helper()
{
var environment = new FakeEnvironmentVariables(new Dictionary<string, string>());
var fileSystem = new FakeFileSystem();
var pathResolver = new ViewHostPathResolver(environment);
var projectDirectory = pathResolver.GetRepositoryRelativeDirectoryCandidates("Luotsi.ViewServer.Android").First();
var wrapperPath = OperatingSystem.IsWindows()
? Path.Join(projectDirectory, "gradlew.bat")
: Path.Join(projectDirectory, "gradlew");
fileSystem.CreateDirectory(projectDirectory);
fileSystem.AddFile(wrapperPath, string.Empty);

var processRunner = new FakeProcessRunner();
processRunner.EnqueueResult(new ProcessResult(0, "BUILD SUCCESSFUL", string.Empty));

var package = new AndroidViewHelperPackage("C:/tmp/helper.apk", "/data/local/tmp/luotsi-view-server.apk", "dev.luotsi.view.Main", "test-helper");
var provisioner = new AndroidViewHelperSetupProvisioner(
new SequencedAndroidViewHelperPackageLocator(new InvalidOperationException("missing helper"), package),
pathResolver,
fileSystem,
processRunner);
var steps = new List<ViewSetupStep>();

var resolved = await provisioner.ResolveOrBuildAsync(fix: true, steps.Add);

Assert.Same(package, resolved);
var call = Assert.Single(processRunner.Calls);
Assert.Equal(wrapperPath, call.FileName);
Assert.Equal(["-p", projectDirectory, ":app:assembleDebug"], call.Args);
Assert.Collection(
steps,
step =>
{
Assert.Equal("helper_resolve", step.Name);
Assert.Equal(ViewStartupPhaseStatus.Failed, step.Status);
},
step =>
{
Assert.Equal("helper_build", step.Name);
Assert.Equal(ViewStartupPhaseStatus.Started, step.Status);
Assert.Equal(projectDirectory, step.Detail);
},
step =>
{
Assert.Equal("helper_build", step.Name);
Assert.Equal(ViewStartupPhaseStatus.Succeeded, step.Status);
Assert.Equal("BUILD SUCCESSFUL", step.Detail);
},
step =>
{
Assert.Equal("helper_resolve", step.Name);
Assert.Equal(ViewStartupPhaseStatus.Succeeded, step.Status);
});
}

private sealed class SequencedAndroidViewHelperPackageLocator(params object[] outcomes) : IAndroidViewHelperPackageLocator
{
private readonly Queue<object> _outcomes = new(outcomes);

public AndroidViewHelperPackage Resolve()
{
if (_outcomes.Count == 0)
{
throw new InvalidOperationException("No fake helper locator outcomes remain.");
}

var outcome = _outcomes.Count > 1 ? _outcomes.Dequeue() : _outcomes.Peek();
return outcome switch
{
AndroidViewHelperPackage package => package,
Exception exception => throw exception,
_ => throw new InvalidOperationException($"Unsupported fake helper locator outcome '{outcome.GetType().Name}'.")
};
}
}


}
Loading
Loading