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
3 changes: 2 additions & 1 deletion PolyPilot.Provider.Abstractions/IProviderHostContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,6 @@ public enum ProviderConnectionMode
public enum ProviderCliSource
{
BuiltIn,
System
System,
Custom
}
22 changes: 22 additions & 0 deletions PolyPilot.Tests/CliPathResolutionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ public void CliSourceMode_System_IsOne()
Assert.Equal(1, (int)CliSourceMode.System);
}

[Fact]
public void CliSourceMode_Custom_IsTwo()
{
Assert.Equal(2, (int)CliSourceMode.Custom);
}

[Fact]
public void CopilotClientOptions_CliPath_AcceptsNonExistentPath()
{
Expand Down Expand Up @@ -410,6 +416,22 @@ public void ResolveCopilotCliPath_System_ReturnsNonNull()
"(system install or bundled fallback)");
}

[Fact]
public void ResolveCopilotCliPath_Custom_ReturnsConfiguredPath()
{
var path = CopilotService.ResolveCopilotCliPath(CliSourceMode.Custom, "/tmp/custom-copilot");

Assert.Equal("/tmp/custom-copilot", path);
}

[Fact]
public void ResolveCopilotCliPath_Custom_Blank_ReturnsNull()
{
var path = CopilotService.ResolveCopilotCliPath(CliSourceMode.Custom, " ");

Assert.Null(path);
}

[Fact]
public void GetCliSourceInfo_ReturnsBuiltInPath()
{
Expand Down
64 changes: 62 additions & 2 deletions PolyPilot.Tests/ConnectionSettingsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ public void DefaultValues_NewFields_AreCorrect()
Assert.Null(settings.ServerPassword);
Assert.False(settings.DirectSharingEnabled);
Assert.Equal(CliSourceMode.BuiltIn, settings.CliSource);
Assert.Null(settings.CustomCliPath);
Assert.Null(settings.CustomCliArguments);
Assert.Null(settings.RepositoryStorageRoot);
}

Expand All @@ -155,7 +157,9 @@ public void Save_Load_RoundTrip_WithNewFields()
Port = 4321,
ServerPassword = "mypass",
DirectSharingEnabled = true,
CliSource = CliSourceMode.System,
CliSource = CliSourceMode.Custom,
CustomCliPath = "/custom/copilot-wrapper",
CustomCliArguments = "copilot --profile work",
RepositoryStorageRoot = "D:\\DevDrive\\PolyPilot"
};

Expand All @@ -165,7 +169,9 @@ public void Save_Load_RoundTrip_WithNewFields()
Assert.NotNull(loaded);
Assert.Equal("mypass", loaded!.ServerPassword);
Assert.True(loaded.DirectSharingEnabled);
Assert.Equal(CliSourceMode.System, loaded.CliSource);
Assert.Equal(CliSourceMode.Custom, loaded.CliSource);
Assert.Equal("/custom/copilot-wrapper", loaded.CustomCliPath);
Assert.Equal("copilot --profile work", loaded.CustomCliArguments);
Assert.Equal("D:\\DevDrive\\PolyPilot", loaded.RepositoryStorageRoot);
}

Expand All @@ -181,6 +187,8 @@ public void BackwardCompatibility_OldJsonWithoutNewFields()
Assert.Null(loaded.ServerPassword);
Assert.False(loaded.DirectSharingEnabled);
Assert.Equal(CliSourceMode.BuiltIn, loaded.CliSource);
Assert.Null(loaded.CustomCliPath);
Assert.Null(loaded.CustomCliArguments);
Assert.Null(loaded.RepositoryStorageRoot);
}

Expand All @@ -196,6 +204,58 @@ public void NormalizeRepositoryStorageRoot_TrimmedPath_ReturnsTrimmed()
Assert.Equal("C:\\Dev", ConnectionSettings.NormalizeRepositoryStorageRoot(" C:\\Dev "));
}

[Fact]
public void NormalizeCustomCliPath_Whitespace_ReturnsNull()
{
Assert.Null(ConnectionSettings.NormalizeCustomCliPath(" "));
}

[Fact]
public void NormalizeCustomCliArguments_Trimmed_ReturnsTrimmed()
{
Assert.Equal("--flag value", ConnectionSettings.NormalizeCustomCliArguments(" --flag value "));
}

[Fact]
public void SplitCommandLineArguments_HandlesQuotesAndSpaces()
{
var args = ConnectionSettings.SplitCommandLineArguments("wrapper subcommand --label \"two words\" 'three words'");

Assert.Equal(new[] { "wrapper", "subcommand", "--label", "two words", "three words" }, args);
}

[Fact]
public void SplitCommandLineArguments_PreservesBackslashesInUnquotedPaths()
{
var args = ConnectionSettings.SplitCommandLineArguments("--launcher C:\\Tools\\wrapper.exe --flag");

Assert.Equal(new[] { "--launcher", "C:\\Tools\\wrapper.exe", "--flag" }, args);
}

[Fact]
public void SplitCommandLineArguments_PreservesBackslashesInDoubleQuotedPaths()
{
var args = ConnectionSettings.SplitCommandLineArguments("--config \"C:\\Users\\me\\.copilot\\config.json\"");

Assert.Equal(new[] { "--config", "C:\\Users\\me\\.copilot\\config.json" }, args);
}

[Fact]
public void SplitCommandLineArguments_PreservesBackslashesInQuotedUncPaths()
{
var args = ConnectionSettings.SplitCommandLineArguments("--config \"\\\\server\\share\\tool.json\"");

Assert.Equal(new[] { "--config", "\\\\server\\share\\tool.json" }, args);
}

[Fact]
public void SplitCommandLineArguments_StillSupportsEscapedWhitespace()
{
var args = ConnectionSettings.SplitCommandLineArguments("one\\ two three");

Assert.Equal(new[] { "one two", "three" }, args);
}

[Fact]
public void ServerPassword_NotInCliUrl()
{
Expand Down
4 changes: 2 additions & 2 deletions PolyPilot.Tests/ProtocolVersionMismatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ public void StubServerManager_StopServer_ClearsIsServerRunning()
public async Task StubServerManager_StartServerAsync_ReturnsConfiguredResult()
{
_serverManager.StartServerResult = true;
Assert.True(await _serverManager.StartServerAsync(4321));
Assert.True(await _serverManager.StartServerAsync(new ConnectionSettings { Port = 4321 }));

_serverManager.StartServerResult = false;
Assert.False(await _serverManager.StartServerAsync(4321));
Assert.False(await _serverManager.StartServerAsync(new ConnectionSettings { Port = 4321 }));
}

[Fact]
Expand Down
15 changes: 15 additions & 0 deletions PolyPilot.Tests/ProviderPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ public void ProviderHostContext_Maps_Embedded()
Assert.Equal(ProviderCliSource.BuiltIn, ctx.CliSource);
}

[Fact]
public void ProviderHostContext_Maps_CustomCliSource()
{
var settings = new ConnectionSettings
{
Mode = ConnectionMode.Embedded,
CliSource = CliSourceMode.Custom,
CustomCliPath = "/custom/copilot-wrapper"
};
var ctx = new ProviderHostContext(settings);

Assert.Equal(ProviderConnectionMode.Embedded, ctx.ConnectionMode);
Assert.Equal(ProviderCliSource.Custom, ctx.CliSource);
}

[Fact]
public void ProviderHostContext_Maps_Persistent()
{
Expand Down
30 changes: 30 additions & 0 deletions PolyPilot.Tests/ServerManagerTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Sockets;
using PolyPilot.Models;
using PolyPilot.Services;

namespace PolyPilot.Tests;
Expand Down Expand Up @@ -88,4 +89,33 @@ public void CheckServerRunning_DefaultPort_UsesServerPort()
Assert.True(defaultResult || !defaultResult); // completed without throwing
Assert.False(customResult);
}

[Fact]
public void BuildLaunchArguments_PrependsCustomArgs_BeforeHeadlessFlags()
{
var settings = new ConnectionSettings
{
CliSource = CliSourceMode.Custom,
CustomCliPath = "/custom/copilot-wrapper",
CustomCliArguments = "copilot --profile work"
};

var args = ServerManager.BuildLaunchArguments(settings, 4567);

Assert.Equal("copilot", args[0]);
Assert.Equal("--profile", args[1]);
Assert.Equal("work", args[2]);
Assert.Contains("--headless", args);
Assert.Contains("4567", args);
}

[Fact]
public void CreateStartInfo_ForwardsGitHubToken()
{
var settings = new ConnectionSettings();
var psi = ServerManager.CreateStartInfo(settings, "/tmp/copilot", 4321, "token-123");

Assert.Equal("/tmp/copilot", psi.FileName);
Assert.Equal("token-123", psi.Environment["COPILOT_GITHUB_TOKEN"]);
}
}
4 changes: 2 additions & 2 deletions PolyPilot.Tests/ServerRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ public void ServerManager_AcceptsGitHubToken_InStartServerAsync()
// Verify the stub properly records the token parameter
var mgr = new StubServerManager();
mgr.StartServerResult = true;
mgr.StartServerAsync(4321, "test-token-123").GetAwaiter().GetResult();
mgr.StartServerAsync(new ConnectionSettings { Port = 4321 }, "test-token-123").GetAwaiter().GetResult();
Assert.Equal("test-token-123", mgr.LastGitHubToken);
}

Expand All @@ -220,7 +220,7 @@ public void ServerManager_AcceptsNullGitHubToken_InStartServerAsync()
{
var mgr = new StubServerManager();
mgr.StartServerResult = true;
mgr.StartServerAsync(4321).GetAwaiter().GetResult();
mgr.StartServerAsync(new ConnectionSettings { Port = 4321 }).GetAwaiter().GetResult();
Assert.Null(mgr.LastGitHubToken);
}

Expand Down
20 changes: 20 additions & 0 deletions PolyPilot.Tests/SettingsRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ public void ForCategory_RespectsVisibilityPredicate()
Assert.DoesNotContain(cliSettings, s => s.Id == "cli.source");
}

[Fact]
public void CustomCliSettings_Hidden_WhenSourceIsNotCustom()
{
var ctx = CreateContext(new ConnectionSettings { CliSource = CliSourceMode.BuiltIn });
var devSettings = SettingsRegistry.ForCategory("Developer", ctx).ToList();

Assert.DoesNotContain(devSettings, s => s.Id == "cli.customPath");
Assert.DoesNotContain(devSettings, s => s.Id == "cli.customArguments");
}

[Fact]
public void CustomCliSettings_Visible_WhenSourceIsCustom()
{
var ctx = CreateContext(new ConnectionSettings { CliSource = CliSourceMode.Custom });
var devSettings = SettingsRegistry.ForCategory("Developer", ctx).ToList();

Assert.Contains(devSettings, s => s.Id == "cli.customPath");
Assert.Contains(devSettings, s => s.Id == "cli.customArguments");
}

[Fact]
public void Search_MatchesByLabel()
{
Expand Down
6 changes: 4 additions & 2 deletions PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ internal class StubServerManager : IServerManager

public bool CheckServerRunning(string host = "localhost", int? port = null) => IsServerRunning;

public Task<bool> StartServerAsync(int port, string? githubToken = null)
public Task<bool> StartServerAsync(ConnectionSettings settings, string? githubToken = null)
{
ServerPort = port;
ServerPort = settings.Port;
LastGitHubToken = githubToken;
LastStartSettings = settings;
return Task.FromResult(StartServerResult);
}
public string? LastGitHubToken { get; private set; }
public ConnectionSettings? LastStartSettings { get; private set; }

public void StopServer() { IsServerRunning = false; StopServerCallCount++; }
public int StopServerCallCount { get; private set; }
Expand Down
40 changes: 36 additions & 4 deletions PolyPilot/Components/Pages/Settings.razor
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@
}
@if (cliSourceChanged)
{
<div class="cli-restart-hint">⟳ Restart the app to apply the change</div>
<div class="cli-restart-hint">⟳ Reconnect or restart the persistent server to apply the change</div>
}
</div>
}
Expand Down Expand Up @@ -831,7 +831,7 @@
{
"connection" => SectionVisible("transport mode embedded persistent remote server port start stop pid devtunnel share tunnel mobile qr url token connect save reconnect"),
"ui" => SectionVisible("chat message layout default reversed both left theme font size text zoom"),
"developer" => SectionVisible("auto update main git watch relaunch rebuild cli source built-in system repository repo clone worktree storage root directory dev drive"),
"developer" => SectionVisible("auto update main git watch relaunch rebuild cli source built-in system custom executable path launcher wrapper repository repo clone worktree storage root directory dev drive"),
"plugins" => SectionVisible("plugins provider extension dll assembly trust enable disable"),
"diagnostics" => SectionVisible("logs diagnostics troubleshoot crash event console"),
_ => true
Expand Down Expand Up @@ -1121,7 +1121,7 @@
private void SetCliSource(CliSourceMode source)
{
// Only block selection if no CLI is available at all (both modes have fallback logic)
if (cliInfo.builtInPath == null && cliInfo.systemPath == null) return;
if (source != CliSourceMode.Custom && cliInfo.builtInPath == null && cliInfo.systemPath == null) return;
settings.CliSource = source;
cliSourceChanged = source != _initialCliSource;
settings.Save();
Expand All @@ -1141,6 +1141,31 @@
private static bool IsStorageRootValid(string? path) =>
string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path.Trim());

private bool ValidateCustomCliConfiguration()
{
settings.CustomCliPath = ConnectionSettings.NormalizeCustomCliPath(settings.CustomCliPath);
settings.CustomCliArguments = ConnectionSettings.NormalizeCustomCliArguments(settings.CustomCliArguments);

if (settings.CliSource != CliSourceMode.Custom
|| settings.Mode == ConnectionMode.Remote
|| settings.Mode == ConnectionMode.Demo)
return true;

if (string.IsNullOrWhiteSpace(settings.CustomCliPath))
{
ShowStatus("Enter a custom CLI path or executable name first", "error", 5000);
return false;
}

if (Path.IsPathRooted(settings.CustomCliPath) && !File.Exists(settings.CustomCliPath))
{
ShowStatus($"Custom CLI path not found: {settings.CustomCliPath}", "error", 8000);
return false;
}

return true;
}

private async Task BrowseRepositoryStorageRoot()
{
#if MACCATALYST || WINDOWS
Expand Down Expand Up @@ -1293,10 +1318,14 @@

private async Task StartServer()
{
if (!ValidateCustomCliConfiguration())
return;

settings.Save();
starting = true;
StateHasChanged();

var success = await ServerManager.StartServerAsync(settings.Port);
var success = await ServerManager.StartServerAsync(settings);
serverAlive = success;
starting = false;

Expand Down Expand Up @@ -1657,6 +1686,9 @@
return;
}

if (!ValidateCustomCliConfiguration())
return;

if (settings.Mode == ConnectionMode.Persistent && !serverAlive)
{
ShowStatus("Start the persistent server first", "error", 5000);
Expand Down
Loading