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); } [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); } // ── 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; } }