diff --git a/src/Hex1b/Automation/AutomationStepRecord.cs b/src/Hex1b/Automation/AutomationStepRecord.cs
new file mode 100644
index 00000000..50e267e7
--- /dev/null
+++ b/src/Hex1b/Automation/AutomationStepRecord.cs
@@ -0,0 +1,30 @@
+namespace Hex1b.Automation;
+
+///
+/// Immutable record of a completed automation step.
+/// Used by to track step history for diagnostic purposes.
+///
+/// 1-based step number in the automation sequence.
+/// Human-readable description of what the step did (e.g., "Key(Enter)", "WaitUntilText(\"File\")").
+/// How long this step took to execute.
+/// Source file path where the automator method was called.
+/// Line number where the automator method was called.
+public sealed record AutomationStepRecord(
+ int Index,
+ string Description,
+ TimeSpan Elapsed,
+ string? CallerFilePath = null,
+ int? CallerLineNumber = null)
+{
+ ///
+ /// Formats this step record as a human-readable string for diagnostic output.
+ ///
+ public override string ToString()
+ {
+ var elapsed = Elapsed.TotalMilliseconds < 1
+ ? "0ms"
+ : $"{Elapsed.TotalMilliseconds:F0}ms";
+
+ return $"[{Index}] {Description} — {elapsed}";
+ }
+}
diff --git a/src/Hex1b/Automation/Hex1bAutomationException.cs b/src/Hex1b/Automation/Hex1bAutomationException.cs
new file mode 100644
index 00000000..cfb2f3b6
--- /dev/null
+++ b/src/Hex1b/Automation/Hex1bAutomationException.cs
@@ -0,0 +1,177 @@
+using System.Text;
+
+namespace Hex1b.Automation;
+
+///
+/// Exception thrown by when an automation step fails.
+/// Includes rich diagnostic information including the full step history, terminal snapshot,
+/// and source location to help pinpoint the failure.
+///
+public sealed class Hex1bAutomationException : Exception
+{
+ ///
+ /// Gets the 1-based index of the step that failed.
+ ///
+ public int FailedStepIndex { get; }
+
+ ///
+ /// Gets the description of the step that failed (e.g., "WaitUntilText(\"File\")").
+ ///
+ public string FailedStepDescription { get; }
+
+ ///
+ /// Gets all steps that completed successfully before the failure.
+ ///
+ public IReadOnlyList CompletedSteps { get; }
+
+ ///
+ /// Gets how long the failed step was executing before it failed.
+ ///
+ public TimeSpan FailedStepElapsed { get; }
+
+ ///
+ /// Gets the total elapsed time across all steps (completed + failed).
+ ///
+ public TimeSpan TotalElapsed { get; }
+
+ ///
+ /// Gets the terminal snapshot captured at the time of failure.
+ /// This is taken from the inner when available,
+ /// or captured directly from the terminal otherwise.
+ ///
+ public Hex1bTerminalSnapshot? TerminalSnapshot { get; }
+
+ ///
+ /// Gets the source file path where the failing automator method was called.
+ ///
+ public string? CallerFilePath { get; }
+
+ ///
+ /// Gets the line number where the failing automator method was called.
+ ///
+ public int? CallerLineNumber { get; }
+
+ internal Hex1bAutomationException(
+ int failedStepIndex,
+ string failedStepDescription,
+ IReadOnlyList completedSteps,
+ TimeSpan failedStepElapsed,
+ Hex1bTerminalSnapshot? terminalSnapshot,
+ string? callerFilePath,
+ int? callerLineNumber,
+ Exception innerException)
+ : base(FormatMessage(
+ failedStepIndex,
+ failedStepDescription,
+ completedSteps,
+ failedStepElapsed,
+ terminalSnapshot,
+ callerFilePath,
+ callerLineNumber,
+ innerException), innerException)
+ {
+ FailedStepIndex = failedStepIndex;
+ FailedStepDescription = failedStepDescription;
+ CompletedSteps = completedSteps;
+ FailedStepElapsed = failedStepElapsed;
+ TerminalSnapshot = terminalSnapshot;
+ CallerFilePath = callerFilePath;
+ CallerLineNumber = callerLineNumber;
+
+ var totalMs = failedStepElapsed.TotalMilliseconds;
+ foreach (var step in completedSteps)
+ {
+ totalMs += step.Elapsed.TotalMilliseconds;
+ }
+ TotalElapsed = TimeSpan.FromMilliseconds(totalMs);
+ }
+
+ private static string FormatMessage(
+ int failedStepIndex,
+ string failedStepDescription,
+ IReadOnlyList completedSteps,
+ TimeSpan failedStepElapsed,
+ Hex1bTerminalSnapshot? terminalSnapshot,
+ string? callerFilePath,
+ int? callerLineNumber,
+ Exception innerException)
+ {
+ var sb = new StringBuilder();
+
+ // Header with step context
+ sb.Append($"Step {failedStepIndex} of {failedStepIndex} failed — {failedStepDescription}");
+
+ // Inner exception message (e.g., timeout details)
+ if (innerException is WaitUntilTimeoutException waitEx)
+ {
+ sb.Append($"\n Timed out after {waitEx.Timeout} waiting for: {waitEx.ConditionDescription}");
+ }
+ else
+ {
+ sb.Append($"\n {innerException.GetType().Name}: {innerException.Message}");
+ }
+
+ // Source location
+ if (callerFilePath is not null)
+ {
+ var fileName = Path.GetFileName(callerFilePath);
+ sb.Append(callerLineNumber.HasValue
+ ? $"\n at {fileName}:{callerLineNumber}"
+ : $"\n at {fileName}");
+ }
+
+ // Completed steps breadcrumb
+ if (completedSteps.Count > 0)
+ {
+ sb.Append($"\n\nCompleted steps ({completedSteps.Count} of {failedStepIndex}):");
+ foreach (var step in completedSteps)
+ {
+ var elapsed = FormatElapsed(step.Elapsed);
+ var location = FormatStepLocation(step);
+ sb.Append($"\n [{step.Index}] {step.Description} — {elapsed} ✓{location}");
+ }
+ }
+
+ // Failed step
+ sb.Append($"\n [{failedStepIndex}] {failedStepDescription} — FAILED after {FormatElapsed(failedStepElapsed)}");
+
+ // Total elapsed
+ var totalMs = failedStepElapsed.TotalMilliseconds;
+ foreach (var step in completedSteps)
+ {
+ totalMs += step.Elapsed.TotalMilliseconds;
+ }
+ sb.Append($"\n\nTotal elapsed: {FormatElapsed(TimeSpan.FromMilliseconds(totalMs))}");
+
+ // Terminal snapshot
+ if (terminalSnapshot is not null)
+ {
+ var screenMode = terminalSnapshot.InAlternateScreen ? "alternate screen" : "normal screen";
+ sb.Append($"\n\nTerminal snapshot at failure ({terminalSnapshot.Width}x{terminalSnapshot.Height}, cursor at {terminalSnapshot.CursorX},{terminalSnapshot.CursorY}, {screenMode}):");
+ sb.Append('\n');
+ sb.Append(terminalSnapshot.GetText());
+ }
+
+ return sb.ToString();
+ }
+
+ private static string FormatElapsed(TimeSpan elapsed)
+ {
+ if (elapsed.TotalMilliseconds < 1)
+ return "0ms";
+ if (elapsed.TotalSeconds < 10)
+ return $"{elapsed.TotalMilliseconds:F0}ms";
+ return elapsed.ToString(@"m\:ss\.fff");
+ }
+
+ private static string FormatStepLocation(AutomationStepRecord step)
+ {
+ if (step.CallerFilePath is null)
+ return "";
+
+ var fileName = Path.GetFileName(step.CallerFilePath);
+ return step.CallerLineNumber.HasValue
+ ? $" ({fileName}:{step.CallerLineNumber})"
+ : $" ({fileName})";
+ }
+}
diff --git a/src/Hex1b/Automation/Hex1bTerminalAutomator.cs b/src/Hex1b/Automation/Hex1bTerminalAutomator.cs
new file mode 100644
index 00000000..f15c97f6
--- /dev/null
+++ b/src/Hex1b/Automation/Hex1bTerminalAutomator.cs
@@ -0,0 +1,697 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using Hex1b.Input;
+
+namespace Hex1b.Automation;
+
+///
+/// Provides an imperative, async API for automating terminal interactions in tests.
+/// Each method executes immediately and records its result in a step history,
+/// providing rich diagnostic context when failures occur.
+///
+///
+///
+/// The automator layers on top of —
+/// each method builds its own input sequence under the covers and runs it via
+/// . The flow is always:
+/// automator method → sequencer → steps.
+///
+///
+/// When a step fails (e.g., a times out), the automator
+/// wraps the exception in which includes the
+/// full step history, terminal snapshot, and source location for debugging.
+///
+///
+///
+///
+/// var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+/// await auto.WaitUntilTextAsync("File");
+/// await auto.EnterAsync();
+/// await auto.WaitUntilTextAsync("New");
+/// await auto.DownAsync();
+/// await auto.WaitUntilAsync(s => IsSelected(s, "Open"), description: "Open to be selected");
+/// await auto.EnterAsync();
+///
+///
+public sealed class Hex1bTerminalAutomator
+{
+ private readonly Hex1bTerminal _terminal;
+ private readonly Hex1bTerminalInputSequenceOptions _options;
+ private readonly TimeSpan _defaultTimeout;
+ private readonly List _completedSteps = [];
+ private Hex1bModifiers _pendingModifiers = Hex1bModifiers.None;
+ private int _mouseX;
+ private int _mouseY;
+
+ // Cached sequences for simple key operations (no modifiers)
+ private readonly Dictionary _cachedKeySequences = new();
+
+ ///
+ /// Creates a new automator for the specified terminal.
+ ///
+ /// The terminal to automate.
+ /// Default timeout for WaitUntil*Async methods when no explicit timeout is provided.
+ public Hex1bTerminalAutomator(Hex1bTerminal terminal, TimeSpan defaultTimeout)
+ : this(terminal, Hex1bTerminalInputSequenceOptions.Default, defaultTimeout)
+ {
+ }
+
+ ///
+ /// Creates a new automator for the specified terminal with custom options.
+ ///
+ /// The terminal to automate.
+ /// Options for controlling poll intervals, typing speed, and time provider.
+ /// Default timeout for WaitUntil*Async methods when no explicit timeout is provided.
+ public Hex1bTerminalAutomator(
+ Hex1bTerminal terminal,
+ Hex1bTerminalInputSequenceOptions options,
+ TimeSpan defaultTimeout)
+ {
+ _terminal = terminal ?? throw new ArgumentNullException(nameof(terminal));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _defaultTimeout = defaultTimeout;
+ }
+
+ ///
+ /// Gets the list of all steps that have completed successfully.
+ ///
+ public IReadOnlyList CompletedSteps => _completedSteps;
+
+ ///
+ /// Creates a snapshot of the current terminal state.
+ ///
+ public Hex1bTerminalSnapshot CreateSnapshot() => _terminal.CreateSnapshot();
+
+ // ========================================
+ // Wait conditions
+ // ========================================
+
+ ///
+ /// Waits until a condition is met on the terminal.
+ ///
+ /// The condition to wait for. Receives a snapshot of the terminal state.
+ /// Maximum time to wait. If null, uses the default timeout.
+ /// Description for error messages. If not provided, the predicate expression is used.
+ /// Auto-captured predicate source text. Do not pass explicitly.
+ /// Auto-captured caller file path. Do not pass explicitly.
+ /// Auto-captured caller line number. Do not pass explicitly.
+ public async Task WaitUntilAsync(
+ Func predicate,
+ TimeSpan? timeout = null,
+ string? description = null,
+ [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var effectiveTimeout = timeout ?? _defaultTimeout;
+ var desc = description ?? predicateExpression ?? "condition";
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .WaitUntil(predicate, effectiveTimeout, desc)
+ .Build();
+
+ await RunAndRecordAsync(
+ sequence,
+ description is not null ? $"WaitUntil(\"{description}\")" : $"WaitUntil({predicateExpression})",
+ default,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ ///
+ /// Waits until the terminal contains the specified text.
+ ///
+ /// The text to wait for.
+ /// Maximum time to wait. If null, uses the default timeout.
+ /// Auto-captured caller file path. Do not pass explicitly.
+ /// Auto-captured caller line number. Do not pass explicitly.
+ public async Task WaitUntilTextAsync(
+ string text,
+ TimeSpan? timeout = null,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var effectiveTimeout = timeout ?? _defaultTimeout;
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .WaitUntil(s => s.ContainsText(text), effectiveTimeout, $"text \"{text}\" to appear")
+ .Build();
+
+ await RunAndRecordAsync(
+ sequence,
+ $"WaitUntilText(\"{text}\")",
+ default,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ ///
+ /// Waits until the terminal no longer contains the specified text.
+ ///
+ /// The text to wait to disappear.
+ /// Maximum time to wait. If null, uses the default timeout.
+ /// Auto-captured caller file path. Do not pass explicitly.
+ /// Auto-captured caller line number. Do not pass explicitly.
+ public async Task WaitUntilNoTextAsync(
+ string text,
+ TimeSpan? timeout = null,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var effectiveTimeout = timeout ?? _defaultTimeout;
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .WaitUntil(s => !s.ContainsText(text), effectiveTimeout, $"text \"{text}\" to disappear")
+ .Build();
+
+ await RunAndRecordAsync(
+ sequence,
+ $"WaitUntilNoText(\"{text}\")",
+ default,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ ///
+ /// Waits until the terminal enters alternate screen mode.
+ ///
+ /// Maximum time to wait. If null, uses the default timeout.
+ /// Auto-captured caller file path. Do not pass explicitly.
+ /// Auto-captured caller line number. Do not pass explicitly.
+ public async Task WaitUntilAlternateScreenAsync(
+ TimeSpan? timeout = null,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var effectiveTimeout = timeout ?? _defaultTimeout;
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .WaitUntil(s => s.InAlternateScreen, effectiveTimeout, "alternate screen mode")
+ .Build();
+
+ await RunAndRecordAsync(
+ sequence,
+ "WaitUntilAlternateScreen()",
+ default,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ // ========================================
+ // Modifier prefixes
+ // ========================================
+
+ ///
+ /// Adds Ctrl modifier to the next key or mouse action.
+ ///
+ public Hex1bTerminalAutomator Ctrl()
+ {
+ _pendingModifiers |= Hex1bModifiers.Control;
+ return this;
+ }
+
+ ///
+ /// Adds Shift modifier to the next key or mouse action.
+ ///
+ public Hex1bTerminalAutomator Shift()
+ {
+ _pendingModifiers |= Hex1bModifiers.Shift;
+ return this;
+ }
+
+ ///
+ /// Adds Alt modifier to the next key or mouse action.
+ ///
+ public Hex1bTerminalAutomator Alt()
+ {
+ _pendingModifiers |= Hex1bModifiers.Alt;
+ return this;
+ }
+
+ // ========================================
+ // Key input
+ // ========================================
+
+ ///
+ /// Sends a key press event.
+ ///
+ public async Task KeyAsync(
+ Hex1bKey key,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var modifiers = _pendingModifiers;
+ _pendingModifiers = Hex1bModifiers.None;
+
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .Key(key, modifiers)
+ .Build();
+
+ var desc = modifiers != Hex1bModifiers.None
+ ? $"Key({FormatModifiers(modifiers)}{key})"
+ : $"Key({key})";
+
+ await RunAndRecordAsync(sequence, desc, ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Sends a key press event with explicit modifiers.
+ ///
+ public async Task KeyAsync(
+ Hex1bKey key,
+ Hex1bModifiers modifiers,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var combinedModifiers = _pendingModifiers | modifiers;
+ _pendingModifiers = Hex1bModifiers.None;
+
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .Key(key, combinedModifiers)
+ .Build();
+
+ var desc = combinedModifiers != Hex1bModifiers.None
+ ? $"Key({FormatModifiers(combinedModifiers)}{key})"
+ : $"Key({key})";
+
+ await RunAndRecordAsync(sequence, desc, ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Types text quickly (no delay between keystrokes).
+ ///
+ public async Task TypeAsync(
+ string text,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .Type(text)
+ .Build();
+
+ var displayText = text.Length > 30 ? text[..27] + "..." : text;
+ await RunAndRecordAsync(sequence, $"Type(\"{displayText}\")", ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Types text slowly with a delay between keystrokes.
+ ///
+ public async Task SlowTypeAsync(
+ string text,
+ TimeSpan? delay = null,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var builder = new Hex1bTerminalInputSequenceBuilder().WithOptions(_options);
+ var sequence = delay.HasValue
+ ? builder.SlowType(text, delay.Value).Build()
+ : builder.SlowType(text).Build();
+
+ var displayText = text.Length > 30 ? text[..27] + "..." : text;
+ await RunAndRecordAsync(sequence, $"SlowType(\"{displayText}\")", ct, callerFilePath, callerLineNumber);
+ }
+
+ // ========================================
+ // Common key shortcuts
+ // ========================================
+
+ /// Sends Enter key.
+ public Task EnterAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Enter, "Key(Enter)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Tab key.
+ public Task TabAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Tab, "Key(Tab)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Escape key.
+ public Task EscapeAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Escape, "Key(Escape)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Backspace key.
+ public Task BackspaceAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Backspace, "Key(Backspace)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Delete key.
+ public Task DeleteAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Delete, "Key(Delete)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Space key.
+ public Task SpaceAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Spacebar, "Key(Space)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Up arrow key.
+ public Task UpAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.UpArrow, "Key(UpArrow)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Down arrow key.
+ public Task DownAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.DownArrow, "Key(DownArrow)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Left arrow key.
+ public Task LeftAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.LeftArrow, "Key(LeftArrow)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Right arrow key.
+ public Task RightAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.RightArrow, "Key(RightArrow)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Home key.
+ public Task HomeAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.Home, "Key(Home)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends End key.
+ public Task EndAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.End, "Key(End)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Page Up key.
+ public Task PageUpAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.PageUp, "Key(PageUp)", ct, callerFilePath, callerLineNumber);
+
+ /// Sends Page Down key.
+ public Task PageDownAsync(CancellationToken ct = default, [CallerFilePath] string? callerFilePath = null, [CallerLineNumber] int callerLineNumber = 0)
+ => RunCachedKeyAsync(Hex1bKey.PageDown, "Key(PageDown)", ct, callerFilePath, callerLineNumber);
+
+ // ========================================
+ // Mouse input
+ // ========================================
+
+ ///
+ /// Moves the mouse to an absolute position.
+ ///
+ public async Task MouseMoveToAsync(
+ int x,
+ int y,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ _mouseX = x;
+ _mouseY = y;
+
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .MouseMoveTo(x, y)
+ .Build();
+
+ await RunAndRecordAsync(sequence, $"MouseMoveTo({x}, {y})", ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Performs a click at the specified position.
+ ///
+ public async Task ClickAtAsync(
+ int x,
+ int y,
+ MouseButton button = MouseButton.Left,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ _mouseX = x;
+ _mouseY = y;
+
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .ClickAt(x, y, button)
+ .Build();
+
+ await RunAndRecordAsync(sequence, $"ClickAt({x}, {y})", ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Performs a double-click at the specified position.
+ ///
+ public async Task DoubleClickAtAsync(
+ int x,
+ int y,
+ MouseButton button = MouseButton.Left,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ _mouseX = x;
+ _mouseY = y;
+
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .DoubleClickAt(x, y, button)
+ .Build();
+
+ await RunAndRecordAsync(sequence, $"DoubleClickAt({x}, {y})", ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Performs a drag from one position to another.
+ ///
+ public async Task DragAsync(
+ int fromX,
+ int fromY,
+ int toX,
+ int toY,
+ MouseButton button = MouseButton.Left,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ _mouseX = toX;
+ _mouseY = toY;
+
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .Drag(fromX, fromY, toX, toY, button)
+ .Build();
+
+ await RunAndRecordAsync(
+ sequence, $"Drag({fromX},{fromY} → {toX},{toY})", ct, callerFilePath, callerLineNumber);
+ }
+
+ ///
+ /// Scrolls up at the current mouse position.
+ ///
+ public async Task ScrollUpAsync(
+ int ticks = 1,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var builder = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .MouseMoveTo(_mouseX, _mouseY)
+ .ScrollUp(ticks);
+
+ await RunAndRecordAsync(
+ builder.Build(),
+ ticks == 1 ? "ScrollUp()" : $"ScrollUp({ticks})",
+ ct,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ ///
+ /// Scrolls down at the current mouse position.
+ ///
+ public async Task ScrollDownAsync(
+ int ticks = 1,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var builder = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .MouseMoveTo(_mouseX, _mouseY)
+ .ScrollDown(ticks);
+
+ await RunAndRecordAsync(
+ builder.Build(),
+ ticks == 1 ? "ScrollDown()" : $"ScrollDown({ticks})",
+ ct,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ // ========================================
+ // Timing
+ // ========================================
+
+ ///
+ /// Pauses for the specified duration.
+ ///
+ public async Task WaitAsync(
+ TimeSpan duration,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .Wait(duration)
+ .Build();
+
+ await RunAndRecordAsync(
+ sequence,
+ $"Wait({duration.TotalMilliseconds:F0}ms)",
+ ct,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ ///
+ /// Pauses for the specified number of milliseconds.
+ ///
+ public Task WaitAsync(
+ int milliseconds,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ => WaitAsync(TimeSpan.FromMilliseconds(milliseconds), ct, callerFilePath, callerLineNumber);
+
+ // ========================================
+ // Composability
+ // ========================================
+
+ ///
+ /// Builds and runs an input sequence inline.
+ /// The sequence is tracked as a single step in the automator's history.
+ ///
+ /// Action to configure the sequence builder.
+ /// Description for error messages and step history.
+ /// Cancellation token.
+ /// Auto-captured caller file path. Do not pass explicitly.
+ /// Auto-captured caller line number. Do not pass explicitly.
+ public async Task SequenceAsync(
+ Action configure,
+ string? description = null,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ var builder = new Hex1bTerminalInputSequenceBuilder().WithOptions(_options);
+ configure(builder);
+ var sequence = builder.Build();
+
+ await RunAndRecordAsync(
+ sequence,
+ description ?? "Sequence",
+ ct,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ ///
+ /// Runs a pre-built input sequence.
+ /// The sequence is tracked as a single step in the automator's history.
+ ///
+ /// The pre-built sequence to run.
+ /// Description for error messages and step history.
+ /// Cancellation token.
+ /// Auto-captured caller file path. Do not pass explicitly.
+ /// Auto-captured caller line number. Do not pass explicitly.
+ public async Task SequenceAsync(
+ Hex1bTerminalInputSequence sequence,
+ string? description = null,
+ CancellationToken ct = default,
+ [CallerFilePath] string? callerFilePath = null,
+ [CallerLineNumber] int callerLineNumber = 0)
+ {
+ await RunAndRecordAsync(
+ sequence,
+ description ?? "Sequence",
+ ct,
+ callerFilePath,
+ callerLineNumber);
+ }
+
+ // ========================================
+ // Internal execution
+ // ========================================
+
+ private async Task RunCachedKeyAsync(
+ Hex1bKey key,
+ string description,
+ CancellationToken ct,
+ string? callerFilePath,
+ int callerLineNumber)
+ {
+ if (_pendingModifiers != Hex1bModifiers.None)
+ {
+ // Modifiers present — can't use cache, build fresh
+ await KeyAsync(key, ct, callerFilePath, callerLineNumber);
+ return;
+ }
+
+ if (!_cachedKeySequences.TryGetValue(key, out var sequence))
+ {
+ sequence = new Hex1bTerminalInputSequenceBuilder()
+ .WithOptions(_options)
+ .Key(key)
+ .Build();
+ _cachedKeySequences[key] = sequence;
+ }
+
+ await RunAndRecordAsync(sequence, description, ct, callerFilePath, callerLineNumber);
+ }
+
+ private async Task RunAndRecordAsync(
+ Hex1bTerminalInputSequence sequence,
+ string description,
+ CancellationToken ct,
+ string? callerFilePath = null,
+ int callerLineNumber = 0)
+ {
+ var stepIndex = _completedSteps.Count + 1;
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ await sequence.ApplyAsync(_terminal, ct);
+ sw.Stop();
+ _completedSteps.Add(new AutomationStepRecord(
+ stepIndex, description, sw.Elapsed, callerFilePath, callerLineNumber));
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+
+ // Get terminal snapshot from the inner exception if available, otherwise capture fresh
+ var snapshot = (ex as WaitUntilTimeoutException)?.TerminalSnapshot
+ ?? TryCreateSnapshot();
+
+ throw new Hex1bAutomationException(
+ failedStepIndex: stepIndex,
+ failedStepDescription: description,
+ completedSteps: _completedSteps.ToList(),
+ failedStepElapsed: sw.Elapsed,
+ terminalSnapshot: snapshot,
+ callerFilePath: callerFilePath,
+ callerLineNumber: callerLineNumber,
+ innerException: ex);
+ }
+ }
+
+ private Hex1bTerminalSnapshot? TryCreateSnapshot()
+ {
+ try
+ {
+ return _terminal.CreateSnapshot();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static string FormatModifiers(Hex1bModifiers modifiers)
+ {
+ var parts = new List(3);
+ if ((modifiers & Hex1bModifiers.Control) != 0) parts.Add("Ctrl+");
+ if ((modifiers & Hex1bModifiers.Alt) != 0) parts.Add("Alt+");
+ if ((modifiers & Hex1bModifiers.Shift) != 0) parts.Add("Shift+");
+ return string.Join("", parts);
+ }
+}
diff --git a/src/content/guide/testing.md b/src/content/guide/testing.md
index e5da84fa..160a9ac6 100644
--- a/src/content/guide/testing.md
+++ b/src/content/guide/testing.md
@@ -15,8 +15,9 @@ Hex1b's testing APIs work with any .NET testing framework. This guide uses **xUn
Testing a Hex1b app involves:
1. **Hex1bTerminal** - A virtual terminal that captures screen output
-2. **Hex1bInputSequenceBuilder** - A fluent API to simulate user input
-3. **Your test framework** - To run tests and make assertions
+2. **Hex1bTerminalInputSequenceBuilder** - A fluent API to build and run input sequences
+3. **Hex1bTerminalAutomator** - An imperative async API for complex tests with rich error diagnostics
+4. **Your test framework** - To run tests and make assertions
```csharp
// The pattern
@@ -453,6 +454,223 @@ var sequence = new Hex1bInputSequenceBuilder()
.Build();
```
+## Imperative Testing with Hex1bTerminalAutomator
+
+For complex integration tests, the `Hex1bTerminalAutomator` provides an imperative, async API that executes each step immediately. When a step fails, the exception includes a full breadcrumb trail of completed steps with timings and a terminal snapshot — making failures much easier to diagnose.
+
+### When to Use the Automator
+
+| Approach | Best For |
+|----------|----------|
+| `Hex1bTerminalInputSequenceBuilder` | Short, self-contained sequences (5-10 steps) |
+| `Hex1bTerminalAutomator` | Long integration tests, multi-step workflows, tests where debugging failures matters |
+
+### Basic Usage
+
+```csharp
+using Hex1b;
+using Hex1b.Automation;
+using Hex1b.Input;
+using Hex1b.Widgets;
+
+[Fact]
+public async Task MenuItem_NavigatesToNextItem()
+{
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => CreateMenuBar(ctx))
+ .WithHeadless()
+ .WithDimensions(80, 24)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ // Each line executes and completes before the next
+ await auto.WaitUntilTextAsync("File"); // step 1
+ await auto.EnterAsync(); // step 2 - open menu
+ await auto.WaitUntilTextAsync("New"); // step 3
+ await auto.DownAsync(); // step 4 - navigate to Open
+ await auto.WaitUntilAsync( // step 5
+ s => s.ContainsText("▶ Open"),
+ description: "Open to be selected");
+ await auto.EnterAsync(); // step 6 - activate
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+}
+```
+
+### Rich Error Diagnostics
+
+When a step fails, `Hex1bAutomationException` includes everything you need to diagnose the failure:
+
+```
+Hex1b.Automation.Hex1bAutomationException: Step 5 of 5 failed — WaitUntil timed out after 00:00:05
+ Condition: Open to be selected
+ at MenuBarTests.cs:42
+
+Completed steps (4 of 5):
+ [1] WaitUntilText("File") — 120ms ✓
+ [2] Key(Enter) — 0ms ✓
+ [3] WaitUntilText("New") — 340ms ✓
+ [4] Key(DownArrow) — 0ms ✓
+ [5] WaitUntil("Open to be selected") — FAILED after 5,000ms
+
+Total elapsed: 5,460ms
+
+Terminal snapshot at failure (80x24, cursor at 5,3, alternate screen):
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ File Edit View Help │
+│┌─────────┐ │
+││ New │ │
+││ Open │ │
+│└─────────┘ │
+└──────────────────────────────────────────────────────────────────────────────┘
+```
+
+The exception also exposes structured properties for programmatic inspection:
+
+```csharp
+catch (Hex1bAutomationException ex)
+{
+ ex.FailedStepIndex // 1-based index of the failing step
+ ex.FailedStepDescription // e.g., "WaitUntilText(\"File\")"
+ ex.CompletedSteps // IReadOnlyList
+ ex.TotalElapsed // Total time across all steps
+ ex.TerminalSnapshot // Terminal state at failure
+ ex.CallerFilePath // Source file where the step was called
+ ex.CallerLineNumber // Line number where the step was called
+ ex.InnerException // Original exception (e.g., WaitUntilTimeoutException)
+}
+```
+
+### Automator API
+
+#### Waiting
+
+```csharp
+// Wait for a condition
+await auto.WaitUntilAsync(s => s.ContainsText("Ready"), description: "app to be ready");
+
+// Convenience methods
+await auto.WaitUntilTextAsync("Hello"); // Wait for text to appear
+await auto.WaitUntilNoTextAsync("Loading"); // Wait for text to disappear
+await auto.WaitUntilAlternateScreenAsync(); // Wait for alternate screen
+
+// Custom timeout (overrides the default)
+await auto.WaitUntilTextAsync("Slow result", timeout: TimeSpan.FromSeconds(30));
+```
+
+#### Keyboard
+
+```csharp
+// Individual keys
+await auto.EnterAsync();
+await auto.TabAsync();
+await auto.EscapeAsync();
+await auto.SpaceAsync();
+await auto.BackspaceAsync();
+await auto.DeleteAsync();
+
+// Arrow keys
+await auto.UpAsync();
+await auto.DownAsync();
+await auto.LeftAsync();
+await auto.RightAsync();
+
+// Navigation
+await auto.HomeAsync();
+await auto.EndAsync();
+await auto.PageUpAsync();
+await auto.PageDownAsync();
+
+// Any key
+await auto.KeyAsync(Hex1bKey.F1);
+
+// Modifiers (consumed by the next key call)
+await auto.Ctrl().KeyAsync(Hex1bKey.S); // Ctrl+S
+await auto.Shift().TabAsync(); // Shift+Tab
+await auto.Ctrl().Shift().KeyAsync(Hex1bKey.Z); // Ctrl+Shift+Z
+
+// Typing
+await auto.TypeAsync("Hello World"); // Fast type
+await auto.SlowTypeAsync("search", delay: TimeSpan.FromMilliseconds(50));
+```
+
+#### Mouse
+
+```csharp
+await auto.ClickAtAsync(10, 5);
+await auto.DoubleClickAtAsync(10, 5);
+await auto.MouseMoveToAsync(20, 10);
+await auto.DragAsync(10, 10, 30, 10);
+await auto.ScrollUpAsync(3);
+await auto.ScrollDownAsync();
+```
+
+#### Timing and Snapshots
+
+```csharp
+// Pause between steps
+await auto.WaitAsync(100); // 100ms
+await auto.WaitAsync(TimeSpan.FromSeconds(1)); // 1 second
+
+// Inspect the terminal at any point
+using var snapshot = auto.CreateSnapshot();
+
+// Review completed steps
+foreach (var step in auto.CompletedSteps)
+{
+ Console.WriteLine($"[{step.Index}] {step.Description} — {step.Elapsed.TotalMilliseconds}ms");
+}
+```
+
+#### Composability with SequenceAsync
+
+You can run a pre-built sequence or inline builder through the automator. The sequence is tracked as a single step in the automator's history:
+
+```csharp
+// Inline builder
+await auto.SequenceAsync(b => b
+ .Type("aspire new")
+ .Enter(),
+ description: "Run aspire new command");
+
+// Pre-built sequence
+var openMenu = new Hex1bTerminalInputSequenceBuilder()
+ .Enter()
+ .WaitUntil(s => s.ContainsText("New"), TimeSpan.FromSeconds(5))
+ .Build();
+
+await auto.SequenceAsync(openMenu, description: "Open file menu");
+```
+
+### Building Extension Methods
+
+The automator is designed for domain-specific extension methods. This is how you build reusable test helpers for your application:
+
+```csharp
+public static class MyAppAutomatorExtensions
+{
+ public static async Task LoginAsync(
+ this Hex1bTerminalAutomator auto, string username, string password)
+ {
+ await auto.WaitUntilTextAsync("Username:");
+ await auto.TypeAsync(username);
+ await auto.TabAsync();
+ await auto.TypeAsync(password);
+ await auto.EnterAsync();
+ await auto.WaitUntilTextAsync("Welcome");
+ }
+}
+
+// Usage in tests:
+await auto.LoginAsync("admin", "secret");
+await auto.WaitUntilTextAsync("Dashboard");
+```
+
+Each call inside the extension method is individually tracked in the step history, so if `LoginAsync` fails at the password tab, you'll see exactly which step timed out.
+
## Complete Example
Here's a full test class for the counter app:
diff --git a/tests/Hex1b.Tests/Hex1bTerminalAutomatorTests.cs b/tests/Hex1b.Tests/Hex1bTerminalAutomatorTests.cs
new file mode 100644
index 00000000..6ec6d276
--- /dev/null
+++ b/tests/Hex1b.Tests/Hex1bTerminalAutomatorTests.cs
@@ -0,0 +1,495 @@
+using Hex1b.Automation;
+using Hex1b.Input;
+using Hex1b.Widgets;
+
+namespace Hex1b.Tests;
+
+///
+/// Tests for .
+///
+public class Hex1bTerminalAutomatorTests
+{
+ [Fact]
+ public async Task WaitUntilTextAsync_WaitsForText()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Hello World"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Hello World");
+
+ Assert.Single(auto.CompletedSteps);
+ Assert.Contains("WaitUntilText(\"Hello World\")", auto.CompletedSteps[0].Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task WaitUntilTextAsync_Timeout_ThrowsHex1bAutomationException()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Hello"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromMilliseconds(250));
+
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await auto.WaitUntilTextAsync("NonExistent");
+ });
+
+ Assert.Equal(1, ex.FailedStepIndex);
+ Assert.Contains("WaitUntilText(\"NonExistent\")", ex.FailedStepDescription);
+ Assert.IsType(ex.InnerException);
+ Assert.NotNull(ex.TerminalSnapshot);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task WaitUntilTextAsync_Timeout_ExceptionMessageContainsStepHistory()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(new TextBlockWidget("Hello World")),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ // Complete some steps successfully first
+ await auto.WaitUntilTextAsync("Hello World");
+ await auto.EnterAsync();
+
+ // Now fail on a WaitUntil
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await auto.WaitUntilTextAsync("NonExistent", timeout: TimeSpan.FromMilliseconds(250));
+ });
+
+ // Verify step index reflects that 2 steps completed before the failure
+ Assert.Equal(3, ex.FailedStepIndex);
+
+ // Verify completed steps are captured
+ Assert.Equal(2, ex.CompletedSteps.Count);
+ Assert.Contains("WaitUntilText(\"Hello World\")", ex.CompletedSteps[0].Description);
+ Assert.Contains("Key(Enter)", ex.CompletedSteps[1].Description);
+
+ // Verify the message contains the breadcrumb
+ Assert.Contains("Step 3 of 3 failed", ex.Message);
+ Assert.Contains("WaitUntilText(\"Hello World\")", ex.Message);
+ Assert.Contains("Key(Enter)", ex.Message);
+ Assert.Contains("FAILED", ex.Message);
+
+ // Verify terminal snapshot is in the message
+ Assert.Contains("Terminal snapshot at failure", ex.Message);
+ Assert.Contains("Hello World", ex.Message);
+
+ // Verify caller info
+ Assert.NotNull(ex.CallerFilePath);
+ Assert.Contains("Hex1bTerminalAutomatorTests.cs", ex.CallerFilePath);
+ Assert.True(ex.CallerLineNumber > 0);
+
+ // Clean up
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task EnterAsync_SendsEnterKey()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var enterPressed = false;
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(
+ new ButtonWidget("Click Me").OnClick(_ => { enterPressed = true; })),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Click Me");
+ await auto.EnterAsync();
+
+ // Give the app a moment to process
+ await auto.WaitAsync(50);
+
+ Assert.True(enterPressed);
+ Assert.Equal(3, auto.CompletedSteps.Count);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task TypeAsync_TypesText()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var typedText = "";
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(
+ new TextBoxWidget("").OnTextChanged(args =>
+ {
+ typedText = args.NewText;
+ return Task.CompletedTask;
+ })),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilAlternateScreenAsync();
+ await auto.TypeAsync("Hello");
+ await auto.WaitUntilTextAsync("Hello");
+
+ Assert.Equal("Hello", typedText);
+ Assert.Contains("Type(\"Hello\")", auto.CompletedSteps[1].Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task Ctrl_KeyAsync_SendsModifiedKey()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(new TextBlockWidget("Test")),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Test");
+
+ // Ctrl+C should exit the app
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+
+ await runTask;
+
+ // Verify modifier was recorded in description
+ var lastStep = auto.CompletedSteps[^1];
+ Assert.Contains("Ctrl+", lastStep.Description);
+ }
+
+ [Fact]
+ public async Task WaitUntilAsync_WithCustomPredicate_Works()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Count: 42"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilAsync(
+ s => s.ContainsText("Count: 42"),
+ description: "count to be 42");
+
+ Assert.Single(auto.CompletedSteps);
+ Assert.Contains("WaitUntil(\"count to be 42\")", auto.CompletedSteps[0].Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task WaitUntilNoTextAsync_WaitsForTextToDisappear()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var showText = true;
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(
+ showText ? new TextBlockWidget("Visible") : new TextBlockWidget("Hidden")),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Visible");
+
+ // Toggle the text off
+ showText = false;
+ app.Invalidate();
+
+ await auto.WaitUntilNoTextAsync("Visible");
+
+ Assert.Equal(2, auto.CompletedSteps.Count);
+ Assert.Contains("WaitUntilNoText(\"Visible\")", auto.CompletedSteps[1].Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task SequenceAsync_WithBuilderAction_ExecutesSequence()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var typedText = "";
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(
+ new TextBoxWidget("").OnTextChanged(args =>
+ {
+ typedText = args.NewText;
+ return Task.CompletedTask;
+ })),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilAlternateScreenAsync();
+
+ // Use SequenceAsync with a builder action
+ await auto.SequenceAsync(
+ b => b.Type("Hi").Enter(),
+ description: "Type and submit");
+
+ await auto.WaitAsync(50);
+
+ Assert.Contains("Type and submit", auto.CompletedSteps[1].Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task SequenceAsync_WithPrebuiltSequence_ExecutesSequence()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(
+ new TextBoxWidget("").OnTextChanged(args => Task.CompletedTask)),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilAlternateScreenAsync();
+
+ // Build a reusable sequence
+ var typeSequence = new Hex1bTerminalInputSequenceBuilder()
+ .Type("Reusable")
+ .Build();
+
+ await auto.SequenceAsync(typeSequence, description: "Type reusable text");
+
+ await auto.WaitUntilTextAsync("Reusable");
+
+ Assert.Contains("Type reusable text", auto.CompletedSteps[1].Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task CompletedSteps_TracksCallerInfo()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Test"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Test");
+
+ var step = auto.CompletedSteps[0];
+ Assert.NotNull(step.CallerFilePath);
+ Assert.Contains("Hex1bTerminalAutomatorTests.cs", step.CallerFilePath);
+ Assert.True(step.CallerLineNumber > 0);
+ Assert.True(step.Elapsed >= TimeSpan.Zero);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task AutomationException_TotalElapsed_SumsAllSteps()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Hello"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromMilliseconds(250));
+
+ await auto.WaitUntilTextAsync("Hello");
+
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await auto.WaitUntilTextAsync("Never");
+ });
+
+ // TotalElapsed should be >= the timeout since the failing step waited that long
+ Assert.True(ex.TotalElapsed >= TimeSpan.FromMilliseconds(200),
+ $"TotalElapsed was {ex.TotalElapsed.TotalMilliseconds}ms, expected >= 200ms");
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task CreateSnapshot_ReturnsCurrentState()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Snapshot Test"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Snapshot Test");
+
+ using var snapshot = auto.CreateSnapshot();
+ Assert.True(snapshot.ContainsText("Snapshot Test"));
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task WaitUntilAsync_WithPredicateExpression_CapturesExpression()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Hello"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromMilliseconds(250));
+
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await auto.WaitUntilAsync(s => s.ContainsText("Nope"));
+ });
+
+ // The predicate expression should be captured
+ Assert.Contains("ContainsText(\"Nope\")", ex.FailedStepDescription);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task MultipleModifiers_AreStackedCorrectly()
+ {
+ using var workload = new Hex1bAppWorkloadAdapter();
+ using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithWorkload(workload)
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ using var app = new Hex1bApp(
+ ctx => Task.FromResult(new TextBlockWidget("Test")),
+ new Hex1bAppOptions { WorkloadAdapter = workload }
+ );
+
+ var runTask = app.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Test");
+
+ // Stack Ctrl+Shift
+ await auto.Ctrl().Shift().KeyAsync(Hex1bKey.Z);
+
+ var lastStep = auto.CompletedSteps[^1];
+ Assert.Contains("Ctrl+", lastStep.Description);
+ Assert.Contains("Shift+", lastStep.Description);
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+
+ [Fact]
+ public async Task WaitAsync_PausesForDuration()
+ {
+ await using var terminal = Hex1bTerminal.CreateBuilder()
+ .WithHex1bApp((app, options) => ctx => new TextBlockWidget("Test"))
+ .WithHeadless()
+ .WithDimensions(40, 10)
+ .Build();
+
+ var runTask = terminal.RunAsync(TestContext.Current.CancellationToken);
+ var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5));
+
+ await auto.WaitUntilTextAsync("Test");
+ await auto.WaitAsync(50);
+
+ Assert.Equal(2, auto.CompletedSteps.Count);
+ Assert.Contains("Wait(50ms)", auto.CompletedSteps[1].Description);
+ Assert.True(auto.CompletedSteps[1].Elapsed >= TimeSpan.FromMilliseconds(40));
+
+ await auto.Ctrl().KeyAsync(Hex1bKey.C);
+ await runTask;
+ }
+}