Skip to content
Open
150 changes: 150 additions & 0 deletions PolyPilot.Tests/ServerRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,156 @@ public void IsAuthError_ReturnsFalseForEmptyAggregate()
Assert.False(CopilotService.IsAuthError(agg));
}

// ===== IsAuthError string overload =====

[Theory]
[InlineData("Unauthorized")]
[InlineData("Not authenticated")]
[InlineData("not created with authentication info")]
[InlineData("Token expired")]
[InlineData("HTTP 401")]
public void IsAuthError_StringOverload_DetectsAuthMessages(string message)
{
Assert.True(CopilotService.IsAuthError(message));
}

[Theory]
[InlineData("Session not found")]
[InlineData("Connection refused")]
[InlineData("")]
public void IsAuthError_StringOverload_ReturnsFalseForNonAuth(string message)
{
Assert.False(CopilotService.IsAuthError(message));
}

// ===== GetLoginCommand =====

[Fact]
public void GetLoginCommand_ReturnsFallback_WhenNoSettings()
{
var svc = CreateService();
var cmd = svc.GetLoginCommand();
// Without settings or resolved path, returns the generic fallback
Assert.Contains("login", cmd);
}

// ===== ClearAuthNotice =====

[Fact]
public async Task ClearAuthNotice_ClearsNoticeAndStopsPolling()
{
var svc = CreateService();
await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });
// ClearAuthNotice should not throw even when no notice is set
svc.ClearAuthNotice();
Assert.Null(svc.AuthNotice);
}

// ===== ReauthenticateAsync =====

[Fact]
public async Task ReauthenticateAsync_NonPersistentMode_SetsFailureNotice()
{
var svc = CreateService();
// Initialize in Demo mode — TryRecoverPersistentServerAsync returns false
await svc.ReconnectAsync(new ConnectionSettings { Mode = ConnectionMode.Demo });
await svc.ReauthenticateAsync();
// Should set a failure notice since recovery isn't available in demo mode
Assert.NotNull(svc.AuthNotice);
Assert.Contains("restart failed", svc.AuthNotice!, StringComparison.OrdinalIgnoreCase);
}

// ===== ResolveGitHubTokenForServer =====

[Fact]
public void ResolveGitHubTokenForServer_ReturnsNull_WhenNoTokenAvailable()
{
// In test environment, no env vars should be set and gh CLI may not be available.
// The method should return null gracefully without throwing.
var token = CopilotService.ResolveGitHubTokenForServer();
// We can't assert null because the test runner might have GH_TOKEN set.
// Just verify it doesn't throw and returns a string or null.
Assert.True(token == null || token.Length > 0);
}

// ===== TryReadCopilotKeychainToken =====

[Fact]
public void TryReadCopilotKeychainToken_DoesNotThrow()
{
// Should silently return null (or a token) — never throw — even if the entry
// is absent, the `security` binary is missing, or it times out.
var result = CopilotService.TryReadCopilotKeychainToken();
Assert.True(result == null || result.Length > 0);
}

[Fact]
public void TryReadCopilotKeychainToken_ReturnsNonEmptyToken_WhenCopilotLoginDone()
{
// Only meaningful on macOS where `copilot login` writes to the login Keychain.
// On non-macOS the method always returns null — that's fine, verified by the
// DoesNotThrow test above.
if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsMacCatalyst())
return;

var result = CopilotService.TryReadCopilotKeychainToken();
// May be null if the user hasn't run `copilot login`, but must never be empty string.
Assert.True(result == null || result.Length > 0);
}

[Fact]
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();
Assert.Equal("test-token-123", mgr.LastGitHubToken);
}

[Fact]
public void ServerManager_AcceptsNullGitHubToken_InStartServerAsync()
{
var mgr = new StubServerManager();
mgr.StartServerResult = true;
mgr.StartServerAsync(4321).GetAwaiter().GetResult();
Assert.Null(mgr.LastGitHubToken);
}

// ===== RunProcessWithTimeout =====

[Fact]
public void RunProcessWithTimeout_ReturnsOutput_OnSuccess()
{
// `echo` is universally available — should return the text
var result = CopilotService.RunProcessWithTimeout("echo", new[] { "hello" }, 3000);
Assert.Equal("hello", result);
}

[Fact]
public void RunProcessWithTimeout_ReturnsNull_OnNonZeroExit()
{
// `false` exits with code 1
var result = CopilotService.RunProcessWithTimeout("false", Array.Empty<string>(), 3000);
Assert.Null(result);
}

[Fact]
public void RunProcessWithTimeout_ReturnsNull_OnMissingBinary()
{
var result = CopilotService.RunProcessWithTimeout("nonexistent-binary-12345",
Array.Empty<string>(), 3000);
Assert.Null(result);
}

[Fact]
public void RunProcessWithTimeout_ReturnsNull_WhenTimeoutExceeded()
{
// `sleep 30` with a 100ms timeout should be killed
var result = CopilotService.RunProcessWithTimeout("sleep", new[] { "30" }, 100);
Assert.Null(result);
}

// ===== IsConnectionError now catches auth errors =====

[Theory]
Expand Down
4 changes: 3 additions & 1 deletion PolyPilot.Tests/TestStubs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ internal class StubServerManager : IServerManager

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

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

public void StopServer() { IsServerRunning = false; StopServerCallCount++; }
public int StopServerCallCount { get; private set; }
Expand Down
47 changes: 47 additions & 0 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@
<button class="dismiss-btn" @onclick="DismissServerHealthNotice">Dismiss</button>
</div>
}
@if (!string.IsNullOrEmpty(CopilotService.AuthNotice))
{
<div class="init-error-card auth-notice">
<span class="init-error-icon">🔑</span>
<div class="auth-notice-content">
<p class="init-error-text">@CopilotService.AuthNotice</p>
<div class="auth-command-row">
<code class="auth-command">@CopilotService.GetLoginCommand()</code>
<button class="copy-cmd-btn" @onclick="CopyLoginCommand" title="Copy to clipboard">📋</button>
</div>
</div>
<div class="auth-notice-actions">
<button class="retry-btn" @onclick="ReauthenticateAsync" disabled="@_isReauthenticating">
@(_isReauthenticating ? "Restarting…" : "Re-authenticate")
</button>
<button class="dismiss-btn" @onclick="DismissAuthNotice">Dismiss</button>
</div>
</div>
}
@if (PlatformHelper.IsMobile && !CopilotService.IsInitialized && !_initializationComplete)
{
<div class="restoring-indicator">
Expand Down Expand Up @@ -753,6 +772,34 @@
StateHasChanged();
}

private void DismissAuthNotice()
{
CopilotService.ClearAuthNotice();
StateHasChanged();
}

private bool _isReauthenticating;
private async Task ReauthenticateAsync()
{
if (_isReauthenticating) return;
_isReauthenticating = true;
StateHasChanged();
try
{
await CopilotService.ReauthenticateAsync();
}
finally
{
_isReauthenticating = false;
StateHasChanged();
}
}

private async Task CopyLoginCommand()
{
await CopyToClipboard(CopilotService.GetLoginCommand());
}

private async Task DashboardScanQr()
{
var result = await QrScanner.ScanAsync();
Expand Down
44 changes: 44 additions & 0 deletions PolyPilot/Components/Pages/Dashboard.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,50 @@
.fallback-notice .init-error-icon { font-size: var(--type-large-title); }
.fallback-notice .init-error-text { color: var(--text-secondary); }

.auth-notice {
max-width: 400px;
align-items: flex-start;
}
.auth-notice-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.auth-command-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.auth-command {
font-family: var(--font-mono, 'SF Mono', monospace);
font-size: var(--type-callout);
background: rgba(255,255,255,0.06);
padding: 0.25rem 0.5rem;
border-radius: 6px;
color: var(--text-primary);
user-select: all;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.copy-cmd-btn {
background: transparent;
border: none;
cursor: pointer;
font-size: var(--type-body);
padding: 0.15rem 0.3rem;
border-radius: 4px;
opacity: 0.7;
}
.copy-cmd-btn:hover { opacity: 1; background: var(--hover-bg); }
.auth-notice-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}

.session-grid {
display: grid;
grid-template-columns: repeat(3, 1fr); /* overridden by inline style from _gridColumns */
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/Models/ErrorMessageHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ public static string HumanizeMessage(string message)
if (message.Contains("Host is down", StringComparison.OrdinalIgnoreCase))
return "The server appears to be down. Try again later.";

// Authentication errors from the CLI SDK
if (message.Contains("not created with authentication info", StringComparison.OrdinalIgnoreCase))
return "Not authenticated — run `copilot login` (or `gh auth login`) in your terminal, then click Re-authenticate.";

// Catch-all for any other net_webstatus_ codes we haven't mapped
if (message.Contains("net_webstatus_", StringComparison.OrdinalIgnoreCase))
return "A network error occurred. Check your connection and try again.";
Expand Down
6 changes: 6 additions & 0 deletions PolyPilot/Services/CopilotService.Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,12 @@ await notifService.SendNotificationAsync(
{
if (state.IsOrphaned) return;
OnError?.Invoke(sessionName, errMsg);
// Surface auth errors as a dismissible banner
if (IsAuthError(err.Data?.Message ?? ""))
{
AuthNotice = "Not authenticated — run the login command below, then click Re-authenticate.";
StartAuthPolling();
}
// Flush any accumulated partial response before clearing the accumulator
FlushCurrentResponse(state);
state.FlushedResponse.Clear();
Expand Down
Loading