Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/ConPtyPaneViewModelTests.cs
T
mika kuns 256bea5ffa fix(ui): show a banner when a ConPTY session dies after startup
InteractiveTerminalViewModel.HasExitedAfterStartup now covers every
post-startup death that ExitedAfterStartup alone missed (crash, clean
exit, quiet exit inside the grace window) -- previously the tile just
stayed looking alive/thinking with zero signal. ConPtyPaneView binds a
new banner to it (message + Retry + Close); CanRetry simplifies to
"any HasExited state" since the same descriptor factory can relaunch
either kind of death. HandlerRestartCoordinator's worker-side
auto-restart path is untouched.
2026-09-10 13:32:19 +02:00

237 lines
7.7 KiB
C#

using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.MissionControl;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl;
public class ConPtyPaneViewModelTests
{
private static ConPtyPaneViewModel NewTaskPane(
Func<Task<TerminalLaunchDescriptor>> descriptorFactory,
string taskId = "t1")
=> new(taskId, "Some Task", descriptorFactory);
private static Task<TerminalLaunchDescriptor> NeverCompletes()
=> new TaskCompletionSource<TerminalLaunchDescriptor>().Task;
private static Task<TerminalLaunchDescriptor> Failing(string message = "boom")
=> Task.FromException<TerminalLaunchDescriptor>(new InvalidOperationException(message));
// ── CanSubmitForReview gating (fix: don't offer Submit for Review on a dead/starting pane) ──
[Fact]
public void CanSubmitForReview_False_WhileStarting()
{
using var pane = NewTaskPane(NeverCompletes);
pane.Start();
Assert.True(pane.Terminal.IsStarting);
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_False_AfterLaunchFailure()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
Assert.NotNull(pane.Terminal.StartError);
Assert.True(pane.Terminal.HasExited);
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_True_WhenRunning()
{
using var pane = NewTaskPane(NeverCompletes);
SetRunning(pane);
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
SetRunning(pane);
Assert.False(pane.IsTaskBased);
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
}
[Fact]
public void CanSubmitForReview_False_WhileSubmitPending()
{
using var pane = NewTaskPane(NeverCompletes);
SetRunning(pane);
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
pane.IsSubmitPending = true;
Assert.False(pane.SubmitForReviewCommand.CanExecute(null));
pane.IsSubmitPending = false;
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
}
// ── Retry (fix: a failed launch used to permanently occupy the TaskId dedupe slot) ──────────
[Fact]
public void RetryCommand_Disabled_BeforeAndWhileStarting()
{
using var pane = NewTaskPane(NeverCompletes);
Assert.False(pane.RetryCommand.CanExecute(null));
pane.Start();
Assert.False(pane.RetryCommand.CanExecute(null)); // still starting, no failure yet
}
[Fact]
public void RetryCommand_Enabled_AfterLaunchFailure()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
Assert.True(pane.RetryCommand.CanExecute(null));
}
[Fact]
public void Retry_ReplacesTerminal_AndRefetchesDescriptor()
{
var callCount = 0;
Func<Task<TerminalLaunchDescriptor>> factory = () =>
{
callCount++;
return callCount == 1 ? Failing() : NeverCompletes();
};
using var pane = NewTaskPane(factory);
pane.Start();
Assert.True(pane.RetryCommand.CanExecute(null));
var terminalBeforeRetry = pane.Terminal;
pane.RetryCommand.Execute(null);
Assert.Equal(2, callCount);
Assert.NotSame(terminalBeforeRetry, pane.Terminal);
Assert.Null(pane.Terminal.StartError);
Assert.False(pane.Terminal.HasExited);
Assert.False(pane.RetryCommand.CanExecute(null));
}
[Fact]
public void Retry_FailsAgain_StillOffersRetry()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
Assert.True(pane.RetryCommand.CanExecute(null));
pane.RetryCommand.Execute(null);
Assert.NotNull(pane.Terminal.StartError);
Assert.True(pane.RetryCommand.CanExecute(null));
}
// ── Died-at-startup (fix: an immediate ConPTY exit used to leave StartError null, so Retry
// never became reachable and the pane was stuck reusing the same persisted session id) ──
[Fact]
public void RetryCommand_Enabled_AfterProcessExitsNonzeroRightAfterLaunch()
{
using var pane = NewTaskPane(NeverCompletes);
SetRunning(pane);
Assert.False(pane.RetryCommand.CanExecute(null));
pane.Terminal.OnSessionProcessExited(null, 1);
Assert.NotNull(pane.Terminal.StartError);
Assert.True(pane.RetryCommand.CanExecute(null));
}
// ── HasExitedAfterStartup banner (fix: a mid-run death used to leave a silent dead tile — no
// StartError, no signal at all) ─────────────────────────────────────────────────────────
[Fact]
public void RetryCommand_Enabled_AfterDeathAfterStartup()
{
using var pane = NewTaskPane(NeverCompletes);
DieAfterRunning(pane, exitCode: 1);
Assert.True(pane.Terminal.HasExitedAfterStartup);
Assert.True(pane.RetryCommand.CanExecute(null));
}
[Fact]
public void ExitedBannerText_IncludesExitCode()
{
using var pane = NewTaskPane(NeverCompletes);
DieAfterRunning(pane, exitCode: 42);
Assert.Contains("42", pane.ExitedBannerText);
}
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
[Fact]
public void SessionDiedAfterStartup_TaskPane_RaisesWithTaskIdAndExitCode()
{
using var pane = NewTaskPane(NeverCompletes, taskId: "handler-1");
(string TaskId, int ExitCode)? reported = null;
pane.SessionDiedAfterStartup += (id, code) => reported = (id, code);
DieAfterRunning(pane, exitCode: 1);
Assert.Equal(("handler-1", 1), reported);
}
[Fact]
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true;
DieAfterRunning(pane, exitCode: 1);
Assert.False(raised);
}
// Retry swaps in a fresh terminal -- the death of the RETRIED session must still be reported.
[Fact]
public void SessionDiedAfterStartup_StillWiredAfterRetry()
{
var callCount = 0;
using var pane = NewTaskPane(
() => callCount++ == 0 ? Failing() : NeverCompletes(), taskId: "handler-1");
pane.Start();
pane.RetryCommand.Execute(null);
var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true;
DieAfterRunning(pane, exitCode: 1);
Assert.True(raised);
}
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
// time off the real clock, so a sleep-free "ran for a while" needs the exit routed through a
// terminal whose clock has already moved on.
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
{
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var callCount = 0;
pane.Terminal = new InteractiveTerminalViewModel(
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
pane.Terminal.IsRunning = true;
pane.Terminal.OnSessionProcessExited(null, exitCode);
}
}