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
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 @@ -793,6 +793,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