Testing Hex1b Applications
Hex1b provides first-class testing support through its virtual terminal and input sequence builder. You can write automated tests that simulate user interactions and verify your application's behavior.
Framework Agnostic
Hex1b's testing APIs work with any .NET testing framework. This guide uses xUnit as an example, but the same patterns apply to NUnit, MSTest, or any other framework.
Overview
Testing a Hex1b app involves:
- Hex1bTerminal - A virtual terminal that captures screen output
- Hex1bTerminalInputSequenceBuilder - A fluent API to build and run input sequences
- Hex1bTerminalAutomator - An imperative async API for complex tests with rich error diagnostics
- Your test framework - To run tests and make assertions
// The pattern
using var terminal = new Hex1bTerminal(80, 24);
using var app = new Hex1bApp(/* ... */);
var sequence = new Hex1bInputSequenceBuilder()
.Type("Hello")
.Enter()
.Build();
await sequence.ApplyAsync(terminal);
Assert.Contains("Hello", terminal.GetScreenText());A Sample Application
Let's create a simple counter app to test:
// CounterApp.cs
using Hex1b;
using Hex1b.Widgets;
public static class CounterApp
{
public static Hex1bWidget Build(WidgetContext ctx)
{
var count = ctx.UseState(0);
return ctx.VStack(v => [
v.Text($"Count: {count.Value}"),
v.HStack(h => [
h.Button("Increment")
.OnClick(_ => { count.Value++; return Task.CompletedTask; }),
h.Button("Decrement")
.OnClick(_ => { count.Value--; return Task.CompletedTask; }),
h.Button("Reset")
.OnClick(_ => { count.Value = 0; return Task.CompletedTask; })
])
]);
}
}Setting Up the Test Project
Add the Hex1b package to your test project:
<ItemGroup>
<PackageReference Include="Hex1b" Version="*" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>Writing Your First Test
using Hex1b;
using Hex1b.Widgets;
using Xunit;
public class CounterAppTests
{
[Fact]
public async Task InitialState_ShowsZero()
{
// Arrange
using var terminal = new Hex1bTerminal(80, 24);
using var app = new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(CounterApp.Build(ctx)),
new Hex1bAppOptions { WorkloadAdapter = terminal.WorkloadAdapter }
);
// Act - start app and exit with Ctrl+C
var runTask = app.RunAsync();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Count:"), TimeSpan.FromSeconds(2))
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal);
await runTask;
// Assert
Assert.Contains("Count: 0", terminal.GetScreenText());
}
}Key Components
| Component | Purpose |
|---|---|
Hex1bTerminal(width, height) | Creates a virtual terminal for headless testing |
terminal.WorkloadAdapter | Connect the app to the virtual terminal |
Hex1bTerminalInputSequenceBuilder | Build input sequences with waits and assertions |
terminal.GetScreenText() | Get the current screen content as text (auto-flushes) |
Testing User Interactions
Use Hex1bInputSequenceBuilder to simulate user input:
[Fact]
public async Task ClickIncrement_IncreasesCount()
{
using var terminal = new Hex1bTerminal(80, 24);
using var app = new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(CounterApp.Build(ctx)),
new Hex1bAppOptions { WorkloadAdapter = terminal.WorkloadAdapter }
);
// Build the input sequence
var sequence = new Hex1bInputSequenceBuilder()
.Enter() // Click the focused "Increment" button
.Enter() // Click again
.Enter() // And again
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
// Wait for initial render
await Task.Delay(50);
// Apply the input sequence
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
// Stop the app
cts.Cancel();
await runTask;
// Assert
Assert.Contains("Count: 3", terminal.GetScreenText());
}Input Sequence Builder API
Keyboard Input
var sequence = new Hex1bInputSequenceBuilder()
// Basic keys
.Key(Hex1bKey.A) // Press 'a'
.Enter() // Press Enter
.Tab() // Press Tab
.Escape() // Press Escape
.Backspace() // Press Backspace
.Delete() // Press Delete
.Space() // Press Space
// Arrow keys
.Up()
.Down()
.Left()
.Right()
// Navigation
.Home()
.End()
.PageUp()
.PageDown()
// Modifiers
.Shift().Key(Hex1bKey.A) // Shift+A (uppercase)
.Ctrl().Key(Hex1bKey.C) // Ctrl+C
.Alt().Key(Hex1bKey.F) // Alt+F
.Ctrl().Shift().Key(Hex1bKey.Z) // Ctrl+Shift+Z
// Text typing
.Type("Hello World") // Type text quickly
.FastType("Quick") // Same as Type()
.SlowType("Slow") // With delays between keys
.SlowType("Custom", TimeSpan.FromMilliseconds(100))
.Build();Mouse Input
var sequence = new Hex1bInputSequenceBuilder()
// Positioning
.MouseMoveTo(10, 5) // Move to absolute position
.MouseMove(5, 0) // Move relative (delta)
// Clicks
.Click() // Left click at current position
.Click(MouseButton.Right) // Right click
.ClickAt(20, 10) // Move and click
.DoubleClick() // Double click
// Drag
.Drag(10, 10, 30, 10) // Drag from (10,10) to (30,10)
// Scroll
.ScrollUp() // Scroll up once
.ScrollUp(3) // Scroll up 3 ticks
.ScrollDown(2) // Scroll down 2 ticks
// Modifiers with mouse
.Ctrl().Click() // Ctrl+Click
.Shift().MouseDown() // Shift+MouseDown
.Build();Timing
var sequence = new Hex1bInputSequenceBuilder()
.Type("search query")
.Wait(100) // Wait 100ms
.Wait(TimeSpan.FromSeconds(1)) // Wait 1 second
.Enter()
.Build();Testing Keyboard Navigation
[Fact]
public async Task TabNavigation_MovesBetweenButtons()
{
using var terminal = new Hex1bTerminal(80, 24);
using var app = new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(CounterApp.Build(ctx)),
new Hex1bAppOptions { WorkloadAdapter = terminal.WorkloadAdapter }
);
var sequence = new Hex1bInputSequenceBuilder()
.Tab() // Move to "Decrement" button
.Tab() // Move to "Reset" button
.Enter() // Click Reset (no effect, count is already 0)
.Shift().Tab() // Back to "Decrement"
.Enter() // Click Decrement
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
await Task.Delay(50);
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
cts.Cancel();
await runTask;
Assert.Contains("Count: -1", terminal.GetScreenText());
}Testing Text Input
[Fact]
public async Task TextBox_CapturesInput()
{
using var terminal = new Hex1bTerminal(80, 24);
var capturedText = "";
using var app = new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(
ctx.VStack(v => [
v.TextBox()
.OnTextChanged(args =>
{
capturedText = args.NewText;
return Task.CompletedTask;
})
])
),
new Hex1bAppOptions { WorkloadAdapter = terminal.WorkloadAdapter }
);
var sequence = new Hex1bInputSequenceBuilder()
.Type("Hello, Hex1b!")
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
await Task.Delay(50);
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
cts.Cancel();
await runTask;
Assert.Equal("Hello, Hex1b!", capturedText);
}Testing Keyboard Shortcuts
[Fact]
public async Task CtrlS_TriggersSave()
{
using var terminal = new Hex1bTerminal(80, 24);
var saveTriggered = false;
using var app = new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(
ctx.VStack(v => [
v.Text("Press Ctrl+S to save")
]).WithInputBindings(bindings =>
{
bindings.Ctrl().Key(Hex1bKey.S)
.Action(_ => saveTriggered = true);
})
),
new Hex1bAppOptions
{
WorkloadAdapter = terminal.WorkloadAdapter,
EnableDefaultCtrlCExit = false
}
);
var sequence = new Hex1bInputSequenceBuilder()
.Ctrl().Key(Hex1bKey.S)
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
await Task.Delay(50);
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
cts.Cancel();
await runTask;
Assert.True(saveTriggered);
}Terminal Inspection APIs
The virtual terminal provides several ways to inspect the screen:
// Text content
terminal.GetScreenText() // Full screen as string
terminal.GetLine(0) // Specific line (0-based)
terminal.GetLineTrimmed(0) // Line without trailing spaces
terminal.GetNonEmptyLines() // All non-blank lines
// Searching
terminal.ContainsText("Hello") // Boolean check
terminal.FindText("Error") // List of (line, column) positions
// Screen buffer (with colors)
terminal.GetScreenBuffer() // 2D array of TerminalCell
// Cursor position
terminal.CursorX // Current X position
terminal.CursorY // Current Y position
// Terminal state
terminal.InAlternateScreen // Whether in alternate screen mode
terminal.Width // Terminal width
terminal.Height // Terminal heightBest Practices
1. Use Descriptive Sequences
// ❌ Hard to understand
var sequence = new Hex1bInputSequenceBuilder()
.Tab().Tab().Enter().Type("x").Tab().Enter()
.Build();
// ✅ Clear intent
var sequence = new Hex1bInputSequenceBuilder()
.Tab() // Navigate to username field
.Tab() // Navigate to password field
.Enter() // Focus the password input
.Type("secret123") // Enter password
.Tab() // Navigate to login button
.Enter() // Click login
.Build();2. Wait for Renders
Allow time for the app to process and render:
await Task.Delay(50); // After starting app
await sequence.ApplyAsync(terminal);
await Task.Delay(50); // After input, before assertions
// Screen buffer reads (GetScreenText, ContainsText, etc.) auto-flush3. Use CancellationToken for Cleanup
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
// ... test logic ...
cts.Cancel();
await runTask; // Ensure clean shutdown4. Test One Behavior at a Time
// ✅ Focused test
[Fact]
public async Task Increment_WhenClicked_IncreasesCountByOne()
// ❌ Too broad
[Fact]
public async Task Counter_WorksCorrectly()5. Consider SlowType for Timing-Sensitive Tests
// For debounced search, autocomplete, etc.
var sequence = new Hex1bInputSequenceBuilder()
.SlowType("search query", TimeSpan.FromMilliseconds(50))
.Wait(200) // Wait for debounce
.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
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:
catch (Hex1bAutomationException ex)
{
ex.FailedStepIndex // 1-based index of the failing step
ex.FailedStepDescription // e.g., "WaitUntilText(\"File\")"
ex.CompletedSteps // IReadOnlyList<AutomationStepRecord>
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
// 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
// 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
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
// 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:
// 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:
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:
using Hex1b;
using Hex1b.Input;
using Hex1b.Widgets;
using Xunit;
public class CounterAppTests
{
private static Hex1bApp CreateApp(Hex1bTerminal terminal)
{
return new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(CounterApp.Build(ctx)),
new Hex1bAppOptions { WorkloadAdapter = terminal.WorkloadAdapter }
);
}
[Fact]
public async Task InitialRender_DisplaysZero()
{
using var terminal = new Hex1bTerminal(80, 24);
using var app = CreateApp(terminal);
var runTask = app.RunAsync();
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Count:"), TimeSpan.FromSeconds(2))
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal);
await runTask;
Assert.Contains("Count: 0", terminal.GetScreenText());
}
[Fact]
public async Task Increment_IncreasesCount()
{
using var terminal = new Hex1bTerminal(80, 24);
using var app = CreateApp(terminal);
var sequence = new Hex1bInputSequenceBuilder()
.Enter()
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
await Task.Delay(50);
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
cts.Cancel();
await runTask;
Assert.Contains("Count: 1", terminal.GetScreenText());
}
[Fact]
public async Task Decrement_DecreasesCount()
{
using var terminal = new Hex1bTerminal(80, 24);
using var app = CreateApp(terminal);
var sequence = new Hex1bInputSequenceBuilder()
.Tab() // Move to Decrement button
.Enter() // Click it
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
await Task.Delay(50);
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
cts.Cancel();
await runTask;
Assert.Contains("Count: -1", terminal.GetScreenText());
}
[Fact]
public async Task Reset_SetsCountToZero()
{
using var terminal = new Hex1bTerminal(80, 24);
using var app = CreateApp(terminal);
var sequence = new Hex1bInputSequenceBuilder()
.Enter() // Increment to 1
.Enter() // Increment to 2
.Tab().Tab() // Move to Reset button
.Enter() // Click Reset
.Build();
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
await Task.Delay(50);
await sequence.ApplyAsync(terminal);
await Task.Delay(50);
cts.Cancel();
await runTask;
Assert.Contains("Count: 0", terminal.GetScreenText());
}
}Next Steps
- Input Handling - Learn about keyboard bindings and focus
- Widgets & Nodes - Understand the rendering model
- Layout System - Master the constraint-based layout
- Terminal Emulator - Test inside Docker containers