Skip to content

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 by ci-check and by the windows-smoke CI 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 by layout list and restored by layout load, which is the live proof of the profiles-migration read path. Its vault step seeds a temp vault and drives vault search (twice - the second after editing a note outside gPTY, which proves the index-on-search freshness), vault read, and the not_found refusal, 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 by test_ipc_dispatch_contract.gd, which drives WorkspaceIpcHandlers.handle with a real Workspace
  • godot/scenes/terminal/terminal_pane.gd - renderer and input paths are partially covered by test_keyboard.gd/test_copy_routing.gd mocks; real rendering is manual-only
  • godot/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.gd
  • godot/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.gd is the exception: test_cli_view.gd runs 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 outcome

For CLI commands, include the exact command and expected output format. For UI actions, describe the gesture and the visual change.