PasteAsync silently swallowed every exception and returned void, so a package update renaming Iciclecreek's private write seam would break Ctrl+V/drop with zero user-visible signal. PasteAsync/SendTextAsync now return bool (mirroring SendLineAsync), and the view checks the result, routing a failure through InteractiveTerminalViewModel.ErrorReported -> ConPtyPaneViewModel.ErrorReported -> the existing footer error strip.
159 lines
5.6 KiB
C#
159 lines
5.6 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;
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|