Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControl/InteractiveTerminalViewModelTests.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

128 lines
4.7 KiB
C#

using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl;
public class InteractiveTerminalViewModelTests
{
private static readonly DateTime LaunchedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
// ── Died-at-startup detection (fix: an immediate ConPTY exit left StartError null, so
// ConPtyPaneViewModel.CanRetry — which requires StartError — never offered Retry) ────────
[Fact]
public void ProcessExitsNonzero_RightAfterLaunch_SetsStartError()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
terminal.IsRunning = true; // simulates a successful ConPTY spawn
terminal.OnSessionProcessExited(null, 1);
Assert.False(terminal.IsRunning);
Assert.True(terminal.HasExited);
Assert.Equal(1, terminal.ExitCode);
Assert.NotNull(terminal.StartError);
}
[Fact]
public void ProcessExitsNonzero_AfterRunningAWhile_DoesNotSetStartError()
{
var exitedAt = LaunchedAt.AddSeconds(10);
var callCount = 0;
using var terminal = new InteractiveTerminalViewModel(() => callCount++ == 0 ? LaunchedAt : exitedAt);
terminal.IsRunning = true; // records LaunchedAt via the first clock call
terminal.OnSessionProcessExited(null, 1); // crash after running for a while
Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError);
Assert.True(terminal.HasExitedAfterStartup); // no StartError banner, so the host needs this signal instead
}
[Fact]
public void ProcessExitsZero_RightAfterLaunch_DoesNotSetStartError()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
terminal.IsRunning = true;
terminal.OnSessionProcessExited(null, 0); // user typed exit / session ended cleanly
Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError);
Assert.True(terminal.HasExitedAfterStartup);
}
// ── HasExitedAfterStartup (drives the host's "session ended" banner — every death that
// ISN'T already covered by StartError) ─────────────────────────────────────────────────
[Fact]
public void FreshTerminal_HasExitedAfterStartupIsFalse()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
Assert.False(terminal.HasExitedAfterStartup);
}
[Fact]
public void ProcessExitsNonzero_WithinGraceWindow_HasExitedAfterStartupIsFalse()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
terminal.IsRunning = true;
terminal.OnSessionProcessExited(null, 1); // startup failure — StartError covers it instead
Assert.NotNull(terminal.StartError);
Assert.False(terminal.HasExitedAfterStartup);
}
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
// the worker turns it into a usage-limit auto-restart for list handlers) ───────────────────
[Fact]
public void ProcessExitsNonzero_AfterRunningAWhile_RaisesExitedAfterStartup()
{
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
int? reported = null;
terminal.ExitedAfterStartup += code => reported = code;
terminal.OnSessionProcessExited(null, 1);
Assert.Equal(1, reported);
}
[Fact]
public void ProcessExitsNonzero_WithinGraceWindow_DoesNotRaiseExitedAfterStartup()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
terminal.IsRunning = true;
var raised = false;
terminal.ExitedAfterStartup += _ => raised = true;
terminal.OnSessionProcessExited(null, 1);
Assert.False(raised);
Assert.NotNull(terminal.StartError); // still the startup-failure banner
}
[Fact]
public void ProcessExitsZero_AfterRunningAWhile_DoesNotRaiseExitedAfterStartup()
{
using var terminal = NewTerminalExitingAfter(TimeSpan.FromMinutes(90));
var raised = false;
terminal.ExitedAfterStartup += _ => raised = true;
terminal.OnSessionProcessExited(null, 0);
Assert.False(raised);
}
private static InteractiveTerminalViewModel NewTerminalExitingAfter(TimeSpan uptime)
{
var callCount = 0;
var terminal = new InteractiveTerminalViewModel(
() => callCount++ == 0 ? LaunchedAt : LaunchedAt + uptime);
terminal.IsRunning = true; // records LaunchedAt via the first clock call
return terminal;
}
}