tui-tests
GitHub指导 OpenTUI 测试的编写、修复与审查,涵盖渲染器状态处理、帧预算机制及常见超时问题的排查。
触发场景
安装
npx skills add iamcheyan/oc --skill tui-tests -g -y
SKILL.md
Frontmatter
{
"name": "tui-tests",
"description": "Use when writing, fixing, or reviewing tests that render OpenTUI (testRender, createTestRenderer, createAppFixture) in packages\/tui or packages\/cli. Covers frame waits (waitForFrame, waitFor, waitForVisualIdle, flush), started vs non-started renderers, injected clocks, mock keyboard and mouse input, terminal sizes, cleanup and shared paths, and the 30s budget. Also use when a TUI test fails with \"Timed out waiting for frame predicate after N passes\", or when a tui test file is slow. For cli subprocess and service tests, use the unit-tests skill."
}
TUI tests
packages/tui runs about 170 test files in one bun test process. CI runs it next to up to two other suites on 4-vCPU runners (bun turbo test --affected --concurrency=3 in .github/workflows/test.yml), where it has taken 136-146s on Linux and 147-171s on Windows, most of it idle on frame pacing, production timers and sleeps. Most flakes come from waiting on the wrong signal and then running out of frames on a busy runner.
Read the root AGENTS.md Testing section (no globalThis mocks, run from the package directory) and its TUI Stories section. To drive the real TUI, use the opencode-dev skill. For CLI subprocesses, service deadlines, Effect TestClock and turbo test tasks, use the unit-tests skill.
Frame budgets are not time
-
waitForFrame,waitForandflushgive up aftermaxPasses(default 20).waitForVisualIdlegives up aftermaxFrames(default 20). One pass is one rendered frame (DEFAULT_MAX_PASSESin@opentui/core/testing). "Timed out waiting for frame predicate after 20 passes" comes from this budget, not from bun's timeout. -
Started renderer (
setup.renderer.start(), ascreateAppFixturedoes atpackages/tui/test/fixture/app.ts:28): frames tick at OpenTUI's default 30 fps, so 20 passes are about 0.66s. Production renders attargetFps: 60(packages/tui/src/app.tsx:241), so test frames are twice as slow as the real app. -
Not started (the default for
testRenderandcreateTestRenderer):waitForandwaitForFramefail within milliseconds when no render is scheduled, and the error showsisRunning: false. In the same stateflush()andwaitForVisualIdle()return immediately without error; they do not wait for fetches or timers. Await the signal, thenrenderOnce(). -
Wait on frames only for something a frame produces. For anything else, wait on the signal itself:
- Focus set on a timer.
DialogSelectfocuses its filter withsetTimeout(..., 1)(packages/tui/src/ui/dialog-select.tsx:702), which takes about 15ms on Windows.typeTextsends keys to whatever is focused, so early keys are dropped and Enter then picks the default option. Put the focus in the predicate, as both dialog waits inpackages/tui/test/command-selection.test.tsxdo (:68-70for the model,:75-80for the variant):setup.renderer.currentFocusedRenderable instanceof InputRenderable. Before the variant wait had it, the test flaked in 5 CI runs in 20 hours, including a dev push, and a bigger budget did not help: it still failed withmaxPasses: 150, because the keys were lost, not late. If a wait fails with the focus in the predicate, look for a state race, not the budget. - Markdown and code text.
<markdown>(packages/tui/src/routes/session/message-parts.tsx:182) draws no text until the shared tree-sitter worker replies, while frames keep ticking, so a plainwaitForFramecan spend all 20 passes on a loaded runner with the text still pending. UsewaitForHighlightedFrame(setup, predicate, options?)frompackages/tui/test/fixture/frame.ts. When the predicate fails, it awaits every in-flightCodeRenderable.highlightingDoneinside the frame predicate, and awaiting there does not use up passes. It accepts any setup withrendererandwaitForFrame; app-lifecycle, session-group-navigation and session-home wait on transcript text this way. If the code under test accepts a client, injectnew MockTreeSitterClient({ autoResolveTimeout: 0 })instead (packages/tui/test/mini/scrollback.surface.test.ts:87). - Requests and other state. Await the deferred response your fetch handler returns. With no signal to await, poll with a wall-clock deadline instead of raising
maxPasses: the file-localwaitForFrame(setup, matches)atcommand-selection.test.tsx:117checkscaptureCharFrame()every 10ms for up to 2s.waitinpackages/tui/test/cli/tui/data.test.tsx:37andwaitForModelinpackages/tui/test/fixture/local.tsx:109do the same for state.
- Focus set on a timer.
-
waitForVisualIdlereturns on the first frame withcellsUpdated === 0by default. A frame waiting on a highlight is also quiet, so wait for the text before you measure layout or assert on it:session-home.test.tsx:227waits for "History message 0001" withwaitForHighlightedFrameafterwaitForVisualIdle()instead of reading the frame directly. -
Settle steps cost frames. Each
waitForVisualIdle({ quietFrames: 3 })takes at least 3 frames, about 100ms at 30 fps.session-group-navigation.test.tsxhas 14 of them. When you know what you are waiting for, wait for that frame instead. -
Do not use
maxPassesas a clock. Several waits do, and each assumes 33ms frames:app-lifecycle.test.tsx:792: 120 passes to cover the 1s reconnect delay aftersetup.events.disconnect()at:784;app-lifecycle.test.tsx:1721-1723: 200 passes per retry-countdown second;component/dialog-shell-output.test.tsx:144: 100 passes for a 1s poll, plusBun.sleep(1100)at:163and:182;session-panel.test.tsx:590: 120 passes inready()for the initial load.
With test frame rates scaled 4x, dialog-shell-output (3 tests) and session-activity-anchor (2 tests) failed. The same budgets are why tests cannot yet run at production fps. A larger budget is fine only for a long run of frame-driven work, such as scrolling 50 rows.
Pick the renderer mode and clock
- Component logic:
testRender(() => <X />, { width, height })withoutstart(). Change state,await app.renderOnce(), then assert oncaptureCharFrame(). One frame, deterministic. - Time-driven renderables: pass
clock: new ManualClock(), callrenderer.pause(), then advance the clock andrenderOnce()(fixture()inpackages/tui/test/component/retry-provider.test.tsx:8).pause()also stopsrequestLive()(spinners, animations) from starting the frame loop. - Clock order: inject a clock first (OpenTUI
ManualClock; EffectTestClockfor Effect code, seeunit-tests). Usejest.useFakeTimers()only when nothing is injectable: it patches the global timers of a process that runs every tui file, so restore them withjest.useRealTimers()infinally(packages/tui/test/util/delayed-presence.test.ts:24-26). - Whole app:
createAppFixture(packages/tui/test/fixture/app.ts) gives a started renderer, an in-processBun.serveandkittyKeyboard: true. Callawait setup.readybefore the first wait. Passingconfigreplaces the default{ animations: false }, so includeanimations: falseyourself. The first use in a process paysawait import("../../src/app"): about 1s idle, 12-25s under heavy load. Use it only when the behavior spans routes or data. - Server behavior: use the existing
createFetchandcreateEventStreamfixture (packages/tui/test/fixture/tui-client.ts:15,:69) rather than spawning a server. The persistent PTY, for example, is faked as/api/experimental/persistent-pty/...routes plus aBun.servewebsocket (packages/tui/test/session-wheel-focus.test.tsx:52); real PTY behavior is tested inpackages/server/test/persistent-pty.test.ts. Every fixture server listens onport: 0(fixture/app.ts:32) and is reached throughserver.url; never hard-code a port. Windows hands out ephemeral ports from 49152 up, so a fixed port in that range can already be taken by another connection.
Keyboard and mouse
-
Use
setup.mockInput:pressKey,typeText,pressEnter,pressEscape,pressArrow,pasteBracketedText. -
Create renderers with
kittyKeyboard: true. Without it,mockInputsilently sends something else (OpenTUI 0.5.17):Input Without kitty mode With kitty mode pressKey("u", { ctrl: true, shift: true })ctrl+uctrl+shift+upressKey("2", { ctrl: true })2ctrl+2pressEnter({ shift: true })returnshift+returnpressEscape()thenpressKey("a")meta+a(a lone Escape is held for 20ms)escape,a -
A second click on the same renderable within 500ms (renderer clock) and within 1 cell becomes a word or line selection. Do not sleep past that window:
Bun.sleep(550)atpackages/tui/test/session-activity-anchor.test.tsx:120costs 4.4s per file. Callsetup.renderer.clearSelection()first, which resets the click history, or click at least 2 cells away. In aManualClockrenderer driven byrenderOnce(),clock.advance(501)also works.
Terminal sizes
- Always pass
widthandheight. Otherwise the renderer usesprocess.stdout.columns: your terminal locally, 80 in CI. - Cover layout at a narrow and a wide width, for example
test.each([100, 44])(packages/tui/test/app-lifecycle.test.tsx:13). Aftersetup.resize(w, h),await setup.renderOnce()orflush()before asserting. Run any real-time sequence at one width only. - Measure work by counting, not timing: mounted rows (
packages/tui/test/session-mount-budget.test.tsx), rendered diff lines (packages/tui/test/cli/tui/diff-viewer.test.tsx:2198).performance.now()thresholds fail on slow Windows runners.
Timers, cleanup and shared state
- A TUI test that takes over a second is usually waiting on a production constant; make it injectable instead of waiting it out. Two such constants: the shell output poll
setTimeout(poll, more ? 0 : 1_000)(packages/tui/src/component/dialog-shell-output.tsx:73; 6 tests, 18.8s, 95% idle) andreconnectDelay = 1_000(packages/client/src/solid/connection.ts:33; about 1s per reconnect test). connection.ts already takesidleTimeoutandflushIntervaloptions, so add a sibling. - Clean up with
await usingorfinally: destroy the renderer and stop the fixture server. Cancel timers first, or they throw "TextBuffer is destroyed" into the next test. - Remove the tmpdir last. Destroy the renderer and await the app task first, as
createAppFixture's[Symbol.asyncDispose]does (fixture/app.ts:63). Removing it under a pending write logs "Failed to persist" and flock "lock is compromised". TestTuiContexts(packages/tui/test/fixture/tui-environment.tsx:32-35) defaultshomeandstateto fixed/tmp/opencode/...paths. When the tree mounts a provider that writes underpaths.state(StorageProviderinsrc/context/storage.tsx:145,LocalProvider, or the prompt history, frecency and stash providers), passpaths={{ state, home }}built fromtmpdir()(test/fixture/fixture.ts:8). Process-shared paths are what keep tui from parallel workers:--parallel=2 --no-isolatehalved local wall time (162.9s to 80.7s). Isolated mode re-importssrc/appin each of the 18 test files that load it, at 6x the CPU.- Bun does not cancel a timed-out test body. It keeps running and can fail the next test or rerun as "Unhandled error between tests". Triage the first failure in a file; the ones after it may be leftovers.
- Import another package's test helpers by relative path only from a package in tui's workspace dependency closure.
@opencode/tui#testdepends ontransitinturbo.json, which folds tui's whole workspace dependency closure into the test hash, sodata.test.tsx:7reaching../../../../core/testis covered because tui depends on@opencode/core. A package outside that closure is not hashed, and a change there can replay a stale pass (seeunit-tests, Turbo test tasks).
Timeouts and failures
- The tui and cli test scripts run
bun test --timeout 30000; Bun's default is 5s. In tui, that budget exists for the coldsrc/appimport, not for slow waits. - A frame-budget failure is not a timeout problem. Raising the test timeout does nothing for it, and raising
maxPassesonly hides the wrong wait. - A per-test timeout below 30s, like the 15000 at
session-group-navigation.test.tsx:249, must still cover the cold import when the file runs alone. New per-test timeouts name their work in a comment (seeunit-tests, Know the budget). - A frame-budget failure that reproduces on every run of one branch is a regression in that change, not a flake. dialog-shell-output failed "after 100 passes" in 21 of 29 tui runs on one PR stack and in 0 of about 102 elsewhere. Do not answer it with a bigger budget.
- Do not add
skipIf(process.platform === "win32")to hide a timing flake; fix the wait. Windows timers have about 15ms resolution, so a 1mssetTimeoutrace that never shows on macOS fails there.
Before you push
- From
packages/tui:bun test --timeout 30000 test/<file>. This matches CI's budget and prints per-test milliseconds, which the package script's--only-failureshides. bun test --timeout 30000 test/<file> --rerun-each 10: all 10 must pass. The first iteration pays the cold import, so a timeout on iteration 1 under heavy load is that import, not your wait. Repeat while one other suite runs (for examplebun run testinpackages/core), not on an overloaded machine. Before its focus wait, the command-selection race failed 5 of 8 local runs under load.- Force the race you suspect: temporarily slow the timer or reply (for example the focus
setTimeoutto 150ms). The test must still pass. Revert afterwards. - Negative control: make the predicate impossible. The test must fail fast with a frame dump, not hang until the timeout.
- Windows-sensitive behavior to check for: timers under 16ms, renaming a file another process holds, process kill, and paths shown in frames. On pushes and pull requests the unit job runs
--affected, so@opencode/tui:testis scheduled only when tui, a workspace package it depends on, or aglobalDependenciesfile inturbo.jsonchanged. In theunit (windows)log, confirm the lines prefixed@opencode/tui:test:are there withcache miss, executingand a pass, and compare itsRan N tests across M files. [Xs]line with a recent run that included tui. Per-file timing is only available locally.
版本历史
- d9b4ce8 当前 2026-10-18 23:22


