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; + } +}