tui-td 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0be0079e98bf668b7fb089333a443ac31e36e1726254d129461e0f580ba1af9b
4
- data.tar.gz: 25b660c725c5dd79e7eada430d71a6f8711bd6c5b763bbc1069bc7fc740322e7
3
+ metadata.gz: be8d93991312317febcad6a713d4dd58e6e670e620211bae0adc0db0bdd21da1
4
+ data.tar.gz: 5618bc612addd9be3c634ebd4009800ddd6f7da9396ad5d950c8f831727398f8
5
5
  SHA512:
6
- metadata.gz: 44ab226b002b10eec8d8bc1b8f6ffb84c92a1c5ad2a6cb491d08f7cd647acec4bd024e63806b8a3f16f524c36b8a608f11b7749fedf352e75a7ed26d81803694
7
- data.tar.gz: 4ad17e1e563b1d7c8c34d098b249eb9b05fb8da335c570409dff236048a91039852f595b2a08c87e5780d6ad5b24148b9f6b999d34c2a437d1617c800a164f4b
6
+ metadata.gz: ea0199d84e4f2a038141178e6b0adc539c1547184d99b13ee270e971e7076e37d079f315b6d6fde4b880231a032f8463677ebc359e93ad5084974ca0bc6ad30d
7
+ data.tar.gz: 99e5a3c8d7bdf39ec903a7b44fb9a7d2e9c792cc405ec25b409ed8eca6f504b830b75cdf24a29271612931b79b4719f547580f61ab6f5f67aa829e74ab95bd10
data/CHANGELOG.md CHANGED
@@ -1,9 +1,37 @@
1
1
  # CHANGELOG
2
2
 
3
- ## 0.1.0 (initial)
3
+ ## 0.1.3
4
4
 
5
- - PTY-basierter TUI-Driver
6
- - ANSI-Parser (SGR Farben, Cursor, Erase, 256/Truecolor)
7
- - Strukturierter State als JSON
8
- - CLI: run, drive, capture
9
- - Screenshot-Funktion (ANSI → PNG via terminal-screenshot)
5
+ - UTF-8 multi-byte character support in ANSI parser (`_utf8_char_at`)
6
+ - `--help` is now a complete CLI reference: Examples section, interactive drive commands
7
+ - `tui-td help test` JSON test step reference with CLI and Ruby code workflow
8
+ - `tui-td help rspec` — RSpec matchers reference with Driver/State setup workflow
9
+ - `--version` flag
10
+
11
+ ## 0.1.2
12
+
13
+ - Background reader thread — continuous PTY reads prevent buffer overflow
14
+ - Thread-safe output buffer with Mutex protection
15
+ - DSR (Device Status Report) support — respond to `\e[6n` cursor position requests
16
+ - Replace `IO.select`/`readpartial` with `read_nonblock`
17
+
18
+ ## 0.1.1
19
+
20
+ - Add `--chdir` / `-C` CLI flag for running commands in a specific directory
21
+ - Default `capture` output to text instead of full JSON grid
22
+ - Use `permute!` for CLI option parsing so global flags work after the command
23
+ - Fix: skip ISO 2022 charset sequences (`ESC(B`) instead of leaking `(B` into output
24
+ - Add `raw` field to state JSON (original ANSI with all escape sequences preserved)
25
+ - Publish to rubygems.org
26
+
27
+ ## 0.1.0
28
+
29
+ - PTY-based TUI driver with `start`, `send`, `send_keys`, `wait_for_text`, `wait_for_stable`
30
+ - ANSI parser: SGR colors (16, 256, TrueColor), cursor movement, erase, scroll
31
+ - Structured state output: `{size, cursor, rows, raw}`
32
+ - CLI: `capture`, `run`, `drive`, `test`, `serve`
33
+ - Pure Ruby PNG screenshots via `chunky_png` with embedded Spleen 8×16 font
34
+ - HTML renderer with run-length encoding
35
+ - JSON test runner with 12 step types
36
+ - RSpec matchers: `have_text`, `have_fg`, `have_bg`, `have_style`
37
+ - MCP server (JSON-RPC over stdio) for AI-driven TUI testing
data/README.md CHANGED
@@ -1,13 +1,18 @@
1
1
  # TUI Test Drive
2
2
 
3
+ *Like Playwright or Puppeteer, but built specifically for Terminal UIs and optimized for AI Coding Agents.*
4
+
3
5
  Testing framework for Terminal User Interfaces (TUIs) with MCP support.
4
6
 
7
+ A Ruby library, but language-agnostic through its JSON test format and MCP server — use it from Python, JavaScript, Go, or any other programming language on Linux and macOS.
8
+
5
9
  **tui-td** lets you:
6
10
  1. Start a TUI application in a virtual terminal (PTY)
7
11
  2. See the output — as structured JSON, plain text, PNG screenshots, or HTML renders
8
12
  3. Send input — keystrokes, text, control sequences
9
13
  4. Analyze output — find text, check colors, detect cursor position
10
14
  5. Loop — adjust and retest without manual intervention
15
+ 6. Integrate — works with any language via JSON test files or MCP
11
16
 
12
17
  ## Installation
13
18
 
@@ -108,9 +113,12 @@ Global options:
108
113
  --json Output state as compact JSON (includes raw ANSI)
109
114
  --pretty Output state as pretty JSON
110
115
  --text Output state as plain text table (default)
111
- -h, --help Show help
116
+ -h, --help Show complete reference (commands, options, examples, interactive commands)
112
117
  ```
113
118
 
119
+ `tui-td --help` serves as the full CLI reference. `tui-td help test` shows all JSON test
120
+ step types and `tui-td help rspec` shows all RSpec matchers — no need to consult the docs.
121
+
114
122
  ## Ruby API
115
123
 
116
124
  ### Driver — Start, send, capture
@@ -92,6 +92,7 @@ module TUITD
92
92
  attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false }
93
93
  saved_cursor = nil
94
94
  scroll_region = nil
95
+ pending_dsr = false
95
96
 
96
97
  # Strip everything before the last full clear (if any)
97
98
  # to avoid accumulated garbage
@@ -102,10 +103,11 @@ module TUITD
102
103
  if processed[i] == "\e" && processed[i + 1] == "["
103
104
  # Find end of CSI sequence
104
105
  j = i + 2
105
- j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fm]/)
106
+ j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fmnR]/)
106
107
  seq = processed[i..j]
107
108
 
108
- _apply_csi(seq, cursor, attrs, grid, rows, cols)
109
+ dsr = _apply_csi(seq, cursor, attrs, grid, rows, cols)
110
+ pending_dsr ||= dsr
109
111
 
110
112
  i = j + 1
111
113
  elsif processed[i] == "\n" || processed[i] == "\r\n"
@@ -135,16 +137,16 @@ module TUITD
135
137
  else
136
138
  i += 1
137
139
  end
138
- elsif processed[i] =~ /[[:print:]]/
139
- # Printable character
140
+ elsif (char, char_len = _utf8_char_at(processed, i))
141
+ # Printable character (including multi-byte UTF-8)
140
142
  if cursor[:row] < rows && cursor[:col] < cols
141
143
  cell = grid[cursor[:row]][cursor[:col]]
142
- cell[:char] = processed[i]
144
+ cell[:char] = char
143
145
  cell.merge!(attrs)
144
146
  cursor[:col] += 1
145
147
  cursor[:col] = cols - 1 if cursor[:col] >= cols
146
148
  end
147
- i += 1
149
+ i += char_len
148
150
  else
149
151
  i += 1
150
152
  end
@@ -164,6 +166,7 @@ module TUITD
164
166
  size: { rows: rows, cols: cols },
165
167
  cursor: cursor,
166
168
  rows: grid,
169
+ pending_dsr: pending_dsr,
167
170
  }
168
171
  end
169
172
 
@@ -211,7 +214,7 @@ module TUITD
211
214
  def self._apply_csi(seq, cursor, attrs, grid, rows, cols)
212
215
  # Strip leading escape char if present
213
216
  cleaned = seq.sub(/^\e/, "")
214
- match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhm])$/)
217
+ match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhmnR])$/)
215
218
  return unless match
216
219
 
217
220
  params = match[1].split(";").map(&:to_i)
@@ -267,6 +270,11 @@ module TUITD
267
270
  next unless cursor[:row] < rows && cursor[:col] + i < cols
268
271
  grid[cursor[:row]][cursor[:col] + i][:char] = " "
269
272
  end
273
+ when "n" # DSR — Device Status Report request
274
+ # \e[6n = request cursor position → caller must respond with \e[row;colR
275
+ return params[0] == 6
276
+ when "R" # DSR response (from terminal side) or CPR — ignore
277
+ nil
270
278
  end
271
279
  end
272
280
 
@@ -401,5 +409,38 @@ module TUITD
401
409
  nil
402
410
  end
403
411
  end
412
+
413
+ # Extract a single UTF-8 character at position i in a binary string.
414
+ # Returns [char_string, byte_length] or nil if the byte is not printable/valid.
415
+ def self._utf8_char_at(str, i)
416
+ byte = str.getbyte(i)
417
+ return nil unless byte
418
+
419
+ if byte < 0x80
420
+ # Single-byte ASCII
421
+ return nil unless byte >= 0x20 # only printable, skip control chars
422
+ return [byte.chr, 1]
423
+ end
424
+
425
+ # Multi-byte UTF-8
426
+ len = if byte & 0xE0 == 0xC0
427
+ 2
428
+ elsif byte & 0xF0 == 0xE0
429
+ 3
430
+ elsif byte & 0xF8 == 0xF0
431
+ 4
432
+ else
433
+ return nil # continuation byte or invalid — let main loop advance
434
+ end
435
+ return nil if i + len > str.bytesize
436
+
437
+ bytes = str.byteslice(i, len)
438
+ char = bytes.dup.force_encoding("UTF-8")
439
+ return nil unless char.valid_encoding?
440
+
441
+ [char, len]
442
+ rescue StandardError
443
+ nil
444
+ end
404
445
  end
405
446
  end
data/lib/tui_td/cli.rb CHANGED
@@ -18,12 +18,29 @@ module TUITD
18
18
  opts.banner = "Usage: tui-td <command> [options]"
19
19
  opts.separator ""
20
20
  opts.separator "Commands:"
21
- opts.separator " serve Start MCP server (JSON-RPC over stdio)"
22
- opts.separator " run <command> Run a TUI app and show live output"
23
- opts.separator " drive <command> Drive a TUI with structured state output"
21
+ opts.separator " serve Start MCP server (JSON-RPC over stdio)"
24
22
  opts.separator " capture <command> Run once, capture and display state"
25
- opts.separator " test <file> Run JSON test file"
26
- opts.separator " help Show this help"
23
+ opts.separator " drive <command> Drive a TUI interactively"
24
+ opts.separator " run <command> Run a TUI app and show live output"
25
+ opts.separator " test <file.json> Run JSON test file"
26
+ opts.separator " help [topic] Show this help, or help test / help rspec"
27
+ opts.separator ""
28
+ opts.separator "Examples:"
29
+ opts.separator " tui-td capture \"ls -la\""
30
+ opts.separator " tui-td --screenshot out.png capture \"htop\" --timeout 5"
31
+ opts.separator " tui-td --html out.html capture \"glow README.md\""
32
+ opts.separator " tui-td -C /my/project capture \"make test\""
33
+ opts.separator " tui-td drive \"vim file.txt\" --rows 24 --cols 80"
34
+ opts.separator " tui-td test examples/echo_test.json"
35
+ opts.separator " tui-td serve"
36
+ opts.separator ""
37
+ opts.separator "Interactive commands (drive mode):"
38
+ opts.separator " state Show terminal state as pretty JSON"
39
+ opts.separator " raw Show raw ANSI output"
40
+ opts.separator " key <name> Send keystroke (enter, tab, escape, up, down, left, right,"
41
+ opts.separator " backspace, ctrl_c, ctrl_d)"
42
+ opts.separator " <text> Send text to the TUI"
43
+ opts.separator " exit Quit drive mode"
27
44
  opts.separator ""
28
45
  opts.separator "Global options:"
29
46
 
@@ -54,6 +71,10 @@ module TUITD
54
71
  opts.on("--text", "Output state as plain text table") do |_|
55
72
  global_opts[:format] = :text
56
73
  end
74
+ opts.on("--version", "Show version") do
75
+ puts "tui-td #{TUITD::VERSION}"
76
+ exit 0
77
+ end
57
78
  opts.on("-h", "--help", "Show help") do
58
79
  puts opts
59
80
  exit 0
@@ -74,9 +95,20 @@ module TUITD
74
95
  cmd_capture(command_opts, global_opts)
75
96
  when "test"
76
97
  cmd_test(command_opts, global_opts)
77
- when nil, "help"
78
- puts OptionParser.new { |o| o.banner = "Usage: tui-td <command> [options]" }
79
- exit 0
98
+ when "help"
99
+ topic = argv.shift
100
+ case topic
101
+ when "test"
102
+ _help_test
103
+ when "rspec"
104
+ _help_rspec
105
+ when nil
106
+ _help_main
107
+ else
108
+ abort "Unknown help topic: #{topic.inspect}\nTry: tui-td help test, tui-td help rspec"
109
+ end
110
+ when nil
111
+ _help_main
80
112
  else
81
113
  abort "Unknown command: #{command.inspect}\nUse tui-td --help for usage"
82
114
  end
@@ -228,5 +260,130 @@ module TUITD
228
260
  puts line.empty? ? "~" : line
229
261
  end
230
262
  end
263
+
264
+ def _help_main
265
+ puts OptionParser.new { |o| o.banner = "Usage: tui-td <command> [options]" }
266
+ puts
267
+ puts "For more: tui-td help test (JSON test step types)"
268
+ puts " tui-td help rspec (RSpec matchers)"
269
+ exit 0
270
+ end
271
+
272
+ def _help_test
273
+ puts <<~HELP
274
+ JSON Test Format
275
+ ================
276
+
277
+ Run from CLI:
278
+
279
+ tui-td test examples/echo_test.json
280
+
281
+ Or from Ruby code:
282
+
283
+ require "tui_td/test_runner"
284
+ runner = TUITD::TestRunner.new(name: "my test", steps: [...])
285
+ result = runner.run # => { passed: true, results: [...] }
286
+
287
+ A test is a Hash or JSON string: {"name": "...", "steps": [...]}
288
+
289
+ Top-level keys: name, steps, rows (default 40), cols (default 120),
290
+ timeout (default 30), chdir
291
+
292
+ Each step is an object with a single action key:
293
+
294
+ {"start": "<command>"}
295
+ Start a TUI process in a PTY.
296
+
297
+ {"send": "<text>"}
298
+ Send text to the TUI. Use "\\n" for Enter.
299
+
300
+ {"send_key": "<name>"}
301
+ Send a keystroke. Names: enter, tab, escape, up, down,
302
+ left, right, backspace, ctrl_c, ctrl_d.
303
+
304
+ {"wait_for_text": "<substring>"}
305
+ Wait until the given text appears in the output.
306
+
307
+ {"wait_for_stable": true}
308
+ Wait until the output stops changing (default 300ms quiet).
309
+
310
+ {"assert_text": "<substring>"}
311
+ Fail if the text is not found in the current state.
312
+
313
+ {"assert_fg": [row, col], "is": "<color>"}
314
+ Assert foreground color at cell. Colors: "default",
315
+ named ANSI (red, green, blue, cyan, ...), "bright_*",
316
+ "color<N>" (256-color), "#rrggbb" (TrueColor).
317
+
318
+ {"assert_bg": [row, col], "is": "<color>"}
319
+ Assert background color at cell. Same color format.
320
+
321
+ {"assert_style": [row, col], "bold": true, "italic": false, ...}
322
+ Assert style attributes at cell. Checks only the keys provided.
323
+
324
+ {"screenshot": "<path>"}
325
+ Save a PNG screenshot. Path defaults to /tmp/tui_td_<ts>.png.
326
+
327
+ {"html": "<path>"}
328
+ Save an HTML render. Path defaults to /tmp/tui_td_<ts>.html.
329
+
330
+ {"close": true}
331
+ Close the driver session.
332
+
333
+ Example test file: examples/echo_test.json
334
+ HELP
335
+ exit 0
336
+ end
337
+
338
+ def _help_rspec
339
+ puts <<~HELP
340
+ RSpec Matchers
341
+ ==============
342
+
343
+ Matchers work on TUITD::State objects, not raw output.
344
+ Get a State from the Driver:
345
+
346
+ require "tui_td"
347
+ require "tui_td/matchers"
348
+
349
+ driver = TUITD::Driver.new("my_tui", rows: 24, cols: 80)
350
+ driver.start
351
+ state = TUITD::State.new(driver.state_data)
352
+
353
+ expect(state).to have_text("Hello")
354
+ expect(state).to have_fg("red").at(0, 5)
355
+
356
+ driver.close
357
+
358
+ Or build a State manually for unit tests:
359
+
360
+ state = TUITD::State.new(
361
+ size: { rows: 5, cols: 20 },
362
+ cursor: { row: 0, col: 0 },
363
+ rows: [[{ char: "H", fg: "default", bg: "default",
364
+ bold: false, italic: false, underline: false }]]
365
+ )
366
+
367
+ Matchers
368
+ --------
369
+
370
+ have_text(expected)
371
+ Passes if expected text appears anywhere in the terminal state.
372
+ Usage: expect(state).to have_text("Hello")
373
+
374
+ have_fg(expected).at(row, col)
375
+ Assert foreground color at [row, col] matches expected.
376
+ Usage: expect(state).to have_fg("red").at(0, 5)
377
+
378
+ have_bg(expected).at(row, col)
379
+ Assert background color at [row, col] matches expected.
380
+ Usage: expect(state).to have_bg("default").at(0, 0)
381
+
382
+ have_style.at(row, col).with(bold: true, italic: false, ...)
383
+ Assert style attributes at [row, col] match the given hash.
384
+ Usage: expect(state).to have_style.at(0, 0).with(bold: true)
385
+ HELP
386
+ exit 0
387
+ end
231
388
  end
232
389
  end
data/lib/tui_td/driver.rb CHANGED
@@ -30,6 +30,9 @@ module TUITD
30
30
  @stdout = nil
31
31
  @wait_thr = nil
32
32
  @output_buffer = +""
33
+ @output_mutex = Mutex.new
34
+ @reader_thread = nil
35
+ @reader_running = false
33
36
  end
34
37
 
35
38
  # Start the TUI application in a PTY
@@ -46,6 +49,8 @@ module TUITD
46
49
  wait_for_stable
47
50
  refresh_state!
48
51
 
52
+ _start_reader_thread
53
+
49
54
  true
50
55
  end
51
56
 
@@ -81,7 +86,8 @@ module TUITD
81
86
  loop do
82
87
  raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
83
88
  read_available!
84
- break if @output_buffer.include?(text)
89
+ found = @output_mutex.synchronize { @output_buffer.include?(text) }
90
+ break if found
85
91
  sleep 0.05
86
92
  end
87
93
  refresh_state!
@@ -114,7 +120,7 @@ module TUITD
114
120
  # Get the terminal output (raw ANSI + text)
115
121
  def raw_output
116
122
  read_available!
117
- @output_buffer
123
+ @output_mutex.synchronize { @output_buffer.dup }
118
124
  end
119
125
 
120
126
  # Get structured terminal state as a Hash
@@ -137,6 +143,8 @@ module TUITD
137
143
 
138
144
  # Close the driver and clean up
139
145
  def close
146
+ _stop_reader_thread
147
+
140
148
  # Kill the process if still running
141
149
  if @pid
142
150
  begin
@@ -156,6 +164,30 @@ module TUITD
156
164
 
157
165
  private
158
166
 
167
+ def _start_reader_thread
168
+ @reader_running = true
169
+ @reader_thread = Thread.new do
170
+ loop do
171
+ break unless @reader_running
172
+ begin
173
+ read_available!
174
+ rescue IOError, Errno::EIO
175
+ break
176
+ end
177
+ sleep 0.05
178
+ end
179
+ end
180
+ end
181
+
182
+ def _stop_reader_thread
183
+ @reader_running = false
184
+ if @reader_thread
185
+ @reader_thread.join(1)
186
+ @reader_thread.kill rescue nil
187
+ @reader_thread = nil
188
+ end
189
+ end
190
+
159
191
  def ensure_running!
160
192
  raise Error, "Driver not started. Call #start first." if @stdin.nil?
161
193
  raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
@@ -164,19 +196,35 @@ module TUITD
164
196
  def read_available!
165
197
  return false unless @stdout
166
198
 
167
- ready, = IO.select([@stdout], nil, nil, 0.01)
168
- return false unless ready
199
+ data = @stdout.read_nonblock(4096)
200
+
201
+ @output_mutex.synchronize { @output_buffer << data }
202
+
203
+ respond_to_dsr if data.include?("\e[6n")
169
204
 
170
- data = @stdout.readpartial(4096)
171
- @output_buffer << data
172
205
  true
173
- rescue EOFError
206
+ rescue IO::WaitReadable, EOFError
174
207
  false
175
208
  end
176
209
 
210
+ def respond_to_dsr
211
+ @output_mutex.synchronize do
212
+ @state = ANSIParser.parse(@output_buffer, @rows, @cols)
213
+ @state[:raw] = @output_buffer.dup
214
+ @output_buffer.gsub!("\e[6n", "")
215
+
216
+ cursor = @state[:cursor]
217
+ response = "\e[#{cursor[:row] + 1};#{cursor[:col] + 1}R"
218
+ @stdin&.print(response)
219
+ @stdin&.flush
220
+ end
221
+ end
222
+
177
223
  def refresh_state!
178
- @state = ANSIParser.parse(@output_buffer, @rows, @cols)
179
- @state[:raw] = @output_buffer.dup
224
+ @output_mutex.synchronize do
225
+ @state = ANSIParser.parse(@output_buffer, @rows, @cols)
226
+ @state[:raw] = @output_buffer.dup
227
+ end
180
228
  end
181
229
 
182
230
  def monotonic
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tui-td
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -155,5 +155,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
155
155
  requirements: []
156
156
  rubygems_version: 4.0.6
157
157
  specification_version: 4
158
- summary: TUI (Terminal User Interface) testing framework with MCP support
158
+ summary: TUI testing framework language-agnostic via JSON tests and MCP
159
159
  test_files: []