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; } // ── Paste/drop failure reporting (fix: PasteAsync used to swallow every exception and return // void, so a failed Ctrl+V or file drop gave the user no signal at all) ──────────────────── [Fact] public async Task PasteAsync_ReturnsFalse_WhenSessionNotRunning() { using var terminal = new InteractiveTerminalViewModel(); Assert.False(await terminal.PasteAsync()); } [Fact] public async Task SendTextAsync_ReturnsFalse_WhenSessionNotRunning() { using var terminal = new InteractiveTerminalViewModel(); Assert.False(await terminal.SendTextAsync("dropped/path")); } [Fact] public void ReportSendFailed_RaisesErrorReported() { using var terminal = new InteractiveTerminalViewModel(); string? error = null; terminal.ErrorReported += msg => error = msg; terminal.ReportSendFailed(); Assert.NotNull(error); } }