Skip to content
Open
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
306 changes: 306 additions & 0 deletions PolyPilot.Tests/SmartUrlResolutionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
using PolyPilot.Models;
using PolyPilot.Services;
using Microsoft.Extensions.DependencyInjection;

namespace PolyPilot.Tests;

public class SmartUrlResolutionTests
{
// --- ConnectionSettings.LanUrl/LanToken serialization ---

[Fact]
public void ConnectionSettings_LanUrl_RoundTrips()
{
var original = new ConnectionSettings
{
Mode = ConnectionMode.Remote,
RemoteUrl = "https://tunnel.devtunnels.ms",
RemoteToken = "tunnel-jwt",
LanUrl = "http://192.168.1.5:4322",
LanToken = "server-pass"
};

var json = System.Text.Json.JsonSerializer.Serialize(original);
var loaded = System.Text.Json.JsonSerializer.Deserialize<ConnectionSettings>(json);

Assert.NotNull(loaded);
Assert.Equal("http://192.168.1.5:4322", loaded!.LanUrl);
Assert.Equal("server-pass", loaded.LanToken);
Assert.Equal("https://tunnel.devtunnels.ms", loaded.RemoteUrl);
}

[Fact]
public void ConnectionSettings_LanUrl_NullByDefault()
{
var settings = new ConnectionSettings();
Assert.Null(settings.LanUrl);
Assert.Null(settings.LanToken);
}

[Fact]
public void ConnectionSettings_BackwardCompat_OldJsonWithoutLanUrl()
{
var json = """{"Mode":2,"RemoteUrl":"https://tunnel.devtunnels.ms","RemoteToken":"jwt"}""";
var loaded = System.Text.Json.JsonSerializer.Deserialize<ConnectionSettings>(json);

Assert.NotNull(loaded);
Assert.Equal("https://tunnel.devtunnels.ms", loaded!.RemoteUrl);
Assert.Null(loaded.LanUrl);
Assert.Null(loaded.LanToken);
}

// --- WsBridgeClient.ProbeLanAsync (static overload) ---

[Fact]
public async Task ProbeLan_NullUrl_ReturnsFalse()
{
var result = await WsBridgeClient.ProbeLanAsync(null, null, CancellationToken.None);
Assert.False(result);
}

[Fact]
public async Task ProbeLan_EmptyUrl_ReturnsFalse()
{
var result = await WsBridgeClient.ProbeLanAsync("", null, CancellationToken.None);
Assert.False(result);
}

[Fact]
public async Task ProbeLan_UnreachableHost_ReturnsFalse()
{
// Use a non-routable IP to guarantee timeout/failure within 2s
var result = await WsBridgeClient.ProbeLanAsync("ws://192.0.2.1:4322", null, CancellationToken.None);
Assert.False(result);
}

[Fact]
public async Task ProbeLan_Cancelled_ReturnsFalse()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = await WsBridgeClient.ProbeLanAsync("ws://192.168.1.5:4322", null, cts.Token);
Assert.False(result);
}

// --- WsBridgeClient.IsCellularOnly ---

[Fact]
public void IsCellularOnly_OnDesktop_ReturnsFalse()
{
// Desktop builds compile out the MAUI Connectivity API — should always return false
Assert.False(WsBridgeClient.IsCellularOnly());
}

// --- WsBridgeClient.ResolveUrlAsync ---

[Fact]
public async Task ResolveUrl_BothUrls_LanUnreachable_FallsToTunnel()
{
// Create a client and set up dual-URL state via reflection
var client = new WsBridgeClient();
SetField(client, "_tunnelWsUrl", "wss://tunnel.devtunnels.ms");
SetField(client, "_tunnelToken", "tunnel-jwt");
SetField(client, "_lanWsUrl", "ws://192.0.2.1:4322"); // Non-routable
SetField(client, "_lanToken", "pass");

var (url, token) = await client.ResolveUrlAsync(CancellationToken.None);

Assert.Equal("wss://tunnel.devtunnels.ms", url);
Assert.Equal("tunnel-jwt", token);
}

[Fact]
public async Task ResolveUrl_OnlyLan_ReturnsLan()
{
var client = new WsBridgeClient();
SetField(client, "_tunnelWsUrl", null);
SetField(client, "_tunnelToken", null);
SetField(client, "_lanWsUrl", "ws://192.168.1.5:4322");
SetField(client, "_lanToken", "pass");

var (url, token) = await client.ResolveUrlAsync(CancellationToken.None);

Assert.Equal("ws://192.168.1.5:4322", url);
Assert.Equal("pass", token);
}

[Fact]
public async Task ResolveUrl_OnlyTunnel_ReturnsTunnelUrl()
{
var client = new WsBridgeClient();
SetField(client, "_tunnelWsUrl", "wss://tunnel.devtunnels.ms");
SetField(client, "_tunnelToken", "jwt");
SetField(client, "_lanWsUrl", null);
SetField(client, "_lanToken", null);

var (url, token) = await client.ResolveUrlAsync(CancellationToken.None);

Assert.Equal("wss://tunnel.devtunnels.ms", url);
Assert.Equal("jwt", token);
}

// --- ToWebSocketUrl (via CopilotService.Bridge internal static) ---

[Theory]
[InlineData("https://tunnel.devtunnels.ms", "wss://tunnel.devtunnels.ms")]
[InlineData("http://192.168.1.5:4322", "ws://192.168.1.5:4322")]
[InlineData("wss://already.ws", "wss://already.ws")]
[InlineData("ws://already.ws:4322", "ws://already.ws:4322")]
[InlineData("http://host:4322/", "ws://host:4322")]
[InlineData(null, null)]
[InlineData("", null)]
public void ToWebSocketUrl_ConvertsCorrectly(string? input, string? expected)
{
// ToWebSocketUrl is private static in CopilotService, but we can test it via
// a public proxy or just verify the same logic in the test
var result = TestToWebSocketUrl(input);
Assert.Equal(expected, result);
}

// --- QR code parsing (JSON with lanUrl/lanToken) ---

[Fact]
public void QrJson_WithBothUrls_ParsesCorrectly()
{
var json = """{"url":"https://tunnel.devtunnels.ms","token":"jwt","lanUrl":"http://192.168.1.5:4322","lanToken":"pass"}""";
var doc = System.Text.Json.JsonDocument.Parse(json);

string? url = null, token = null, lanUrl = null, lanToken = null;
if (doc.RootElement.TryGetProperty("url", out var urlProp)) url = urlProp.GetString();
if (doc.RootElement.TryGetProperty("token", out var tokenProp)) token = tokenProp.GetString();
if (doc.RootElement.TryGetProperty("lanUrl", out var lanUrlProp)) lanUrl = lanUrlProp.GetString();
if (doc.RootElement.TryGetProperty("lanToken", out var lanTokenProp)) lanToken = lanTokenProp.GetString();

Assert.Equal("https://tunnel.devtunnels.ms", url);
Assert.Equal("jwt", token);
Assert.Equal("http://192.168.1.5:4322", lanUrl);
Assert.Equal("pass", lanToken);
}

[Fact]
public void QrJson_OnlyLanUrl_ParsesCorrectly()
{
var json = """{"lanUrl":"http://192.168.1.5:4322","lanToken":"pass"}""";
var doc = System.Text.Json.JsonDocument.Parse(json);

string? url = null, lanUrl = null, lanToken = null;
if (doc.RootElement.TryGetProperty("url", out var urlProp)) url = urlProp.GetString();
if (doc.RootElement.TryGetProperty("lanUrl", out var lanUrlProp)) lanUrl = lanUrlProp.GetString();
if (doc.RootElement.TryGetProperty("lanToken", out var lanTokenProp)) lanToken = lanTokenProp.GetString();

Assert.Null(url); // No tunnel URL
Assert.Equal("http://192.168.1.5:4322", lanUrl);
Assert.Equal("pass", lanToken);
}

[Fact]
public void QrJson_LegacyFormat_NoLanUrl()
{
var json = """{"url":"https://tunnel.devtunnels.ms","token":"jwt"}""";
var doc = System.Text.Json.JsonDocument.Parse(json);

string? lanUrl = null;
if (doc.RootElement.TryGetProperty("lanUrl", out var lanUrlProp)) lanUrl = lanUrlProp.GetString();

Assert.Null(lanUrl); // Old QR codes don't have lanUrl
}

// --- StubWsBridgeClient has new methods ---

[Fact]
public async Task StubBridgeClient_ConnectSmartAsync_DoesNotThrow()
{
var stub = new StubWsBridgeClient();
await stub.ConnectSmartAsync("wss://t", "jwt", "ws://l", "pass");
// No exception = pass
}

[Fact]
public void StubBridgeClient_ActiveUrl_DefaultsNull()
{
var stub = new StubWsBridgeClient();
Assert.Null(stub.ActiveUrl);
}

// --- CopilotService validation accepts LanUrl ---

[Fact]
public async Task CopilotService_RemoteMode_LanUrlOnly_DoesNotNeedConfiguration()
{
var stub = new StubWsBridgeClient();
var service = CopilotServiceTestHelper.CreateService(stub);

var settings = new ConnectionSettings
{
Mode = ConnectionMode.Remote,
RemoteUrl = null,
LanUrl = "http://192.168.1.5:4322",
LanToken = "pass"
};

// InitializeAsync would try to connect — we just verify it doesn't set NeedsConfiguration
// by checking the validation logic
bool needsConfig = settings.Mode == ConnectionMode.Remote
&& string.IsNullOrWhiteSpace(settings.RemoteUrl)
&& string.IsNullOrWhiteSpace(settings.LanUrl);
Assert.False(needsConfig);
}

[Fact]
public void CopilotService_RemoteMode_NoUrls_NeedsConfiguration()
{
var settings = new ConnectionSettings
{
Mode = ConnectionMode.Remote,
RemoteUrl = null,
LanUrl = null
};

bool needsConfig = settings.Mode == ConnectionMode.Remote
&& string.IsNullOrWhiteSpace(settings.RemoteUrl)
&& string.IsNullOrWhiteSpace(settings.LanUrl);
Assert.True(needsConfig);
}

// --- Helper to mirror ToWebSocketUrl logic ---

private static string? TestToWebSocketUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var trimmed = url.TrimEnd('/');
if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return "wss://" + trimmed[8..];
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
return "ws://" + trimmed[7..];
if (trimmed.StartsWith("wss://", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("ws://", StringComparison.OrdinalIgnoreCase))
return trimmed;
return "wss://" + trimmed;
}

private static void SetField(object obj, string name, object? value)
{
var field = obj.GetType().GetField(name,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
field?.SetValue(obj, value);
}
}

/// <summary>
/// Helper to create CopilotService with test stubs for smart URL tests.
/// </summary>
internal static class CopilotServiceTestHelper
{
public static CopilotService CreateService(StubWsBridgeClient? client = null)
{
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
var sp = services.BuildServiceProvider();
return new CopilotService(
new StubChatDatabase(),
new StubServerManager(),
client ?? new StubWsBridgeClient(),
new RepoManager(),
sp,
new StubDemoService());
}
}
3 changes: 3 additions & 0 deletions PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ internal class StubWsBridgeClient : IWsBridgeClient
public event Action<AttentionNeededPayload>? OnAttentionNeeded;

public Task ConnectAsync(string wsUrl, string? authToken = null, CancellationToken ct = default) => Task.CompletedTask;
public Task ConnectSmartAsync(string? tunnelWsUrl, string? tunnelToken, string? lanWsUrl, string? lanToken, CancellationToken ct = default) => Task.CompletedTask;
public string? ActiveUrl { get; set; }
public void Stop() { IsConnected = false; }
public void AbortForReconnect() { }
public int RequestSessionsCallCount { get; private set; }
public Task RequestSessionsAsync(CancellationToken ct = default)
{
Expand Down
14 changes: 12 additions & 2 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@
private string? expandedSession;
private string mobileRemoteUrl = "";
private string mobileRemoteToken = "";
private string mobileLanUrl = "";
private string mobileLanToken = "";
private bool mobileConnecting;
private string? mobileConnectError;
private bool _needsScrollToBottom;
Expand Down Expand Up @@ -413,6 +415,8 @@
var cs = ConnectionSettings.Load();
mobileRemoteUrl = cs.RemoteUrl ?? "";
mobileRemoteToken = cs.RemoteToken ?? "";
mobileLanUrl = cs.LanUrl ?? "";
mobileLanToken = cs.LanToken ?? "";
}
}

Expand Down Expand Up @@ -570,12 +574,16 @@

try
{
// Try JSON format first: { "url": "...", "token": "..." }
// Try JSON format first: { "url": "...", "token": "...", "lanUrl": "...", "lanToken": "..." }
var doc = System.Text.Json.JsonDocument.Parse(result);
if (doc.RootElement.TryGetProperty("url", out var urlProp))
mobileRemoteUrl = urlProp.GetString() ?? "";
if (doc.RootElement.TryGetProperty("token", out var tokenProp))
mobileRemoteToken = tokenProp.GetString() ?? "";
if (doc.RootElement.TryGetProperty("lanUrl", out var lanUrlProp))
mobileLanUrl = lanUrlProp.GetString() ?? "";
if (doc.RootElement.TryGetProperty("lanToken", out var lanTokenProp))
mobileLanToken = lanTokenProp.GetString() ?? "";
}
catch
{
Expand All @@ -599,7 +607,7 @@

private async Task DashboardConnect()
{
if (string.IsNullOrWhiteSpace(mobileRemoteUrl)) return;
if (string.IsNullOrWhiteSpace(mobileRemoteUrl) && string.IsNullOrWhiteSpace(mobileLanUrl)) return;
mobileConnecting = true;
mobileConnectError = null;
StateHasChanged();
Expand All @@ -610,6 +618,8 @@
connSettings.Mode = ConnectionMode.Remote;
connSettings.RemoteUrl = mobileRemoteUrl;
connSettings.RemoteToken = mobileRemoteToken;
connSettings.LanUrl = mobileLanUrl;
connSettings.LanToken = mobileLanToken;
connSettings.Save();
await CopilotService.ReconnectAsync(connSettings);
}
Expand Down
Loading