Black-box e2e TUI tests over a PTY (layer 2, pure .NET)#166
Conversation
The component-test APIs (Application.Create, InjectKey/InjectSequence,
VirtualTimeProvider) first appear in Terminal.Gui 2.1.0, which also landed a
breaking API redesign. This ports the whole UI layer to 2.4.5:
- Namespace reorg: add GlobalUsings for Terminal.Gui.{App,ViewBase,Views,
Drawing,Input,Drivers,Text,Configuration}; alias Attribute -> Drawing.Attribute.
- ColorScheme -> Scheme; view.ColorScheme = x -> view.SetScheme(x); add a
WithScheme() fluent helper for initializer-style scheme assignment.
- Toplevel -> Window; RadioGroup -> OptionSelector (Labels/Value/ValueChanged).
- TableView: SelectedRow -> Value?.SelectedCell.Y; SelectedCellChanged ->
ValueChanged<TableSelection>; MouseClick -> MouseEvent(Mouse).
- TreeView ObjectActivated -> Activated (read SelectedObject).
- ListView OpenSelectedItem -> Accepting; drop TopItem (SelectedItem autoscrolls).
- MenuItem shortcutKey: named arg -> positional Key.
- Custom drawing: Driver!.X(...) -> view-level SetAttribute/AddRune/AddStr/Move
in OnDrawingContent(DrawContext).
- Application.Top -> TopRunnable/TopRunnableView; SizeChanging -> SubViewLayout;
Colors.ColorSchemes["Menu"] -> SchemeManager.AddScheme; MessageBox.* now take
Application.Instance; ShadowStyle.None -> ShadowStyles.None; Subviews -> SubViews;
TextField.CursorPosition -> MoveEnd().
Known regression (flagged for review): Terminal.Gui 2.4 adornments have no
independent Scheme, so per-border colouring (grey borders, focus-highlight title)
now inherits the view scheme; focus highlight reapplied via FrameView scheme.
Builds clean; all 613 existing tests pass; published --help smoke exits 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Constructs the real Terminal.Gui views/dialogs and asserts on observable component behaviour: - MonitoredVariablesView: AddVariable/RemoveVariable, scope-selection bookkeeping, per-client-handle idempotency. - ConnectDialog: endpoint protocol-prefix handling, publishing interval, and the authentication selector (migrated RadioGroup -> OptionSelector). - Theme/Scheme: DarkTheme/LightTheme schemes and ThemeStyler.ApplyTo guard the ColorScheme -> Scheme migration. Tests run in a non-parallel xUnit collection because Terminal.Gui's Application is global state. Picked up automatically by `dotnet test` / CI. Rendered cell-buffer assertions are intentionally deferred to the black-box PTY suite: Terminal.Gui 2.4.5 stable exposes no public headless driver (Application.Create leaves Driver null; DriverAssert is develop-only). Documented in docs/TESTING.md. 627/627 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #166: Black-box e2e TUI tests over a PTYSummary: This is a well-designed, clearly reasoned addition of a Layer 2 black-box testing harness. The architecture is layered cleanly ( Bugs / Correctness1. Potential deadlock in using var p = Process.Start(psi)!;
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException("dotnet publish failed:\n" + p.StandardError.ReadToEnd());Reading from a redirected stream after var stderrTask = p.StandardError.ReadToEndAsync();
var stdoutTask = p.StandardOutput.ReadToEndAsync();
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException("dotnet publish failed:\n" + await stderrTask);
2. File descriptor leak in After Potential Flakiness3. Assert.True(app.WaitForText("Switch", Timeout), Snapshot(app)); // waits
Assert.Contains("Subscribe", app.Snapshot()); // no waitIf the status bar items render across separate frames, "Subscribe" may not be on screen yet. Use Design / Robustness4.
private void EraseLine(int mode)
{
if (!InBounds(_row, 0)) return;
if (mode == 2) { for (int c = 0; c < _cols; c++) _grid[_row, c] = ' '; return; }
int from = mode == 0 ? _col : 0;
int to = mode == 1 ? _col : _cols - 1;
for (int c = Math.Max(0, from); c <= Math.Min(_cols - 1, to); c++) _grid[_row, c] = ' ';
}5. The comment correctly identifies this. Since CI targets Minor / Style6. CI Full git history is not needed for a build-and-test job. Omitting this key (default is 7. NuGet restore cache missing from The 8. Solution file BOM + x64/x86 platform noise The UTF-8 BOM at line 1 and the x64/x86 platform config entries are Visual Studio artifacts. Harmless, but they add diff noise and can trip up Linux tooling that is strict about BOM handling. Worth cleaning up before the final merge to Summary
Items 1–3 are worth addressing before merge. Item 4 is a good defensive addition. The rest can be deferred. 🤖 Generated with Claude Code |
Adversarial review of the Terminal.Gui 2.0->2.4 migration confirmed three event-routing / API-semantics regressions (TG 2.4 moved selection/activation from overridden handlers into the command pipeline, which now runs AFTER the raw MouseEvent/Accepting events): - MonitoredVariablesView.HandleMouseClick: dropped `e.Handled = true` on Sel-column clicks. Marking it handled now pre-empts TableView's LeftButtonClicked -> Command.Activate -> SetSelection, so a Sel click toggled scope but no longer highlighted the row or raised SelectedVariableChanged. Unhandled restores both. - SaveRecordingDialog.OnFileListOpenSelected: set `e.Handled = true`. An unhandled Accepting bubbles Accept to the default Save button, so navigating a folder or picking a file would save+close the dialog mid-browse. - SaveRecordingDialog.NavigateToSelected: null-safe guard. ListView.SelectedItem is now int? (null = none); the OR-form guard didn't catch null and dereferenced null.Value (InvalidOperationException at a CSV-less filesystem root). - LogView: restore log auto-scroll via EnsureSelectedItemVisible() (TG 2.4 removed ListView.TopItem); without it the log stopped following new entries. 627/627 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Launches the published binary attached to a real pseudo-terminal, reconstructs the rendered screen from the VT/ANSI output, and asserts on it — the rendered-output coverage the in-process component tests can't provide on Terminal.Gui 2.4.5 stable. Pure .NET, no extra toolchain: - `Pty`: sized PTY via libc `openpty` + `posix_spawn(POSIX_SPAWN_SETSID)` (P/Invoke). Hand-rolled because `script` yields a 0x0 window over a pipe and `Pty.Net` is unmaintained/Windows-only. - `VtScreen`: minimal in-tree VT100/ANSI -> character-grid emulator (the .NET VT libraries are stale). - `OpcilloscopeSession`: pumps output into the screen and, crucially, answers the terminal capability queries Terminal.Gui emits (CSI 18t size, DSR 6n cursor, OSC 10/11 colours) so the app actually paints; exposes Snapshot/WaitForText/input. - `PublishedBinaryFixture`: publishes once (or reuses $OPCILLOSCOPE_BIN). Tests: startup renders all panes; status-bar keybinding hints; menu bar; '?' opens the help dialog. Stable across repeated runs (wait-for-text, not race-the-first-paint). CI: dedicated `e2e` job publishes once and runs the project; the unit job is scoped to the unit/integration/component project to avoid double-running. Linux-only. See Tests/Opcilloscope.E2ETests/README.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #166: Black-box e2e TUI tests over a PTYThis is impressive infrastructure: a pure-.NET PTY harness with an in-tree VT emulator is a well-reasoned solution given the state of the NuGet ecosystem for this problem (stale libraries, no headless driver in Terminal.Gui 2.4.5 stable). The design rationale in the PR description and README is clear and correct. A few findings below, ordered by severity. Bugs / Correctness
// Current (equivalent to: idx < p.Count ? p[idx] : def)
private static int P(List<int> p, int idx, int def) =>
idx < p.Count && p[idx] != 0 ? p[idx] : (idx < p.Count ? p[idx] : def);Both branches of the outer ternary return private static int P(List<int> p, int idx, int def) =>
idx < p.Count && p[idx] != 0 ? p[idx] : def;This doesn't cause visible test failures because cursor-move callers use
public void Write(byte[] data) => write(_master, data, data.Length);If the child has exited or the PTY buffer is full, public void Write(byte[] data)
{
long n = write(_master, data, data.Length);
if (n < 0) /* log or throw */ ;
}
RedirectStandardOutput = true,
RedirectStandardError = true,
// ...
p.WaitForExit();
if (p.ExitCode != 0)
throw new ... p.StandardError.ReadToEnd();Both streams are captured but neither is drained while the process runs. var stderr = p.StandardError.ReadToEndAsync();
p.StandardOutput.ReadToEnd(); // drain stdout synchronously, or also async
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException("dotnet publish failed:\n" + stderr.Result);Minor / Design
Solution file — unintentional x86/x64 configurations and BOM The diff adds CI The e2e job uses Test CoverageThe four
Assert.True(app.WaitForText("Switch", Timeout), Snapshot(app));
Assert.Contains("Subscribe", app.Snapshot()); // ← races the same paintIf "Switch" and "Subscribe" appear on the same render frame this is fine, but if "Subscribe" lags a frame, the second assertion could fire before it's present. An extra Summary
The |
7d2799b to
8535266
Compare
Draft — do not merge. Second of two PRs adding programmatic TUI testing.
Stacked on #165 (
test/tui-component) so it runs against the migrated (Terminal.Gui 2.4.5)app. Base will retarget to
mainonce #165 merges. Review #165 first.What this is
Layer 2: black-box end-to-end tests that launch the published binary attached to a real
pseudo-terminal, reconstruct the rendered screen from its terminal output, and assert on it.
This is the rendered-output coverage that the in-process component tests (layer 1) can't
provide — Terminal.Gui 2.4.5 stable ships no public headless driver.
Pure .NET — no Node, no Python, no NuGet PTY/VT library.
Design
Ptyopenpty+posix_spawn(POSIX_SPAWN_SETSID)(P/Invoke).VtScreenOpcilloscopeSessionCSI 18t, cursorDSR 6n, OSC 10/11) so it paints, exposesSnapshot/WaitForText/input.PublishedBinaryFixture$OPCILLOSCOPE_BIN.Why not
script/Pty.Net/VtNetCore?scriptproduces a 0×0 window when stdout is a pipe (no size flag) → Terminal.Gui won'tdraw. We size the PTY ourselves.
Pty.Netis unmaintained (2018, unlisted, Windows-only native);VtNetCore/XtermSharparestale — so the emulator is kept in-tree, scoped to what Terminal.Gui emits.
The key insight
Terminal.Gui's net driver detects size/colours by emitting escape sequences and waiting for
the terminal to reply. A bare PTY has no emulator answering, so the app blocks before its
first paint. The session answers those queries — that's what makes black-box rendering work.
Tests (
StartupTests)?opens the help dialog (opcilloscope - Help).Stable across repeated local runs (assertions wait for text rather than racing the first paint).
CI
A dedicated
e2ejob publishes the binary once, setsOPCILLOSCOPE_BIN, and runs the project;the unit job is scoped to the unit/integration/component project to avoid double-running. PTY
allocation works on
ubuntu-latestby default. SeeTests/Opcilloscope.E2ETests/README.md.🤖 Generated with Claude Code