Reline ベースの 自作のTUIライブラリ baslash を作っています。
これに E2E テストを仕込みたかったのですが、printf '/help\n/exit\n' | bundle exec ruby ... のパイプ smoke では Tab 補完も Ctrl-C も OSC 0 のタイトルバーも何ひとつ検証できない、という壁にぶつかりました。
stateful なコンソールアプリの E2E は、one-shot CLI のテストとはまったく別ジャンルを感じました。
ひとしきり周辺をみつつ、最終的に自作の ptyblues という仕組みで録っています。
ここではなぜそうしたか、何を調べたのか書き残しておきます。
stateful なやつは何が違うか
one-shot CLI なら、入力は argv、出力は stdout、終了は exit status の3点で完結します。テストは引数を渡して標準出力を assert するだけです。
stateful なやつ、つまり REPL、shell、curses TUI、最近の AI コーディングエージェントみたいなのは、ここに次の5要素が加わります。
- PTY allocation — Reline はじめ多くの行編集ライブラリは isatty を要求するので、パイプでは raw mode に入れません。
- ANSI escape の解釈 — OSC 0 でタイトルバーを書く、CSI で色やカーソル移動を出す、alt-screen 切替を使う、といった出力を読み取る側でちゃんと解釈する必要があります。
- timing — プロンプトの到着を待ち、ハンドラ完了を待ち、画面更新を待つ制御が求められます。
- screen state — 行頭、カーソル位置、wrap、属性 (色、太字) を含んだ可視状態のことです。
- exit status と signal — Ctrl-C の挙動なども含みます。
このどれが欠けても、対話型アプリの「ユーザーから見た振る舞い」を検証したことにはならない、というところです。
Node センパイはどうしてる
最近の AI コーディングエージェント系の対話型 CLI は Node / TypeScript で書かれているものが目立ちます。Claude Code、Gemini CLI、Ink で書かれた多数の OSS ツール、というあたりです。
Node 側で対話型 CLI の E2E に使われる道具は大きく2系統に分かれているように見えます。
PTY を本物として扱う系 — node-pty
Microsoft 主導の Node 用 PTY バインディングです。VS Code の統合ターミナルが内部で使っていることでも知られています。spawn(cmd, args, { cols, rows }) で疑似端末を取得し、onData で stream を受けて write で送ります。screen 状態の構造化解析は xterm.js を headless で組み合わせるのが定石、というところです。
https://github.com/microsoft/node-pty
TTY を経由しない component test 系 — ink-testing-library
ink は React for CLI で、ink-testing-library は ink で書かれたコンポーネントを in-memory に文字列としてレンダーする仕組みです。render(<App />) のあとに lastFrame() で snapshot 文字列が手に入って、rerender() で props 変更、stdin.write() で入力をシミュレートできます。
real TTY に繋がないので速いし CI で気軽に回せます。ただし ink で書かれたアプリ限定で、Reline ベースの REPL や curses TUI には適用できなさそうに思いました。
https://github.com/vadimdemedes/ink-testing-library
Ink の中身が気になる
ところで ink-testing-library が TTY なしで動くのって、いったいどうやってるのか、というのが気になりました。
調べたところ、React + Yoga の組み合わせを賢く接着した設計が強みに感じました。
- React reconciler を再利用 — React DOM や React Native も使っているものがパッケージとしてnpm切り出しされており、「任意の host を作れる」設計になっています。Ink は ink-root ink-box ink-text といった独自 element の virtual tree を作っています。
- Yoga で flexbox レイアウト計算 — Meta (旧 Facebook) が C++ で書いた flexbox エンジンの Yoga が、React Native でも使われている同じ実装としてターミナル上の文字グリッドにも適用されています。
- 文字グリッド生成 — レイアウト結果を walk して、ターミナル上の cell grid (文字 × スタイル) を作ります。
- 前フレームとの diff を ANSI escape で出力 — 変わったセルだけ ANSI escape sequence で stdout に書きます。
描画 layer が「virtual tree → 文字グリッド → ANSI 文字列」という pure な変換になっていて、tcsetattr の raw mode 切替やカーソル位置クエリに依存していません。最初から TTY 非依存に作ってある、というのが効いている、と理解しています。
Ruby 側で同じことをやろうとすると、react-reconciler 相当のホスト非依存 reconciler、Yoga binding、文字グリッド diff renderer、を全部地盤から立てる必要があります。React + Yoga という二つの大きな基盤の存在の強みを感じます。
Ruby 側の選択肢
Ruby 側で対話型 CLI のテストに使えるものを並べると、こう表現できるかなと思います。
|
ライブラリ |
立ち位置 |
|
標準 PTY モジュール |
PTY.spawn で生の PTY を取って puts/gets でやりとり。プリミティブ。 |
|
expect |
トラディショナルなラッパー。https://docs.ruby-lang.org/ja/latest/method/IO/i/expect.html |
|
Aruba |
Cucumber/RSpec/Minitest 統合の CLI テスト framework。対話型はサポートあるが PTY 直接の扱いは限定的。 |
|
cli-test |
軽量 CLI スクリプトテスト。 |
|
ttytest2 |
tmux pane 経由で shell/cli を走らせて行ベース assertion を行うツール。 |
ink-testing-library 相当の「TTY を経由しない component test」は Ruby 側には存在しません。Reline は raw mode を要求する line editor なので、そもそも in-memory 化が成立しない、というところです。
ttytest2 はどうだったのか
先行実装gemとして ttytest2 があります。tmux pane を通して cli アプリを走らせ、assert_row_like、assert_row_starts_with のような行ベース assertion を retry-with-timeout で実行する設計で、Docker と相性もよく、よくできています。
https://github.com/a-eski/ttytest2:embed:site
もし baslash の E2E 要件が「対話型 CLI を spawn して、送って、行 assertion して、終了確認する」だったら、ttytest2 がベストチョイスだったと思います。
今回はちょっと特殊なことをしたかったんですね。
|
観点 |
ttytest2 |
ptyblues |
|
外部プロセス依存 |
tmux 必須 |
なし |
|
ドライバ層 |
tmux pane |
Ruby stdlib PTY モジュール |
|
screen state 取得 |
tmux pane capture (プロセス越え) |
VT100 emulator (TermSim) をライブラリ内蔵 |
|
検証スタイル |
in-process、retry-with-timeout の同期 assertion |
録画資産に対する後付け DSL 評価 |
|
永続化 |
なし |
SQLite に bytes stream + 時刻 + メタを永続化 |
|
再生 / viewer |
なし |
asciicast / gif / mp4 / webm 出力と時系列再生 |
|
検索 |
なし |
sqlite-vec + informers で録画資産に対する semantic search |
|
分散 |
なし |
DRuby で別マシン / 別プロセスから録画起動 |
baslash の hotkey E2E では、CI で失敗したセッションを後でローカル ptyblues-viewer から再生したい、tmux は普段使ってないから依存から外したいというところです。
また実装を通じて学びたい気持ちもありました。
ptyblues の置き場所
ptyblues は「ターミナル session の VCR」というポジションです。HTTP の世界で VCR gem が API のやりとりを cassette ファイルに録画してリプレイ・検証するのに対して、ptyblues は実 PTY セッションを SQLite に録画して、後から DSL で検証する、という対応関係になっています。
外部プロセス依存はなく、Ruby gem と stdlib だけで完結します。
|
Sub-gem |
役割 |
|
ptyblues (core) |
SQLite ストレージ層、ONNX embedder。extralite、sqlite-vec、informers、drb に依存。 |
|
ptyblues-record |
PTY 起動、Ractor の frame パイプライン、Unix socket daemon。 |
|
ptyblues-inspect |
spec DSL、Captured 検索 API、VT100 サブセット emulator (TermSim)。 |
|
ptyblues-viewer |
session 一覧、frame inspect、hex dump、SQL、semantic search、asciicast/gif/mp4/webm export。 |
|
ptyblues-client-cli / client-druby / client-mcp |
起動 client。CLI、DRuby、MCP。 |
「録画 + 永続化 + 後付け DSL 検証」というカテゴリ自体、Ruby 生態系にこれまでなかったところに置きにいっています。
これから
ptyblues は名前に "pty" が入っていますが、core は「ターミナル session の bytes stream + 時刻 + メタを SQLite に録って後で分析する」というところで、PTY でなくてもいいっちゃいいのです。
そのうち、record-side の source 層を切り出して、PTY 以外も受けられるようにしたいと考えていますが、まずは baslash の E2E でドッグフーディングしながら、抽象化のラインを見極めようと思います。
