| name | test-fixer |
| description | Agent for diagnosing and fixing flaky terminal UI tests in the Hex1b test suite. Use when tests pass locally but fail in CI, or when tests exhibit timing-sensitive behavior. |
Test Fixer Skill
This skill provides guidelines for AI agents to diagnose and fix flaky tests in the Hex1b TUI library test suite. These tests use MSTest 4 (MSTest.Sdk/4.2.3, OutputType=Exe) with Microsoft.Testing.Platform and Hex1bTerminalInputSequenceBuilder to simulate user interactions with terminal applications. global.json configures dotnet test to use MTP, and test projects can also be run as executables with dotnet run --project tests/SomeProject/.
Quick Reference: Common Flaky Test Patterns
| Pattern | Symptom | Fix |
|---|
| Snapshot After Exit | Test passes locally, fails on Linux CI | Move WaitUntil before Capture, ensure Capture is before exit |
| Missing WaitUntil | Intermittent assertion failures | Add WaitUntil for expected state before Capture |
| Race with Ctrl+C | Snapshot missing expected content | Add WaitUntil between last action and Capture |
| Task.WhenAny Race | Test sometimes times out | Replace with proper WaitUntil or increase timeout |
| Test Interference | Pass isolated, fail in suite | Check for shared state, file locks, or parallel execution issues |
| Platform-Specific | Fails consistently on Windows/Linux | Add TestCategory/Ignore or fix platform-specific code |
| Task.Delay for Async Events | Flaky on slower CI runners | Replace Task.Delay with TaskCompletionSource signal |
| Helper Partial Wait | Tests using multi-line helpers fail intermittently | Wait for all/last content, not just first line |
Pattern 1: Snapshot Captured After App Exit
⚠️ This is the most common flaky test issue
Symptoms
- Test passes consistently on Windows
- Test fails on Linux CI (GitHub Actions ubuntu-latest)
- Assertion fails with content that "should" be there
- Error messages like
Assert.IsTrue failed or An item should be selected with indicator
Root Cause
The ApplyWithCaptureAsync method returns terminal.CreateSnapshot() after all steps complete, not at the point where .Capture() is called. When the sequence includes Ctrl+C to exit the app, the terminal buffer may be cleared before the final snapshot is taken.
Platform difference: Windows terminal buffers persist longer after app exit; Linux clears them more aggressively.
Example: Broken Test
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Counter: 3"), TimeSpan.FromSeconds(2))
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("Counter: 3"));
How It Works
Hex1bTerminalInputSequenceBuilder builds a sequence of TestStep objects
ApplyWithCaptureAsync executes all steps, then calls terminal.CreateSnapshot() at the end
CaptureStep only saves SVG/HTML files—it does NOT store the snapshot for return
- When
Ctrl+C triggers app exit, the terminal may clear its buffer before the final snapshot
Fix Strategy
Option A: Ensure WaitUntil is immediately before Capture (Recommended)
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.Key(Hex1bKey.A)
.Key(Hex1bKey.B)
.WaitUntil(s => s.ContainsText("Counter: 3"), TimeSpan.FromSeconds(2))
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
Assert.IsTrue(snapshot.ContainsText("Counter: 3"));
Option B: Don't use snapshot for content assertions
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.Key(Hex1bKey.A)
.Key(Hex1bKey.B)
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
Assert.AreEqual(1, staticRenderCount);
Option C: Use WaitUntil as the assertion itself
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("> Item 15"), TimeSpan.FromSeconds(2))
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
Pattern 2: Missing WaitUntil Before Assertion
Symptoms
- Test sometimes fails, sometimes passes (even on same platform)
- Assertion checks for content that "should" appear after an action
- Content appears when running manually but not in fast automated tests
Root Cause
User input (key press, mouse click) triggers async rendering. Without WaitUntil, the snapshot may be captured before the render completes.
Example: Broken Test
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("First"), TimeSpan.FromSeconds(2))
.Down()
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("> Second"));
Fix
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("First"), TimeSpan.FromSeconds(2))
.Down()
.WaitUntil(s => s.ContainsText("> Second"), TimeSpan.FromSeconds(2))
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("> Second"));
Pattern 3: Race Condition with Ctrl+C
Symptoms
- Test passes most of the time
- Occasionally fails with assertion that content is missing
- More common on slower CI runners
Root Cause
Ctrl+C can be processed before the previous action's render completes, especially if the action triggers complex layout recalculation.
Example: Broken Test
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 01"), TimeSpan.FromSeconds(2))
.ScrollDown(5)
.Capture("after_scroll")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("> Item 06"));
Fix
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Item 01"), TimeSpan.FromSeconds(2))
.ScrollDown(5)
.WaitUntil(s => s.ContainsText("> Item 06"), TimeSpan.FromSeconds(2))
.Capture("after_scroll")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("> Item 06"));
Test Infrastructure Overview
Key Components
| Component | Location | Purpose |
|---|
Hex1bTerminalInputSequenceBuilder | src/Hex1b/Automation/ | Fluent builder for test sequences |
Hex1bTerminalInputSequence | src/Hex1b/Automation/ | Executes steps and returns final snapshot |
CaptureStep | tests/Hex1b.Tests/CaptureStep.cs | Saves SVG/HTML at capture point |
WaitUntilStep | src/Hex1b/Automation/WaitUntilStep.cs | Polls until condition met or timeout |
TestSequenceExtensions | tests/Hex1b.Tests/TestSequenceExtensions.cs | Adds .Capture() extension method |
Understanding ApplyWithCaptureAsync
public async Task<Hex1bTerminalSnapshot> ApplyAsync(Hex1bTerminal terminal, CancellationToken ct = default)
{
foreach (var step in _steps)
{
ct.ThrowIfCancellationRequested();
await step.ExecuteAsync(terminal, _options, ct);
}
return terminal.CreateSnapshot();
}
The CaptureStep only saves files—it does NOT affect what ApplyAsync returns:
internal override Task ExecuteAsync(...)
{
TestCaptureHelper.Capture(terminal, Name);
return Task.CompletedTask;
}
Diagnostic Workflow
Step 1: Identify the Failure Pattern
gh run view <run-id> --log-failed
Step 2: Check Local vs CI Behavior
# Run specific test locally
dotnet test tests/Hex1b.Tests --filter "FullyQualifiedName~<TestName>"
# Run multiple times to check for flakiness
for ($i = 1; $i -le 10; $i++) {
dotnet test tests/Hex1b.Tests --filter "FullyQualifiedName~<TestName>" --no-build 2>&1 |
Select-String -Pattern "(Passed|Failed)"
}
Step 3: Examine the Test Pattern
Look for these anti-patterns:
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
Assert.IsTrue(snapshot.ContainsText("expected"));
.Down()
.Capture("final")
.ScrollDown(10)
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
Step 4: Apply the Fix
- Add
WaitUntil after any action that changes state
- Ensure
WaitUntil checks for the exact condition being asserted
- Place
WaitUntil immediately before Capture
- Consider if the assertion is even needed (WaitUntil already verified it)
Safe Test Patterns
Pattern A: Full Integration Test with Exit
[TestMethod]
public async Task Navigation_DownArrow_SelectsNextItem()
{
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<Hex1bWidget>(ctx.List(["First", "Second", "Third"])),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("> First"), TimeSpan.FromSeconds(2))
.Down()
.WaitUntil(s => s.ContainsText("> Second"), TimeSpan.FromSeconds(2))
.Capture("after_down")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
Assert.IsTrue(snapshot.ContainsText("> Second"));
}
Pattern B: Render Count Test (No Snapshot Assertions)
[TestMethod]
public async Task StaticWidget_OnlyRendersOnce()
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = Hex1bTerminal.CreateBuilder()
.WithWorkload(workload)
.WithHeadless()
.Build();
var renderCount = 0;
var staticWidget = new TestWidget().OnRender(_ => renderCount++);
using var app = new Hex1bApp(
ctx => Task.FromResult<Hex1bWidget>(staticWidget),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.Key(Hex1bKey.A)
.Key(Hex1bKey.B)
.Capture("final")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
Assert.AreEqual(1, renderCount);
}
Pattern C: Unit Test Without App Exit
[TestMethod]
public async Task Render_AllItems_AreVisible()
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = Hex1bTerminal.CreateBuilder()
.WithWorkload(workload)
.WithHeadless()
.Build();
var context = CreateContext(workload);
var node = new ListNode { Items = ["Item 1", "Item 2", "Item 3"] };
node.Arrange(new Rect(0, 0, 20, 5));
node.Render(context);
var snapshot = await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s =>
s.ContainsText("Item 1") &&
s.ContainsText("Item 2") &&
s.ContainsText("Item 3"),
TimeSpan.FromSeconds(2))
.Capture("final")
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
Assert.IsTrue(snapshot.ContainsText("Item 1"));
Assert.IsTrue(snapshot.ContainsText("Item 2"));
Assert.IsTrue(snapshot.ContainsText("Item 3"));
}
Pattern 4: Task.WhenAny Race Condition
Symptoms
- Test uses
Task.WhenAny to check if app exited
- Test sometimes times out even though behavior is correct
- Inconsistent pass/fail across runs
Root Cause
Using Task.WhenAny(runTask, Task.Delay(...)) creates a race condition. The delay may win even when the app is about to exit, or the exit may happen but not be detected in time.
Example: Broken Test
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await renderTest.Task.WaitAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.Key(Hex1bKey.C, Hex1bModifiers.Control)
.Build()
.ApplyAsync(terminal);
var completed = await Task.WhenAny(runTask, Task.Delay(2000));
Assert.IsTrue(completed == runTask, "Expected CTRL-C to exit");
Fix
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("expected content"), TimeSpan.FromSeconds(2))
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await runTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Pattern 5: Test Interference
Symptoms
- Test passes when run in isolation:
dotnet test --filter "FullyQualifiedName~TestName"
- Test fails when run with full suite
- Failures appear random or depend on test execution order
Root Cause
Tests may interfere with each other through:
- Shared static state - Singletons, static fields
- File system conflicts - Temp files with same names
- Port conflicts - Multiple tests binding to same port
- Resource exhaustion - Too many terminals/processes open
Diagnosis
# Run test in isolation
dotnet test --filter "FullyQualifiedName~TestName" --no-build
# Run test with suspected interfering tests
dotnet test --filter "FullyQualifiedName~TestName|FullyQualifiedName~OtherTest" --no-build
Fix Strategies
- Use unique temp paths - Include test name or GUID in temp file paths
- Properly dispose resources - Ensure
using statements on terminals/workloads
- Avoid static state - Use instance fields or dependency injection
- Add
[DoNotParallelize] on the test class - tests parallelize by default, and this forces sequential execution for conflicting tests
var tempPath = Path.Combine(Path.GetTempPath(), $"hex1b_test_{Guid.NewGuid()}.cast");
var tempPath = Path.Combine(Path.GetTempPath(), $"hex1b_{nameof(MyTestMethod)}.cast");
Pattern 6: Platform-Specific Failures
Symptoms
- Test always fails on Windows but passes on Linux (or vice versa)
- Error mentions platform-specific features (PTY, console mode, etc.)
- Test timeout with empty terminal state on one platform
Common Platform Differences
| Feature | Windows | Linux |
|---|
| PTY support | Limited (ConPTY) | Native |
| Terminal buffer cleanup | Delayed | Immediate |
| File locking | Strict | Flexible |
| Path separators | \ | / |
Fix: Add Platform Skip Category or Ignore
[TestMethod]
[TestCategory("LinuxOnly")]
public async Task WithPtyProcess_ExecutesProcess()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return;
}
}
[TestMethod]
[Ignore("Requires Unix PTY support")]
public async Task PtyTest() { }
public sealed class LinuxOnlyTestMethodAttribute : TestMethodAttribute
{
public override async Task<TestResult[]> ExecuteAsync(ITestMethod testMethod)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return [new TestResult
{
Outcome = UnitTestOutcome.Inconclusive,
TestFailureException = new AssertInconclusiveException("This test requires Linux")
}];
}
return await base.ExecuteAsync(testMethod);
}
}
Tests Known to Require Linux
Hex1bTerminalBuilderTests.WithPtyProcess_* - Require PTY
NanoExploratoryTests.* - Require nano and PTY
- Tests using
WithPtyProcess() builder method
Pattern 7: Task.Delay for Async Events
Symptoms
- Test passes locally most of the time
- Test fails intermittently on CI, especially on slower runners
- Test waits a fixed time (e.g.,
Task.Delay(100)) for an async event like input binding firing
- Error message suggests the action never occurred (e.g., "Expected binding to fire")
Root Cause
Using Task.Delay to wait for async events like input bindings firing is unreliable. The fixed delay may not be long enough on slower CI runners, or may be unnecessarily long on fast machines.
Example: Input binding tests that send a key and wait for the binding callback to fire.
Example: Broken Test
var bindingFired = false;
using var app = new Hex1bApp(
ctx =>
{
var vstack = new VStackWidget([ctx.Test()])
.InputBindings(bindings =>
{
bindings.Shift().Key(key).Action(_ =>
{
bindingFired = true;
return Task.CompletedTask;
}, $"Test Shift+{key}");
});
return Task.FromResult<Hex1bWidget>(vstack);
},
new Hex1bAppOptions { WorkloadAdapter = workload }
);
await new Hex1bTerminalInputSequenceBuilder()
.Shift().Key(key)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await Task.Delay(100);
Assert.IsTrue(bindingFired);
Fix
Replace the boolean flag and Task.Delay with a TaskCompletionSource that signals when the event occurs:
var bindingFired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var app = new Hex1bApp(
ctx =>
{
var vstack = new VStackWidget([ctx.Test()])
.InputBindings(bindings =>
{
bindings.Shift().Key(key).Action(_ =>
{
bindingFired.TrySetResult();
return Task.CompletedTask;
}, $"Test Shift+{key}");
});
return Task.FromResult<Hex1bWidget>(vstack);
},
new Hex1bAppOptions { WorkloadAdapter = workload }
);
await new Hex1bTerminalInputSequenceBuilder()
.Shift().Key(key)
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
await bindingFired.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
Assert.IsTrue(bindingFired.Task.IsCompleted);
Key Points
- Use
TaskCompletionSource instead of a boolean flag
- Call
TrySetResult() in the event handler to signal completion
- Use
WaitAsync with timeout instead of Task.Delay
- Use
TaskCreationOptions.RunContinuationsAsynchronously to avoid potential deadlocks
- The timeout should be generous (2+ seconds) to account for slow CI runners
Pattern 8: Test Helper Partial Wait
Symptoms
- Tests using shared helper methods that write multiple lines fail intermittently
- Assertions fail for content on lines other than the first
- Helper waits for initial content but snapshot misses later content
- Test name often includes directional movement (Up, Down) or multi-line content
Root Cause
Test helper methods that write multiple lines of content may only wait for the first line to appear before taking a snapshot. On faster CI systems, the snapshot may be captured before all lines are processed by the output pump.
Example: A helper writes lines ["A", "B"] but only waits for "A" to appear. Tests that expect "B" on a separate line may fail because the snapshot is taken before "B" is processed.
Example: Broken Helper
private static async Task<Hex1bTerminalSnapshot> CreateSnapshotAsync(string[] lines)
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).Build();
foreach (var line in lines)
{
workload.Write(line + "\r\n");
}
var firstLine = lines.Length > 0 ? lines[0] : "";
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText(firstLine), TimeSpan.FromSeconds(1))
.Build()
.ApplyAsync(terminal);
return terminal.CreateSnapshot();
}
Fix
private static async Task<Hex1bTerminalSnapshot> CreateSnapshotAsync(string[] lines)
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).Build();
foreach (var line in lines)
{
workload.Write(line + "\r\n");
}
var lastLine = lines.Length > 0 ? lines[^1] : "";
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => string.IsNullOrEmpty(lastLine) || s.ContainsText(lastLine),
TimeSpan.FromSeconds(1), "last line content")
.Build()
.ApplyAsync(terminal);
return terminal.CreateSnapshot();
}
How to Identify
- Look for test helpers that write multiple pieces of content (arrays, lists)
- Check if the
WaitUntil condition only checks for initial/first content
- Tests affected often have names suggesting multi-line or positional patterns
Checklist for Test Review
Before committing test changes, verify: