documentation/TUI-TESTS.md · main · 2026-09-06

TUI Test Suite

Headless integration tests that exercise every view and keybinding. Inspired by Ghostty's renderer test approach: seed a real git repo, drive the TUI programmatically, assert on rendered output.

Table of Contents


Running

go test ./library/tui/test/...                       # all TUI tests
go test ./library/tui/test/ -run Smoke               # smoke only
go test ./library/tui/test/ -run Display             # display only
go test ./library/tui/test/ -run Golden              # golden file comparison
go test ./library/tui/test/ -run Golden -update      # regenerate golden files
go test ./library/tui/test/ -run Navigation          # navigation only
go test ./library/tui/test/ -run Sequence            # sequence only
go test -v ./library/tui/test/ -run PR               # verbose, PR-related

The suite is slow and silent per package until it finishes. For streamed per-test progress (one line per test as it completes, plus a final summary), wrap any of the above with scripts/test.sh, which passes its args straight through to go test -json:

scripts/test.sh ./library/tui/test/...               # all TUI tests, streamed
scripts/test.sh -run Smoke ./library/tui/test/       # smoke only, streamed

Tests create temp dirs, no external dependencies. The full tier (GITSOCIAL_TEST_FULL=1) runs in about 206 s standalone; TestSmoke (90 s) and TestGolden/LayoutProperties (59 s) dominate. The quick tier (scripts/check.sh --quick) skips those two and TestSequence and runs in about 40 s standalone.


Architecture

library/tui/test/
├── harness.go          # Headless model driver
├── fixture.go          # Test repo setup + data seeding
├── generate_test.go    # Fixture tarball generation (gated by -generate flag)
├── assert.go           # Render assertion helpers (ANSI stripping, pattern matching)
├── main_test.go        # Shared fixture via TestMain
├── smoke_test.go       # Key smoke tests (all keys × all views)
├── display_test.go     # Content display tests
├── golden_test.go      # Visual regression tests
├── navigation_test.go  # View-to-view navigation tests
├── sequence_test.go    # Multi-step interaction tests
├── cursor_test.go      # List-cursor stability across fetch and back-nav
├── history_diff_test.go # History-diff footer registration + render
├── stack_test.go       # Stacked-PR display, bindings, and navigation
├── proposal_test.go    # Cross-repo proposal display + accept/decline flow
└── testdata/           # Generated artifacts (committed)
    ├── fixture-repo.tar.gz  # Pre-built workspace repo with seeded data
    ├── fixture-fork.tar.gz  # Pre-built fork repo (the cross-repo proposal)
    ├── fixture.json         # Fixture metadata (entity IDs)
    └── *.golden             # Golden files (generated with -update flag)

Harness

Wraps tui.NewModel without a real terminal. Sends tea.KeyMsg and tea.WindowSizeMsg directly, collects rendered output via Rendered().

type Harness struct {
    model        tui.Model
    t            *testing.T
    workdir      string
    cache        string
    width        int
    height       int
    SkippedExecN int // execMsg commands skipped (editor spawns, etc.)
}

func New(t *testing.T, workdir, cacheDir string) *Harness

// Drive
func (h *Harness) SendKey(key string)
func (h *Harness) SendKeys(keys ...string)
func (h *Harness) Navigate(path string)
func (h *Harness) NavigateTo(loc tuicore.Location)
func (h *Harness) SetSize(w, h int)
func (h *Harness) DrainCmds()

// Inspect
func (h *Harness) Rendered() string
func (h *Harness) CurrentPath() string
func (h *Harness) CurrentContext() tuicore.Context
func (h *Harness) CurrentView() tuicore.View
func (h *Harness) BindingsForContext(ctx tuicore.Context) []tuicore.Binding

New() initializes the bubblezone global manager, creates the model, sends WindowSizeMsg{120, 40} to make it ready, then runs Init() and drains all startup commands. Each SendKey() and Navigate() call automatically drains resulting commands.

Fixture

Two pre-built git repos stored as tarballs, with entity IDs in testdata/fixture.json. testdata/fixture-repo.tar.gz is the workspace (origin https://github.com/user/repo); testdata/fixture-fork.tar.gz is a second repo (origin https://github.com/bob/repo) that carries one cross-repo edit of the workspace's issue. Both are extracted once per test run via TestMain and shared read-only across tests. Fixture data uses examples from the protocol specs (Alice, dark mode, etc.).

func SetupFixture(t *testing.T) *Fixture     // per-test isolation (extracts fresh copies)
func getFixture(t *testing.T) *Fixture        // shared read-only fixture

The cache handle is process-global and cache.Open is a no-op while one is already open, so SetupFixture closes the shared cache first and reopens it on cleanup. Tests that mutate the repo (accept a proposal, close an issue) must use SetupFixture; everything else uses getFixture.

To regenerate the fixture after changing seed data:

go test ./library/tui/test/ -run TestGenerateFixture -generate
go test ./library/tui/test/ -run Golden -update      # regenerate golden files too

Generation points HOME, XDG_CONFIG_HOME and GITSOCIAL_PERSONAL_REPO at a throwaway directory, so it can never reach the real personal memo repo or the real config.

Seeds via extension APIs (not raw git):

Only the project memo tier is seeded: the personal and session tiers live outside the repo (~/.config/gitsocial/personal, the cache dir) so they cannot travel in a tarball.

Cache populated via SyncWorkspaceToCache() for each extension, same as the real app. The workspace is synced first and the fork second, because a cross-repo edit only resolves once the canonical it edits is cached. Commit timestamps are then rewritten one second apart, ending now, so relative time always renders as "just now" while version and list ordering stays deterministic.

Assert Helpers

func stripANSI(s string) string                          // remove ANSI escape codes
func rendered(h *Harness) string                          // strip ANSI from rendered
func assertContains(t, output, substr)                    // substring in stripped output
func assertRendersItem(t, h, loc, want...)                // navigate, then require seeded content
func assertNotEmpty(t, output)                            // non-empty after stripping
func assertLineCount(t, output, maxLines)                 // output fits height

assertNotEmpty passes on a view that draws nothing but its own chrome and an empty-state message, so it is only appropriate for views with no seeded data. Prefer assertRendersItem: it navigates, then requires every named fragment of seeded content, and fails on an empty expectation so an unset fixture field cannot make the assertion vacuous.


Test Inventory

1. Smoke Tests — every key on every view

File: smoke_test.go

TestDescription
TestSmoke/AllKeysAllViewsIterates AllViewMetas() × Registry.ForContext(ctx). Sends every registered key on every view — no panic = pass
TestSmoke/UnregisteredKeysIgnoredSends unbound keys (z, x, 1, !, #, etc.) — verifies graceful ignore

The smoke test produces 6,364 subtests and runs only in the full tier (GITSOCIAL_TEST_FULL=1), as do TestSequence and TestGolden/LayoutProperties.

2. Display Tests — verify actual content rendering

File: display_test.go

TestVerifies
TestDisplay/TimelineAuthor name, "Timeline" title
TestDisplay/SearchNon-empty search view
TestDisplay/MyRepositoryProject memos appear in the workspace feed
TestDisplay/Board"Board" title
TestDisplay/IssuesListIssue subject from fixture
TestDisplay/MilestonesMilestone title from fixture
TestDisplay/SprintsSprint title from fixture
TestDisplay/PRListPR subject from fixture
TestDisplay/ReleasesListRelease subject from fixture
TestDisplay/NotificationsThe fork's cross-repo edit notification
TestDisplay/MemosMemo subject, body, label and tier badge
TestDisplay/ProjectMemosBoth project-tier memos
TestDisplay/MemoDetailMemo subject, body and label
TestDisplay/MemoHistoryEdited memo's two versions
TestDisplay/MemoInheritsInherited source URL
TestDisplay/ForksFork count and the registered fork URL
TestDisplay/Settings"Settings" text
TestDisplay/Cache"Cache" text
TestDisplay/Help"Help" text

3. Golden File Tests — visual regression

File: golden_test.go

TestViewSize
TestGolden/timeline_120x40/social/timeline120×40
TestGolden/board_120x40/pm/board120×40
TestGolden/issues_120x40/pm/issues120×40
TestGolden/pr_list_120x40/review/prs120×40
TestGolden/releases_120x40/release/list120×40
TestGolden/settings_120x40/settings120×40
TestGolden/help_120x40/help120×40
TestGolden/LayoutPropertiesAll views120×40, 80×24, 200×60

Golden files are ANSI-stripped and compared line-by-line. Update with:

go test ./library/tui/test/ -run Golden -update

Layout property checks verify every view at 3 terminal sizes: the output fits the terminal height and every view renders. They do not check width: nothing in the suite fails on a line wider than the terminal.

4. Navigation Tests — view transitions

File: navigation_test.go

TestFlow
TestNavigation/GlobalKeysT→timeline, B→board, P→PRs, R→releases (from settings)
TestNavigation/Backtimeline → settings → esc → timeline
TestNavigation/MultiLevelBacktimeline → settings → cache → esc → esc
TestNavigation/Detailissues → enter → esc
TestNavigation/Searchtimeline → / → search
TestNavigation/Helptimeline → ? → help
TestNavigation/Notificationstimeline → @ → notifications

5. Sequence Tests — multi-step interactions

File: sequence_test.go

TestFlow
TestSequence/AllExtensionsT → B → P → R → T cycle
TestSequence/BrowseAndReturntimeline → enter → esc → same timeline
TestSequence/IssuesFlowB → issues → enter → esc
TestSequence/SettingsAndBacksettings → cache → esc → settings
TestSequence/QuickJumpOverridesHistorydeep nav → R → releases
TestSequence/PostEditTriggersEditorpost detail → e → editor spawned
TestSequence/PostCommentTriggersEditorpost detail → c → editor spawned
TestSequence/PostRepostTriggersEditorpost detail → y → editor spawned
TestSequence/PostRetractShowsConfirmpost detail → X → [y/n] confirm
TestSequence/PostHistoryNavigatespost detail → h → /social/history
TestSequence/SearchFlow/search → type query → enter → results
TestSequence/PRDiffNavigatesPR detail → d → /review/diff
TestSequence/IssueEditNavigatesissue detail → e → /pm/edit-issue
TestSequence/IssueCommentTriggersEditorissue detail → c → editor spawned
TestSequence/MilestoneEditNavigatesmilestone detail → e → /pm/edit-milestone
TestSequence/MilestoneCommentTriggersEditormilestone detail → c → editor spawned
TestSequence/SprintEditNavigatessprint detail → e → /pm/edit-sprint
TestSequence/SprintCommentTriggersEditorsprint detail → c → editor spawned
TestSequence/ReleaseEditNavigatesrelease detail → e → /release/edit
TestSequence/ReleaseCommentTriggersEditorrelease detail → c → editor spawned
TestSequence/PREditNavigatesPR detail → e → /review/edit-pr
TestSequence/PRCommentTriggersEditorPR detail → c → editor spawned
TestSequence/MultipleViewRendersVisits 10 views sequentially, verifies each renders

6. Cursor Tests — list selection stability

File: cursor_test.go

TestVerifies
TestTimelineCursorSurvivesFetchA completed fetch does not move the timeline cursor
TestTimelineCursorSurvivesBackNavOpening an item and pressing esc returns to the same selection

File: history_diff_test.go

TestVerifies
TestHistoryDiffFooterEach history-diff context registers the expected footer entries, without duplicates or conflicts with the global key set
TestHistoryDiffFooter/PostHistoryDiffRendersThe post history-diff view renders

8. Stack Tests — stacked pull requests

File: stack_test.go

Seeds a child PR on top of the fixture's PR (DependsOn), once per package run.

TestVerifies
TestStackDisplay/BadgeOnPRListStack badge appears on the PR list
TestStackBindingsStack keys are registered on the PR contexts
TestStackNavigationBackendreview.GetStack / GetDependents back the navigation

9. Proposal Tests: cross-repo proposals

File: proposal_test.go

The fork repo's edit of the workspace issue is inert until the owner acts, so it renders as an open proposal everywhere the owner might act on it.

TestVerifies
TestProposalDisplay/IssueListMarkerThe ✎ proposed-edit marker on the issue card
TestProposalDisplay/IssueDetailBannerThe "Proposed edits from another repo" banner
TestProposalDisplay/HistoryRowThe ✎ proposal · bob/repo tag on the fork's version
TestProposalDisplay/HistoryFooterOffersAcceptAndDeclineA/X appear only when an open proposal is present
TestProposalAcceptA applies the proposal: issue closes, marker clears
TestProposalDeclineX declines it: issue stays open, marker clears

Both action tests mutate the repo, so they take an isolated fixture via SetupFixture.


Harness Implementation Notes

Command Draining

Bubbletea views return tea.Cmd (async functions) from Update and Activate. The harness executes them synchronously with a 50-depth recursion limit:

func (h *Harness) processCmds(cmd tea.Cmd, depth int) {
    if cmd == nil || depth > 50 {
        return
    }
    msg := execCmd(cmd)   // skips known blockers, runs rest synchronously
    if msg == nil {
        return
    }
    if batch, ok := msg.(tea.BatchMsg); ok {
        for _, c := range batch {
            h.processCmds(c, depth+1)
        }
        return
    }
    if shouldSkipMsg(msg) {
        return
    }
    m, next := h.model.Update(msg)
    h.model = toModel(m)
    h.processCmds(next, depth+1)
}

Blocking Command Skip List

Some Bubbletea commands block indefinitely (cursor blink via time.After, tea.SetWindowTitle writing to nil program channel). The harness identifies these by function name via runtime.FuncForPC and skips them before execution. The model runs in headless mode (SetHeadless(true)) which also skips terminal-dependent commands in Init().

Model Type Assertion

tui.Model.Update() returns tea.Model which may be tui.Model (value) or *tui.Model (pointer) depending on the code path (handleKey uses pointer receiver). The toModel() helper handles both.

Skip Lists

Commands skipped before execution (identified by function name):

Function patternReason
BlinkCmdCursor blink blocks on time.After indefinitely
startFetchNetwork fetch blocks on remote I/O

Messages skipped after execution:

Message typeReason
tea.QuitMsgWould exit the test
setWindowTitleMsgTerminal-only, unexported
execMsgEditor/process launch
cursor.BlinkMsgCursor blink result

Key Simulation

Maps string key names to tea.KeyMsg:

Key stringMaps to
enter, esc, tab, shift+tabCorresponding tea.Key* type
up, down, left, rightArrow keys
ctrl+c, ctrl+d, ctrl+uControl sequences
space, backspace, home, end, pgup, pgdownSpecial keys
Any other stringtea.KeyRunes with the string as runes

What This Catches

Not caught: horizontal overflow. assertLineCount bounds the number of lines only, so a line wider than the terminal passes every check except a golden diff.