Testing
gpty’s test strategy combines automated CI checks with a manual pre-release smoke test suite. Automated tests cover all pure-logic paths in Rust and GDScript; manual tests cover GUI interaction, IPC bridge roundtrips, and daemon lifecycle that require a running Godot instance.
Automated (CI)
Run on every push and pull request:
# Rust - unit tests, integration tests, doctests
cargo test --workspace
cargo clippy -- -D warnings
cargo fmt --check
# GDScript - unit + integration tests
godot --headless --path godot --import
./scripts/gut-check # runs GUT and fails if the run aborted (GUT exits 0 regardless)The same two commands run on Windows in the windows-smoke job, under the
runner’s Git Bash: the suite takes its shells from
SettingsManager.default_shell_command() and its byte-emitting fixtures from
godot/tests/helpers/shell_fixtures.gd, so a GDScript test is not a
Linux-only artifact. gut-check prints any test that pends with its reason and
fails on a test that ran without asserting.
gut-check also gives the run a private user root, not just a private
endpoint: GPTY_TEST_USER_DIR names a temp directory that
BasePersistenceManager uses for every user:// store and that the GDExtension
uses for history.db. On Windows, where Godot’s user:// resolves through the
Known Folder API and cannot be redirected by an environment variable, it
additionally sandboxes LOCALAPPDATA/APPDATA. The GUT run leaves Godot’s
file logging off (so there is no engine log to redirect) and caps the suite at
GUT_TIMEOUT (600 s) so a hung test fails the gate, and it does not pass
-d (Godot treats it as --debug, so a runtime test error would break into
the interactive debugger instead of being reported); the live smoke uses
--log-file because it needs a failure-time engine log. The
effect is that a suite run cannot read a developer’s saved layout, write probe
vaults or recovery copies into their state directory, or append to their real
scrollback. A test that needs a filesystem path must use
TestPaths.user_path("name") rather than ProjectSettings.globalize_path("user://name");
the live smoke uses the same seam and needs no store backup/restore.
Rust coverage: core engine (parser, keymap, grid, concept routing, capture state machine, history, the vault index and its walk), the vault’s native IPC handlers (search re-indexing, read containment, parameter validation), IPC types + protocol, CLI schema generation and response formatting (both output modes fail on an RPC error, and the JSON envelope is printed first), GDExtension FFI functions. The tile layout algorithm (split, kill, expand) lives only in godot/scenes/terminal/terminal_manager.gd and is tested there, in godot/tests/unit/test_terminal_manager.gd, against the real spawn_pane/kill - including every close order of five panes. A Rust copy of it once stood in for those tests and could not fail when the real code broke; do not reintroduce one. The IPC vocabulary is pinned across languages: a test in crates/gpty-cli/src/commands/mcp.rs reads the real registrations (crates/gpty-gdext/src/ipc.rs), the real dispatch (ipc_handlers.gd, plus the requests workspace.gd intercepts), and every client.call literal in src/commands/*.rs, and asserts they name the same methods as the advertised MCP tools.
GDScript coverage: concept merge/save/load, terminal manager tile lifecycle (spawn, kill, swap, labels, grid-full refusal), divider edge drag (test_pane_edge_drag.gd: pixel preview vs unit commit, one tiles_resized per release, MIN_TILE clamp, window-border refusal, drag survives leaving the strip), settings save/load roundtrip, profile CRUD and the three profile sources (the shipped built-in, installed-plugin profiles, user profiles - merge order, (n) dedupe, and the plugin-revision consent gate), layout save/restore, sidebar signal emission, pane settings application, IPC dispatch against a real workspace (test_ipc_dispatch_contract.gd: listed pane ids and legacy labels both address the pane they name, terminal-only methods refuse other panes, unknown methods answer -32601, an untrusted profile is refused, and a targetless paneStatus covers every tile with status: null for non-terminals), palette command generation.
Known gaps (not automatable in headless CI):
- Real PTY output timing: a headless pane spawns a real shell, but tests that assert on its output are timing-dependent, so they live in the live smoke (
scripts/smoke_pane_api.py) instead - one harness for every platform, run byci-checkand by thewindows-smokeCI job. Its concept step asserts a routed capture event fired by a runtime-built output marker, which is the live proof that committed output lines (ConPTY renders their breaks as cursor moves) reach the concept matcher on the platform running the smoke. Its final step seeds an installed plugin’s record into the sandboxed store and asserts the plugin’s profile is listed bylayout listand restored bylayout load, which is the live proof of the profiles-migration read path. Its vault step seeds a temp vault and drivesvault search(twice - the second after editing a note outside gPTY, which proves the index-on-search freshness),vault read, and thenot_foundrefusal, so the native vault handlers are exercised over the same socket an agent uses.
Files without automated coverage (manual checklist only):
godot/scenes/terminal/workspace.gd- restore/sanitize wiring and concept event routing; IPC dispatch itself is covered bytest_ipc_dispatch_contract.gd, which drivesWorkspaceIpcHandlers.handlewith a realWorkspacegodot/scenes/terminal/terminal_pane.gd- renderer and input paths are partially covered bytest_keyboard.gd/test_copy_routing.gdmocks; real rendering is manual-onlygodot/scenes/ui/settings_panel.gd,godot/scenes/ui/pane_settings_panel.gd,godot/scenes/ui/status_bar.gd,godot/scenes/ui/toast_overlay.gd,godot/scenes/ui/icons.gdgodot/scenes/panes/code_viewer.gd,godot/scenes/panes/file_tree.gd,godot/scenes/panes/wiki_pane.gd,godot/scenes/panes/inspector_pane.gd,godot/scenes/panes/reasoning_pane.gd- unit-tested for routing/session contracts; real OMP/Markdown rendering is manual (cli_view_pane.gdis the exception:test_cli_view.gdruns a real child and asserts its streamed lines)godot/scenes/autoloads/focus_manager.gd,godot/scenes/autoloads/shortcut_manager.gd,godot/scenes/autoloads/toast_manager.gd,godot/scenes/autoloads/update_checker.gd
Manual pre-release checklist
Run before tagging a release. Requires a built GDExtension library and a running Godot instance.
Setup
cargo build -p gpty-gdext
cp target/debug/libgpty_gdext.so godot/bin/libgpty_gdext.linux.x86_64.so
godot --path godot &All CLI commands below use the default socket path ($XDG_RUNTIME_DIR/gpty.sock on Linux) or the GPTY_SOCKET env var if set.
IPC bridge - CLI-to-GUI roundtrip
Given the GUI is running with at least one auto-spawned terminal When CLI commands are issued Then the GUI responds correctly
| # | Command | Expected |
|---|---|---|
| 1 | gpty version |
gpty 0.3.0 / protocol: 2.0 |
| 2 | gpty new-pane --pane-type code_viewer |
Pane C1 appears in sidebar |
| 3 | gpty new-pane --pane-type file_tree |
Pane F1 appears in sidebar |
| 4 | gpty new-pane --pane-type inspector |
Pane I1 appears in sidebar |
| 5 | gpty new-pane --pane-type reasoning |
Pane R1 appears in sidebar |
| 6 | gpty list-panes |
JSON array with 5 entries (T1, C1, F1, I1, R1), correct type fields |
| 7 | gpty inject T1 --text "echo hello" |
“hello” appears in T1 terminal |
| 8 | gpty focus-pane C1 |
C1 highlighted in sidebar |
| 9 | gpty kill-pane F1 |
F1 removed, list-panes shows 4 panes |
| 10 | gpty kill-pane NOPE |
Error: “Pane ‘NOPE’ not found” |
| 11 | gpty layout save my-setup |
Returns {"success":true,"name":"my-setup"} |
| 12 | gpty layout list |
“my-setup” in layouts array |
| 13 | gpty layout load my-setup |
Workspace restored to saved state |
Daemon lifecycle
Given the GUI is running
| # | Command | Expected |
|---|---|---|
| 13 | gpty daemon status |
“gpty GUI is running (v0.3.0)” |
| 14 | gpty daemon stop |
GUI exits cleanly, no zombie processes (`ps aux |
| 15 | gpty daemon status |
“gpty GUI is not running.” with exit code 1 |
| 16 | gpty --no-daemon new-pane (GUI not running) |
error: no gpty GUI is running (start one with \gpty daemon start`)`, exit 1, no window |
| 17 | gpty daemon start (GUI not running) |
gpty GUI started (v<version>), and the window opens; a GUI that cannot start reports its own stderr immediately |
Standalone commands (no GUI needed)
| # | Command | Expected |
|---|---|---|
| 18 | gpty schema |
Valid JSON Schema with oneOf array of subcommands |
| 19 | gpty schema --format mcp |
Valid MCP manifest with {"tools": [...]} |
| 20 | echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | gpty mcp |
Server info with protocolVersion, serverInfo, capabilities |
| 21 | echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | gpty mcp |
Tools array matching all CLI subcommands |
Error paths
| # | Command | Expected |
|---|---|---|
| 22 | gpty kill-pane NONEXISTENT |
Error: “Pane ‘NONEXISTENT’ not found” |
| 23 | gpty inject C1 --text "test" |
Error: “Pane ‘C1’ is not a terminal” (code_viewer can’t receive injection) |
| 24 | Spam gpty new-pane --pane-type code_viewer until full |
Eventually returns “Grid is full” error |
UI regression
Given the GUI is running with at least two panes
| # | Action | Expected |
|---|---|---|
| 25 | Ctrl+N |
New terminal pane spawns |
| 26 | Ctrl+W |
Focused pane closes |
| 27 | Ctrl+B |
Sidebar toggles visible/hidden |
| 28 | Ctrl+P |
Command palette opens, fuzzy search works |
| 29 | Alt+Arrow |
Focus moves between adjacent panes geographically |
| 30 | Drag a pane edge (grab within ~6 px of the separator; the cursor becomes a resize arrow) | Pane resizes, adjacent pane compensates |
| 31 | Sidebar window mode dropdown | Cycle OS → Borderless → Fullscreen, window responds correctly |
| 32 | Settings panel (sidebar gear icon) | Opens, change font size → terminal re-renders, change persists after restart |
| 33 | Profile save (sidebar save icon) | Save current layout, quit, relaunch, load from sidebar → layout restored |
Concept engine regression
Given the GUI is running with a terminal pane and default concepts loaded
| # | Action | Expected |
|---|---|---|
| 34 | In terminal: cat somefile |
Concept triggers, output captured and routed to code_viewer pane (auto-created if needed) |
| 35 | In terminal: git diff |
Concept triggers (if enabled), output appears in code_viewer |
Platform-specific
| # | Platform | Check |
|---|---|---|
| 36 | Linux | Export release: godot --headless --path godot --export-release "Linux/X11" dist/gpty produces working binary |
| 37 | macOS | .app bundle launches, code signing not required for local testing |
| 38 | Windows | gpty.exe launches, ConPTY terminal works |
Terminal mouse reporting
Given the GUI is running and a pane is in a mouse-aware TUI (nvim, lazygit, htop with mouse on)
| # | Action | Expected |
|---|---|---|
| 39 | In nvim, :set mouse=a, then click a line |
The cursor jumps to that line - the click reached the app |
| 40 | Drag across text while such an app is focused | The app receives the drag, not the pane. Hold Shift to select locally and copy with Ctrl+Shift+C |
| 41 | Wheel-scroll a mouse-aware app | The app scrolls it (the pane does not scroll its scrollback) |
| 42 | Quit the TUI, then drag and wheel in the pane again | Selection and scrollback work as before - reporting turned off with the app |
cli_view pane
Given the GUI is running and the GDExtension is built from this tree
| # | Command | Expected |
|---|---|---|
| 43 | gpty new-pane --pane-type cli_view --command /bin/sh --arg -c --arg "printf 'view-ok\n'" |
Pane V1 appears; its body shows view-ok; the status row reports the exit |
| 44 | gpty pane-read V1 |
Returns the streamed text - the pane keeps it after the child exits |
| 45 | Pane Settings → change Program/Arguments → Restart | The new argv runs; a running child is untouched until Restart |
| 46 | gpty kill-pane V1 while a long-running child runs (--command /bin/sh --arg -c --arg "sleep 300") |
Pane closes and the child process is gone (`ps aux |
Vault access (agent surface)
Given the GUI is running and a vault directory holds .md notes
| # | Command | Expected |
|---|---|---|
| 47 | gpty vault search /path/to/vault ownership |
{"results": [{"path", "title", "snippet"}], "total": N, "truncated": false}, best match first; a note edited outside gPTY is found on the next query without reopening the vault |
| 48 | gpty vault read /path/to/vault notes/idea.md |
{"path", "text", "truncated", "crlf", "mtime", "size"}; a missing note exits 1 with a JSON-RPC error whose data.code is not_found, and an escaping path (../x.md) answers bad_path with the same nonzero status |
| 49 | gpty vault weight /path/to/vault notes/a.md notes/b.md --weight 40, then reopen the graph in the wiki pane |
{"vault", "src", "target", "weight": 40}; the a↔b edge is thicker/accented and the panel orders b first; --clear answers weight: 0 and the edge returns to its measured count. user://link_weights.json carries the pair; vaults.db is untouched |
Test case format
New manual test cases follow this pattern:
**Given** preconditions (state before the test)
**When** action performed
**Then** expected observable outcomeFor CLI commands, include the exact command and expected output format. For UI actions, describe the gesture and the visual change.