Agent Skills › iamcheyan/oc › tui-tests

tui-tests

GitHub

指导 OpenTUI 测试的编写、修复与审查,涵盖渲染器状态处理、帧预算机制及常见超时问题的排查。

.opencode/skills/tui-tests/SKILL.md iamcheyan/oc

触发场景

编写或审查 OpenTUI 组件测试 修复 TUI 测试中的超时或抖动问题 调试测试渲染器状态与输入模拟

安装

npx skills add iamcheyan/oc --skill tui-tests -g -y
更多选项

非标准路径

npx skills add https://github.com/iamcheyan/oc/tree/main/.opencode/skills/tui-tests -g -y

不安装直接使用

npx skills use iamcheyan/oc@tui-tests

指定 Agent (Claude Code)

npx skills add iamcheyan/oc --skill tui-tests -a claude-code -g -y

安装 repo 全部 skill

npx skills add iamcheyan/oc --all -g -y

预览 repo 内 skill

npx skills add iamcheyan/oc --list

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, waitFor and flush give up after maxPasses (default 20). waitForVisualIdle gives up after maxFrames (default 20). One pass is one rendered frame (DEFAULT_MAX_PASSES in @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(), as createAppFixture does at packages/tui/test/fixture/app.ts:28): frames tick at OpenTUI's default 30 fps, so 20 passes are about 0.66s. Production renders at targetFps: 60 (packages/tui/src/app.tsx:241), so test frames are twice as slow as the real app.

  • Not started (the default for testRender and createTestRenderer): waitFor and waitForFrame fail within milliseconds when no render is scheduled, and the error shows isRunning: false. In the same state flush() and waitForVisualIdle() return immediately without error; they do not wait for fetches or timers. Await the signal, then renderOnce().

  • Wait on frames only for something a frame produces. For anything else, wait on the signal itself:

    • Focus set on a timer. DialogSelect focuses its filter with setTimeout(..., 1) (packages/tui/src/ui/dialog-select.tsx:702), which takes about 15ms on Windows. typeText sends 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 in packages/tui/test/command-selection.test.tsx do (:68-70 for the model, :75-80 for 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 with maxPasses: 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 plain waitForFrame can spend all 20 passes on a loaded runner with the text still pending. Use waitForHighlightedFrame(setup, predicate, options?) from packages/tui/test/fixture/frame.ts. When the predicate fails, it awaits every in-flight CodeRenderable.highlightingDone inside the frame predicate, and awaiting there does not use up passes. It accepts any setup with renderer and waitForFrame; app-lifecycle, session-group-navigation and session-home wait on transcript text this way. If the code under test accepts a client, inject new 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-local waitForFrame(setup, matches) at command-selection.test.tsx:117 checks captureCharFrame() every 10ms for up to 2s. wait in packages/tui/test/cli/tui/data.test.tsx:37 and waitForModel in packages/tui/test/fixture/local.tsx:109 do the same for state.
  • waitForVisualIdle returns on the first frame with cellsUpdated === 0 by 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:227 waits for "History message 0001" with waitForHighlightedFrame after waitForVisualIdle() 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.tsx has 14 of them. When you know what you are waiting for, wait for that frame instead.

  • Do not use maxPasses as a clock. Several waits do, and each assumes 33ms frames:

    • app-lifecycle.test.tsx:792: 120 passes to cover the 1s reconnect delay after setup.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, plus Bun.sleep(1100) at :163 and :182;
    • session-panel.test.tsx:590: 120 passes in ready() 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 }) without start(). Change state, await app.renderOnce(), then assert on captureCharFrame(). One frame, deterministic.
  • Time-driven renderables: pass clock: new ManualClock(), call renderer.pause(), then advance the clock and renderOnce() (fixture() in packages/tui/test/component/retry-provider.test.tsx:8). pause() also stops requestLive() (spinners, animations) from starting the frame loop.
  • Clock order: inject a clock first (OpenTUI ManualClock; Effect TestClock for Effect code, see unit-tests). Use jest.useFakeTimers() only when nothing is injectable: it patches the global timers of a process that runs every tui file, so restore them with jest.useRealTimers() in finally (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-process Bun.serve and kittyKeyboard: true. Call await setup.ready before the first wait. Passing config replaces the default { animations: false }, so include animations: false yourself. The first use in a process pays await 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 createFetch and createEventStream fixture (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 a Bun.serve websocket (packages/tui/test/session-wheel-focus.test.tsx:52); real PTY behavior is tested in packages/server/test/persistent-pty.test.ts. Every fixture server listens on port: 0 (fixture/app.ts:32) and is reached through server.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, mockInput silently sends something else (OpenTUI 0.5.17):

    Input Without kitty mode With kitty mode
    pressKey("u", { ctrl: true, shift: true }) ctrl+u ctrl+shift+u
    pressKey("2", { ctrl: true }) 2 ctrl+2
    pressEnter({ shift: true }) return shift+return
    pressEscape() then pressKey("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) at packages/tui/test/session-activity-anchor.test.tsx:120 costs 4.4s per file. Call setup.renderer.clearSelection() first, which resets the click history, or click at least 2 cells away. In a ManualClock renderer driven by renderOnce(), clock.advance(501) also works.

Terminal sizes

  • Always pass width and height. Otherwise the renderer uses process.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). After setup.resize(w, h), await setup.renderOnce() or flush() 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) and reconnectDelay = 1_000 (packages/client/src/solid/connection.ts:33; about 1s per reconnect test). connection.ts already takes idleTimeout and flushInterval options, so add a sibling.
  • Clean up with await using or finally: 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) defaults home and state to fixed /tmp/opencode/... paths. When the tree mounts a provider that writes under paths.state (StorageProvider in src/context/storage.tsx:145, LocalProvider, or the prompt history, frecency and stash providers), pass paths={{ state, home }} built from tmpdir() (test/fixture/fixture.ts:8). Process-shared paths are what keep tui from parallel workers: --parallel=2 --no-isolate halved local wall time (162.9s to 80.7s). Isolated mode re-imports src/app in 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#test depends on transit in turbo.json, which folds tui's whole workspace dependency closure into the test hash, so data.test.tsx:7 reaching ../../../../core/test is covered because tui depends on @opencode/core. A package outside that closure is not hashed, and a change there can replay a stale pass (see unit-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 cold src/app import, not for slow waits.
  • A frame-budget failure is not a timeout problem. Raising the test timeout does nothing for it, and raising maxPasses only 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 (see unit-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 1ms setTimeout race 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-failures hides.
  • 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 example bun run test in packages/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 setTimeout to 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:test is scheduled only when tui, a workspace package it depends on, or a globalDependencies file in turbo.json changed. In the unit (windows) log, confirm the lines prefixed @opencode/tui:test: are there with cache miss, executing and a pass, and compare its Ran 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

同 Skill 集合

.opencode/skills/opencode-dev/SKILL.md
.opencode/skills/rtl-aware-development/SKILL.md
.opencode/skills/sample-skill/SKILL.md
packages/opencode-vim/skills/git-master/SKILL.md
packages/opencode-vim/skills/git-workflow/SKILL.md
packages/plugins/figma/skills/figma-code-connect/SKILL.md
packages/plugins/figma/skills/figma-create-new-file/SKILL.md
packages/plugins/figma/skills/figma-generative-plugins/SKILL.md
packages/plugins/figma/skills/figma-implement-motion/SKILL.md
packages/plugins/figma/skills/figma-shaders/SKILL.md
packages/plugins/figma/skills/figma-use-figjam/SKILL.md
packages/plugins/figma/skills/figma-use-motion/SKILL.md
packages/plugins/figma/skills/figma-use-slides/SKILL.md
.opencode/skills/app-e2e-tests/SKILL.md
.opencode/skills/component-tests/SKILL.md
.opencode/skills/effect/SKILL.md
.opencode/skills/unit-tests/SKILL.md
packages/plugins/figma/skills/figma-design-to-code/SKILL.md
packages/plugins/figma/skills/figma-generate-design/SKILL.md
packages/plugins/figma/skills/figma-generate-diagram/SKILL.md
packages/plugins/figma/skills/figma-generate-library/SKILL.md
packages/plugins/figma/skills/figma-swiftui/SKILL.md
packages/plugins/figma/skills/figma-use/SKILL.md

元信息

文件数
0
版本
d9b4ce8
Hash
a972296d
收录时间
2026-10-18 23:22

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-10-20 19:57
浙ICP备14020137号-1