自戒、点検、内省

終わらない反省会をしよう

Ruby作った対話型コンソールアプリにE2Eテストを仕込む調べ物

Reline ベースの 自作のTUIライブラリ baslash を作っています。

github.com

これに E2E テストを仕込みたかったのですが、printf '/help\n/exit\n' | bundle exec ruby ... のパイプ smoke では Tab 補完も Ctrl-C も OSC 0 のタイトルバーも何ひとつ検証できない、という壁にぶつかりました。

stateful なコンソールアプリの E2E は、one-shot CLI のテストとはまったく別ジャンルを感じました。

ひとしきり周辺をみつつ、最終的に自作の ptyblues という仕組みで録っています。

github.com

ここではなぜそうしたか、何を調べたのか書き残しておきます。

stateful なやつは何が違うか

one-shot CLI なら、入力は argv、出力は stdout、終了は exit status の3点で完結します。テストは引数を渡して標準出力を assert するだけです。

stateful なやつ、つまり REPL、shell、curses TUI、最近の AI コーディングエージェントみたいなのは、ここに次の5要素が加わります。

  1. PTY allocation — Reline はじめ多くの行編集ライブラリは isatty を要求するので、パイプでは raw mode に入れません。
  2. ANSI escape の解釈 — OSC 0 でタイトルバーを書く、CSI で色やカーソル移動を出す、alt-screen 切替を使う、といった出力を読み取る側でちゃんと解釈する必要があります。
  3. timing — プロンプトの到着を待ち、ハンドラ完了を待ち、画面更新を待つ制御が求められます。
  4. screen state — 行頭、カーソル位置、wrap、属性 (色、太字) を含んだ可視状態のことです。
  5. 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 の組み合わせを賢く接着した設計が強みに感じました。

  1. React reconciler を再利用 — React DOM や React Native も使っているものがパッケージとしてnpm切り出しされており、「任意の host を作れる」設計になっています。Ink は ink-root ink-box ink-text といった独自 element の virtual tree を作っています。
  2. Yoga で flexbox レイアウト計算 — Meta (旧 Facebook) が C++ で書いた flexbox エンジンの Yoga が、React Native でも使われている同じ実装としてターミナル上の文字グリッドにも適用されています。
  3. 文字グリッド生成 — レイアウト結果を walk して、ターミナル上の cell grid (文字 × スタイル) を作ります。
  4. 前フレームとの 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 でドッグフーディングしながら、抽象化のラインを見極めようと思います。

スタックチャン ピコルビーをつくりはじめています #スタックチャン #picoruby

M5Stack の Stack-chanを買いました。

docs.m5stack.com

M5 スタックチャン AIデスクトップロボット(ESP32-S3搭載)www.switch-science.com

そしてスタックチャン ピコルビーとしてPicoRuby+ESP32のR2P2で書く試みをしています。Rubyist、Rubyで作りたがりがちないつものです。デブサミ2026の懇親会で @meganetaaan にみせてもらったとき、PicoRubyをやってることをお話ししたのもひとつのきっかけです。

リポジトリ

github.com

システム構成は、CoreS3(R2P2-ESP32 + PicoRuby)と Mac(Ruby + CoreBluetooth)の組み合わせです。

現状では、CoreS3 上で BLE Nordic UART Service を立ち上げ、Mac から Ruby のクライアント(CoreBluetooth 経由)を使って、顔の表情と LEDを制御できるところまで完成しました。

表情は Neutral、Smile、Joy、Surprised の 4 種類をつくっていて、LED については、色に加えて「solid / blink / breathing / off」の発光モードを、左右個別、または両側同時に指定できるようにしました。また、heartbeat確認としてまばたきのアニメーションも取り入れています。

この仕組みを動かすため、いったん当repoのサブディレクトリ内にLI9342、py32のmrbgemsを作っています。

また、BLE対応は picoruby orgからのフォークで作業中です。具体的には、CoreS3 用の sdkconfig fragment と BLE 有効化を追加した「R2P2-ESP32」と、picoruby-ble の ESP32 Portingを追加した「PicoRuby」を組み合わせています。も少し実績つんで、コードを磨いたら、upstreamにもPull Requestします。

github.com

IMU、サーボ、TTS、マイク、カメラ、タッチ、AI連携と大事なものが全然まだまだなので、少しずつつくってカワイイをたのしんでいこうと思います。

RubyからMacでBluetooth Low Energyを使うライブラリを #rubykaigi effect でつくった

#rubykaigi effectの続きです

https://retrospective.hatenadiary.com/entry/2026/05/02/114306:embed:site

前作で、Ruby Swift extension を書く土台ができたところまで書きました。あの土台を使って、Mac 上で BLE デバイスを触るための gem を作りました。

https://github.com/bash0C7/rb-corebluetooth-mac:embed:site

rb-corebluetooth-mac という名前で、Apple の CoreBluetooth framework を Swift 経由で Ruby から呼べるようにしたものです。macOS 13 以降、Ruby 3.2 以降で動きます。BLE の Central 役、つまりラップトップやスマホ側として周辺機器を見つけて繋ぐ用途に絞っています。

なんで作ったか

スタックチャンのPicoRuby実装を作っています。BLEで手持ちmacと気軽に通信したくてその部分を先行させているのですが、手元の Mac で BLE デバイスに気軽にアクセスできる方法を探すと、WebBluetooth のある Chrome ぐらいしか見当たりませんでした。

ブラウザで触れるのはありがたいのですが、わたしは Ruby のスクリプトから直接スキャンして、ペリフェラルに繋いで、Characteristic を読み書きする、という流れを完結させたかったのです。

これに適したgemが自分の探索力ではみあたらなかったので Core Bluetooth | Apple Developer Documentation をRubyから呼び出すことにしました。

使い方の概観

基本的な流れは、scan → connect → discover → read / write / subscribe です。

require "corebluetooth_mac"

central = CoreBluetoothMac::Central.new(state_timeout: 5.0)
devices = central.scan(name: "YourDeviceName", timeout: 5.0)
peripheral = central.connect(devices.first, timeout: 5.0)

peripheral.discover_services
gap = peripheral.find_service("1800")
gap.discover_characteristics
ch = gap.find_characteristic("2a00")
puts ch.read.force_encoding("UTF-8")

Notify を購読する Characteristic は subscribeSubscription オブジェクトを返します。これが Ractor-shareable で、ポーリングのループを Ractor の中に置いてフレームを main thread に流す型がそのまま書けます。

sub = tx.subscribe
pump = Ractor.new(sub) do |s|
  while (v = s.next_value(timeout: 5.0))
    Ractor.yield v
  end
end
5.times { puts pump.take.inspect }

エラーは CoreBluetoothMac::Error の1クラスにまとめて、#domain シンボル (:timeout / :closed / :connection / :discovery / :validation / :cb / :att) で振り分ける形にしました。このあたり別サブクラスにすべきか悩んだのですが、まずは例外でたらとにかくなんとかする!というかたちでこうしました。

examples/ の下に rake examples:scan から rake examples:subscribe まで 9 個のデモタスクを置いてあるので、手元の BLE デバイスを指定して動かせる状態です。

現在

スタックチャンのPicoRuby実装に供して実地で不足やバグを見つけ出そうとしているところです。

なお、Peripheral 役 (CBPeripheralManager) には今のところ対応していません。Central 側の作業で当面は事足りているのと、Peripheral 側まで広げると advertise 周りの設計が別物になるので、一旦はスコープを区切っています。