tui-td 0.1.2 → 0.2.0

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: b759a458f409fc1596611c306a59a14e04be1842b6f44fff4e45eebd61f6b673
4
- data.tar.gz: deb8ebbd1e4804f7874f3af677969e333cda8321fb935a287789fd166ab20a29
3
+ metadata.gz: b8ebaa8989019a8cd573f682a538ca76a21a17daee810f29a1f178f8f35f0008
4
+ data.tar.gz: 52fecfc7d59143648f450e71612ac7b7b6839e1b697d03056cb80e0a0dd067ac
5
5
  SHA512:
6
- metadata.gz: ecb87f37f0af8ec853a37465aa822b4c547b389ef064579243522429c48e885b56d7fdf9d701cc7e5e08bed1adeb5d0a7a035301351531fe68806b24d15f9923
7
- data.tar.gz: 53fe8549f0a04d5bf403dda60ab3016e7be26f817453693f6fda68288a926bf89bdd6a86dc858e624a5f4e7ed618d1c8adc1b1e545dedf9fdf2d0926bcfa2543
6
+ metadata.gz: 9563af163f07bbaae4ea0427120785cc814c444289f7a6bc75b527d9a2e2a5a29785b99f40fa7c31c807c10708693f7f1a5cb5d9c39fea40b82a31e8d8c56964
7
+ data.tar.gz: 6912a0399394405e8678e33b13b1c95a7575ce5ef7f7731c23a514d2f55c720724c1f4d17bcb73be654a58461bade34d03adfcfd7e615509d1244c53ac8a8673
data/CHANGELOG.md CHANGED
@@ -1,9 +1,45 @@
1
1
  # CHANGELOG
2
2
 
3
- ## 0.1.0 (initial)
3
+ ## 0.2.0
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
+ - Live debug modes for `tui-td test`: `-v` (verbose), `-l` (live screen-refresh), `-s` (step-by-step pause)
6
+ - Fix: skip DEC private mode sequences (`\e[?1049h` alternate screen, `\e[?25h` cursor visibility, etc.)
7
+ - TestRunner `on_step` callback API for programmatic step-by-step observation
8
+ - Vim interaction test example: type, yank, paste, substitute
9
+ - Removed aruba dev dependency from Gemfile
10
+
11
+ ## 0.1.3
12
+
13
+ - UTF-8 multi-byte character support in ANSI parser (`_utf8_char_at`)
14
+ - `--help` is now a complete CLI reference: Examples section, interactive drive commands
15
+ - `tui-td help test` — JSON test step reference with CLI and Ruby code workflow
16
+ - `tui-td help rspec` — RSpec matchers reference with Driver/State setup workflow
17
+ - `--version` flag
18
+
19
+ ## 0.1.2
20
+
21
+ - Background reader thread — continuous PTY reads prevent buffer overflow
22
+ - Thread-safe output buffer with Mutex protection
23
+ - DSR (Device Status Report) support — respond to `\e[6n` cursor position requests
24
+ - Replace `IO.select`/`readpartial` with `read_nonblock`
25
+
26
+ ## 0.1.1
27
+
28
+ - Add `--chdir` / `-C` CLI flag for running commands in a specific directory
29
+ - Default `capture` output to text instead of full JSON grid
30
+ - Use `permute!` for CLI option parsing so global flags work after the command
31
+ - Fix: skip ISO 2022 charset sequences (`ESC(B`) instead of leaking `(B` into output
32
+ - Add `raw` field to state JSON (original ANSI with all escape sequences preserved)
33
+ - Publish to rubygems.org
34
+
35
+ ## 0.1.0
36
+
37
+ - PTY-based TUI driver with `start`, `send`, `send_keys`, `wait_for_text`, `wait_for_stable`
38
+ - ANSI parser: SGR colors (16, 256, TrueColor), cursor movement, erase, scroll
39
+ - Structured state output: `{size, cursor, rows, raw}`
40
+ - CLI: `capture`, `run`, `drive`, `test`, `serve`
41
+ - Pure Ruby PNG screenshots via `chunky_png` with embedded Spleen 8×16 font
42
+ - HTML renderer with run-length encoding
43
+ - JSON test runner with 12 step types
44
+ - RSpec matchers: `have_text`, `have_fg`, `have_bg`, `have_style`
45
+ - MCP server (JSON-RPC over stdio) for AI-driven TUI testing
data/README.md CHANGED
@@ -98,24 +98,50 @@ Usage: tui-td <command> [options]
98
98
 
99
99
  Commands:
100
100
  serve Start MCP server (JSON-RPC over stdio)
101
- test <file> Run JSON test file
102
- run <command> Run a TUI app and show live output
103
- drive <command> Drive a TUI with structured state output
104
101
  capture <command> Run once, capture and display state
102
+ drive <command> Drive a TUI interactively
103
+ run <command> Run a TUI app and show live output
104
+ test <file.json> Run JSON test file
105
+ help [topic] Show this help, or help test / help rspec
106
+
107
+ Examples:
108
+ tui-td capture "ls -la"
109
+ tui-td --screenshot out.png capture "htop" --timeout 5
110
+ tui-td --html out.html capture "glow README.md"
111
+ tui-td -C /my/project capture "make test"
112
+ tui-td drive "vim file.txt" --rows 24 --cols 80
113
+ tui-td test examples/echo_test.json
114
+ tui-td -vl test examples/vim_hello_world.json
115
+ tui-td serve
116
+
117
+ Interactive commands (drive mode):
118
+ state Show terminal state as pretty JSON
119
+ raw Show raw ANSI output
120
+ key <name> Send keystroke (enter, tab, escape, up, down, left, right,
121
+ backspace, ctrl_c, ctrl_d)
122
+ <text> Send text to the TUI
123
+ exit Quit drive mode
105
124
 
106
125
  Global options:
107
126
  -r, --rows N Terminal rows (default: 40)
108
127
  -c, --cols N Terminal cols (default: 120)
109
- -C, --chdir PATH Working directory for the command
110
128
  -t, --timeout N Timeout in seconds (default: 30)
129
+ -C, --chdir PATH Working directory for the command
111
130
  --screenshot PATH Save PNG screenshot
112
131
  --html PATH Save HTML render for browser viewing
113
132
  --json Output state as compact JSON (includes raw ANSI)
114
133
  --pretty Output state as pretty JSON
115
- --text Output state as plain text table (default)
116
- -h, --help Show help
134
+ --text Output state as plain text table
135
+ -v, --verbose Show each test step as it runs
136
+ -l, --live Show terminal state after each test step (screen-refresh)
137
+ -s, --step Pause after each test step for confirmation
138
+ --version Show version
139
+ -h, --help Show complete reference
117
140
  ```
118
141
 
142
+ `tui-td --help` serves as the full CLI reference. `tui-td help test` shows all JSON test
143
+ step types and `tui-td help rspec` shows all RSpec matchers — no need to consult the docs.
144
+
119
145
  ## Ruby API
120
146
 
121
147
  ### Driver — Start, send, capture
@@ -103,7 +103,7 @@ module TUITD
103
103
  if processed[i] == "\e" && processed[i + 1] == "["
104
104
  # Find end of CSI sequence
105
105
  j = i + 2
106
- j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fmnR]/)
106
+ j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnR]/)
107
107
  seq = processed[i..j]
108
108
 
109
109
  dsr = _apply_csi(seq, cursor, attrs, grid, rows, cols)
@@ -137,16 +137,16 @@ module TUITD
137
137
  else
138
138
  i += 1
139
139
  end
140
- elsif processed[i] =~ /[[:print:]]/
141
- # Printable character
140
+ elsif (char, char_len = _utf8_char_at(processed, i))
141
+ # Printable character (including multi-byte UTF-8)
142
142
  if cursor[:row] < rows && cursor[:col] < cols
143
143
  cell = grid[cursor[:row]][cursor[:col]]
144
- cell[:char] = processed[i]
144
+ cell[:char] = char
145
145
  cell.merge!(attrs)
146
146
  cursor[:col] += 1
147
147
  cursor[:col] = cols - 1 if cursor[:col] >= cols
148
148
  end
149
- i += 1
149
+ i += char_len
150
150
  else
151
151
  i += 1
152
152
  end
@@ -214,7 +214,7 @@ module TUITD
214
214
  def self._apply_csi(seq, cursor, attrs, grid, rows, cols)
215
215
  # Strip leading escape char if present
216
216
  cleaned = seq.sub(/^\e/, "")
217
- match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhmnR])$/)
217
+ match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhlmnR])$/)
218
218
  return unless match
219
219
 
220
220
  params = match[1].split(";").map(&:to_i)
@@ -270,6 +270,8 @@ module TUITD
270
270
  next unless cursor[:row] < rows && cursor[:col] + i < cols
271
271
  grid[cursor[:row]][cursor[:col] + i][:char] = " "
272
272
  end
273
+ when "h", "l" # DEC private mode set/reset — skip (alternate screen, cursor show/hide, etc.)
274
+ nil
273
275
  when "n" # DSR — Device Status Report request
274
276
  # \e[6n = request cursor position → caller must respond with \e[row;colR
275
277
  return params[0] == 6
@@ -409,5 +411,38 @@ module TUITD
409
411
  nil
410
412
  end
411
413
  end
414
+
415
+ # Extract a single UTF-8 character at position i in a binary string.
416
+ # Returns [char_string, byte_length] or nil if the byte is not printable/valid.
417
+ def self._utf8_char_at(str, i)
418
+ byte = str.getbyte(i)
419
+ return nil unless byte
420
+
421
+ if byte < 0x80
422
+ # Single-byte ASCII
423
+ return nil unless byte >= 0x20 # only printable, skip control chars
424
+ return [byte.chr, 1]
425
+ end
426
+
427
+ # Multi-byte UTF-8
428
+ len = if byte & 0xE0 == 0xC0
429
+ 2
430
+ elsif byte & 0xF0 == 0xE0
431
+ 3
432
+ elsif byte & 0xF8 == 0xF0
433
+ 4
434
+ else
435
+ return nil # continuation byte or invalid — let main loop advance
436
+ end
437
+ return nil if i + len > str.bytesize
438
+
439
+ bytes = str.byteslice(i, len)
440
+ char = bytes.dup.force_encoding("UTF-8")
441
+ return nil unless char.valid_encoding?
442
+
443
+ [char, len]
444
+ rescue StandardError
445
+ nil
446
+ end
412
447
  end
413
448
  end
data/lib/tui_td/cli.rb CHANGED
@@ -18,12 +18,30 @@ 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 -vl test examples/vim_hello_world.json"
36
+ opts.separator " tui-td serve"
37
+ opts.separator ""
38
+ opts.separator "Interactive commands (drive mode):"
39
+ opts.separator " state Show terminal state as pretty JSON"
40
+ opts.separator " raw Show raw ANSI output"
41
+ opts.separator " key <name> Send keystroke (enter, tab, escape, up, down, left, right,"
42
+ opts.separator " backspace, ctrl_c, ctrl_d)"
43
+ opts.separator " <text> Send text to the TUI"
44
+ opts.separator " exit Quit drive mode"
27
45
  opts.separator ""
28
46
  opts.separator "Global options:"
29
47
 
@@ -54,6 +72,19 @@ module TUITD
54
72
  opts.on("--text", "Output state as plain text table") do |_|
55
73
  global_opts[:format] = :text
56
74
  end
75
+ opts.on("-v", "--verbose", "Show each test step as it runs") do |_|
76
+ global_opts[:verbose] = true
77
+ end
78
+ opts.on("-l", "--live", "Show terminal state after each step (screen-refresh)") do |_|
79
+ global_opts[:live] = true
80
+ end
81
+ opts.on("-s", "--step", "Pause after each test step for confirmation") do |_|
82
+ global_opts[:step_mode] = true
83
+ end
84
+ opts.on("--version", "Show version") do
85
+ puts "tui-td #{TUITD::VERSION}"
86
+ exit 0
87
+ end
57
88
  opts.on("-h", "--help", "Show help") do
58
89
  puts opts
59
90
  exit 0
@@ -74,9 +105,20 @@ module TUITD
74
105
  cmd_capture(command_opts, global_opts)
75
106
  when "test"
76
107
  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
108
+ when "help"
109
+ topic = argv.shift
110
+ case topic
111
+ when "test"
112
+ _help_test
113
+ when "rspec"
114
+ _help_rspec
115
+ when nil
116
+ _help_main
117
+ else
118
+ abort "Unknown help topic: #{topic.inspect}\nTry: tui-td help test, tui-td help rspec"
119
+ end
120
+ when nil
121
+ _help_main
80
122
  else
81
123
  abort "Unknown command: #{command.inspect}\nUse tui-td --help for usage"
82
124
  end
@@ -196,9 +238,35 @@ module TUITD
196
238
  path = args.first
197
239
  abort "File not found: #{path}" unless File.exist?(path)
198
240
 
241
+ verbose = globals[:verbose]
242
+ live = globals[:live]
243
+ step_mode = globals[:step_mode]
244
+
245
+ on_step = if verbose || live || step_mode
246
+ lambda do |info|
247
+ if live && info[:driver]
248
+ info[:driver].wait_for_stable(stable_ms: 200)
249
+ end
250
+ if verbose
251
+ status = info[:result].passed ? "PASS" : "FAIL"
252
+ puts "[#{info[:index] + 1}/#{info[:total]}] #{info[:action]}: #{info[:result].message}"
253
+ puts " → #{status}"
254
+ end
255
+ if live && info[:driver]
256
+ print "\e[2J\e[H" # clear screen, home cursor
257
+ _render_text(info[:driver].state_data)
258
+ end
259
+ if step_mode
260
+ print "\n[Enter=weiter, q=abbruch] "
261
+ input = $stdin.gets
262
+ exit 1 if input&.chomp == "q"
263
+ end
264
+ end
265
+ end
266
+
199
267
  require "json"
200
268
  plan = JSON.parse(File.read(path), symbolize_names: true)
201
- runner = TestRunner.new(plan)
269
+ runner = TestRunner.new(plan, on_step: on_step)
202
270
  result = runner.run
203
271
 
204
272
  puts
@@ -228,5 +296,134 @@ module TUITD
228
296
  puts line.empty? ? "~" : line
229
297
  end
230
298
  end
299
+
300
+ def _help_main
301
+ puts OptionParser.new { |o| o.banner = "Usage: tui-td <command> [options]" }
302
+ puts
303
+ puts "For more: tui-td help test (JSON test step types)"
304
+ puts " tui-td help rspec (RSpec matchers)"
305
+ exit 0
306
+ end
307
+
308
+ def _help_test
309
+ puts <<~HELP
310
+ JSON Test Format
311
+ ================
312
+
313
+ Run from CLI:
314
+
315
+ tui-td test examples/echo_test.json
316
+ tui-td -v test examples/echo_test.json (verbose: show each step)
317
+ tui-td -vl test examples/echo_test.json (verbose + live terminal view)
318
+ tui-td -vs test examples/echo_test.json (verbose + pause after each step)
319
+ tui-td -vls test examples/vim_hello_world.json (all three: watch vim edit live)
320
+
321
+ Or from Ruby code:
322
+
323
+ require "tui_td/test_runner"
324
+ runner = TUITD::TestRunner.new(name: "my test", steps: [...])
325
+ result = runner.run # => { passed: true, results: [...] }
326
+
327
+ A test is a Hash or JSON string: {"name": "...", "steps": [...]}
328
+
329
+ Top-level keys: name, steps, rows (default 40), cols (default 120),
330
+ timeout (default 30), chdir
331
+
332
+ Each step is an object with a single action key:
333
+
334
+ {"start": "<command>"}
335
+ Start a TUI process in a PTY.
336
+
337
+ {"send": "<text>"}
338
+ Send text to the TUI. Use "\\n" for Enter.
339
+
340
+ {"send_key": "<name>"}
341
+ Send a keystroke. Names: enter, tab, escape, up, down,
342
+ left, right, backspace, ctrl_c, ctrl_d.
343
+
344
+ {"wait_for_text": "<substring>"}
345
+ Wait until the given text appears in the output.
346
+
347
+ {"wait_for_stable": true}
348
+ Wait until the output stops changing (default 300ms quiet).
349
+
350
+ {"assert_text": "<substring>"}
351
+ Fail if the text is not found in the current state.
352
+
353
+ {"assert_fg": [row, col], "is": "<color>"}
354
+ Assert foreground color at cell. Colors: "default",
355
+ named ANSI (red, green, blue, cyan, ...), "bright_*",
356
+ "color<N>" (256-color), "#rrggbb" (TrueColor).
357
+
358
+ {"assert_bg": [row, col], "is": "<color>"}
359
+ Assert background color at cell. Same color format.
360
+
361
+ {"assert_style": [row, col], "bold": true, "italic": false, ...}
362
+ Assert style attributes at cell. Checks only the keys provided.
363
+
364
+ {"screenshot": "<path>"}
365
+ Save a PNG screenshot. Path defaults to /tmp/tui_td_<ts>.png.
366
+
367
+ {"html": "<path>"}
368
+ Save an HTML render. Path defaults to /tmp/tui_td_<ts>.html.
369
+
370
+ {"close": true}
371
+ Close the driver session.
372
+
373
+ Example test file: examples/echo_test.json
374
+ HELP
375
+ exit 0
376
+ end
377
+
378
+ def _help_rspec
379
+ puts <<~HELP
380
+ RSpec Matchers
381
+ ==============
382
+
383
+ Matchers work on TUITD::State objects, not raw output.
384
+ Get a State from the Driver:
385
+
386
+ require "tui_td"
387
+ require "tui_td/matchers"
388
+
389
+ driver = TUITD::Driver.new("my_tui", rows: 24, cols: 80)
390
+ driver.start
391
+ state = TUITD::State.new(driver.state_data)
392
+
393
+ expect(state).to have_text("Hello")
394
+ expect(state).to have_fg("red").at(0, 5)
395
+
396
+ driver.close
397
+
398
+ Or build a State manually for unit tests:
399
+
400
+ state = TUITD::State.new(
401
+ size: { rows: 5, cols: 20 },
402
+ cursor: { row: 0, col: 0 },
403
+ rows: [[{ char: "H", fg: "default", bg: "default",
404
+ bold: false, italic: false, underline: false }]]
405
+ )
406
+
407
+ Matchers
408
+ --------
409
+
410
+ have_text(expected)
411
+ Passes if expected text appears anywhere in the terminal state.
412
+ Usage: expect(state).to have_text("Hello")
413
+
414
+ have_fg(expected).at(row, col)
415
+ Assert foreground color at [row, col] matches expected.
416
+ Usage: expect(state).to have_fg("red").at(0, 5)
417
+
418
+ have_bg(expected).at(row, col)
419
+ Assert background color at [row, col] matches expected.
420
+ Usage: expect(state).to have_bg("default").at(0, 0)
421
+
422
+ have_style.at(row, col).with(bold: true, italic: false, ...)
423
+ Assert style attributes at [row, col] match the given hash.
424
+ Usage: expect(state).to have_style.at(0, 0).with(bold: true)
425
+ HELP
426
+ exit 0
427
+ end
231
428
  end
232
429
  end
@@ -26,10 +26,11 @@ module TUITD
26
26
  class TestRunner
27
27
  Result = Struct.new(:step, :passed, :message, keyword_init: true)
28
28
 
29
- def initialize(source)
29
+ def initialize(source, on_step: nil)
30
30
  raw = source.is_a?(String) ? JSON.parse(source) : source
31
31
  @plan = raw.transform_keys(&:to_sym)
32
32
  @plan[:steps] = @plan[:steps].map { |s| s.transform_keys(&:to_sym) }
33
+ @on_step = on_step
33
34
  end
34
35
 
35
36
  def run
@@ -150,6 +151,24 @@ module TUITD
150
151
 
151
152
  results << r
152
153
  all_passed &&= r.passed
154
+
155
+ if @on_step
156
+ state_data = nil
157
+ begin
158
+ state_data = driver.state_data if driver
159
+ rescue StandardError
160
+ # ignore — state retrieval is best-effort
161
+ end
162
+ @on_step.call(
163
+ index: results.size - 1,
164
+ total: @plan[:steps].size,
165
+ action: action,
166
+ value: value,
167
+ result: r,
168
+ driver: driver,
169
+ state_data: state_data
170
+ )
171
+ end
153
172
  end
154
173
 
155
174
  driver&.close
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
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.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus