tui-td 0.2.12 → 0.2.14

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: 98d74d9d7481eabaca77e302ae6e86e1c0467222ebeabb26320fc4668c96af72
4
- data.tar.gz: a666b663abf34a08b08c31e57b0348ae00020f304a6296012a027a26fbbb69a9
3
+ metadata.gz: 81e8cf766200bdb7f86334be97036e68f36df7e352618e823185976ebb07a629
4
+ data.tar.gz: 1632047e8e7b668d0aee6fcf3b14ff16b01d5c23d4cca1f126315d6fe8c1606b
5
5
  SHA512:
6
- metadata.gz: 22485763c32cac98cb98f8a2b4f2aaf8656c4955c97a28ff7cfdc3a3c2f82252c0b079c75630bb6d0e1ebd21a5c65d5f042be4c20e566502b6d747b88304a95d
7
- data.tar.gz: e3d209ca2706c701efaf6e98af5d46af21ce7f1772aa0fe1a2ddbe9858442380f866883ac5c8948bb66d447843987e7ba7bfb77a6cc2aa08a6363d09deaa0320
6
+ metadata.gz: a0f08cd1cc35c05466f5f5b77f45f674635d390db8a68bf9a75e316e2cc70f681daf718211a8563b765ccbfe1b7689d38e28378311dc2ad9afa3bf57a946a172
7
+ data.tar.gz: 5f6385932f9c350c3186dfe44a3c47bc0c169c86189feb90c8a54dc6ddfdb54f0b97489ffe812da557cc2e6828b384b28cd81ca286be977221586c4f0788fe33
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.2.14
4
+
5
+ ### Fixed
6
+
7
+ - Tighten tans-parser dependency from `~> 0.1` to `~> 0.1.1` to ensure the required
8
+ Selector/Element classes are present (0.1.0 lacks them)
9
+
10
+ ## 0.2.13
11
+
12
+ ### Added
13
+
14
+ - Auto-wait mechanisms: `Driver#wait_for(predicate)` with adaptive polling (10ms → 100ms),
15
+ auto-wait on RSpec matchers (3s timeout when given a Driver), auto-wait on JSON test
16
+ assertions (2s per-check timeout)
17
+ - Semantic selectors: `Element` struct and `Selector` class with heuristic role detection
18
+ for buttons, checkboxes, dialogs, statusbars, and progress bars
19
+ - RSpec matchers: `have_button`, `have_dialog`, `have_checkbox`, `have_role`
20
+ - JSON test steps: `assert_button`, `assert_dialog`, `assert_checkbox`, `assert_role`
21
+ - `within` scoping for filtering elements by bounding box
22
+ - `poll_interval` parameter on Driver for configurable polling speed
23
+
24
+ ### Changed
25
+
26
+ - `wait_for_stable` uses buffer-size tracking instead of full grid parse for performance
27
+ - Output buffer capped at 10 MB (ring buffer) to prevent unbounded memory growth
28
+ - Delegate `Selector` and `Element` to tans-parser 0.1.1
29
+
30
+ ### Documentation
31
+
32
+ - Add CONTRIBUTING.md with development setup, code quality, and PR workflow
33
+ - Add docs/quick_start.md with 2-minute getting-started tutorial
34
+ - Add docs/faq.md covering 8 common troubleshooting topics
35
+ - Add whiptail dialog example (`examples/whiptail_dialog.json`)
36
+ - Update CLI help (`tui-td help test`, `tui-td help rspec`) with new steps and matchers
37
+
3
38
  ## 0.2.12
4
39
 
5
40
  ### Security
data/README.md CHANGED
@@ -1,13 +1,22 @@
1
1
  # TUI Test Drive
2
2
 
3
- Testing framework for Terminal User Interfaces (TUIs) with MCP support.
3
+ [![Gem Version](https://badge.fury.io/rb/tui-td.svg)](https://rubygems.org/gems/tui-td)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
4
5
 
5
- **tui-td** lets you:
6
- 1. Start a TUI application in a virtual terminal (PTY)
7
- 2. See the output as structured JSON, plain text, PNG screenshots, or HTML renders
8
- 3. Send input — keystrokes, text, control sequences
9
- 4. Analyze output — find text, check colors, detect cursor position
10
- 5. Loop — adjust and retest without manual intervention
6
+ Testing framework for Terminal User Interfaces (TUIs). Start a TUI in a PTY, send input, analyze output — as structured JSON, plain text, PNG screenshots, or HTML renders. Includes an MCP server for AI-driven testing, auto-wait RSpec matchers, and semantic selectors.
7
+
8
+ > New to tui-td? Jump to [Quick Start](docs/quick_start.md).
9
+
10
+ **What tui-td gives you:**
11
+
12
+ 1. **Start any TUI** in a virtual terminal (PTY) with `Driver` or JSON test plans
13
+ 2. **Auto-wait assertions** — matchers automatically retry until the condition is met or timeout
14
+ 3. **Semantic selectors** — `get_by_role(:button)`, `get_by_role(:dialog)`, `within { }` scoping
15
+ 4. **Multiple output formats** — structured JSON, plain text, PNG screenshots, HTML renders
16
+ 5. **JSON test runner** — language-agnostic, 15+ step types, CI-friendly
17
+ 6. **RSpec matchers** — `have_text`, `have_fg`, `have_button`, `have_dialog`, and more
18
+ 7. **MCP server** — AI agents can drive TUIs via JSON-RPC over stdio
19
+ 8. **Pure Ruby rendering** — embedded Spleen font + 2766 Unifont glyphs, no native deps required
11
20
 
12
21
  ## Installation
13
22
 
@@ -83,6 +92,7 @@ Examples:
83
92
  Interactive commands (drive mode):
84
93
  state Show terminal state as pretty JSON
85
94
  raw Show raw ANSI output
95
+ elements Show detected UI elements (buttons, dialogs, etc.)
86
96
  key <name> Send keystroke (enter, tab, escape, up, down, left, right,
87
97
  backspace, ctrl_c, ctrl_d)
88
98
  <text> Send text to the TUI
@@ -273,6 +283,10 @@ tui-td test examples/echo_test.json
273
283
  | `assert_exit` | `N` | Assert the process exit code equals N |
274
284
  | `screenshot` | `"path"` | Save PNG screenshot |
275
285
  | `html` | `"path"` | Save HTML render for browser viewing |
286
+ | `assert_button` | `"text"` | Assert a button with given text is visible (`[ OK ]`, `(Cancel)`, `<Submit>`) |
287
+ | `assert_dialog` | — | Assert a dialog (box-drawing region) is visible |
288
+ | `assert_checkbox` | `"label", "checked": true` | Assert a checkbox with given label (and optionally checked state) |
289
+ | `assert_role` | `":button", "text": "OK"` | Generic role assertion (`:button`, `:checkbox`, `:dialog`, `:statusbar`, `:progress`) |
276
290
  | `close` | — | Close the TUI |
277
291
 
278
292
  Example with `html` step for before/after snapshots:
@@ -347,6 +361,10 @@ end
347
361
  | `have_fg("color").at(row, col)` | Assert foreground color at position |
348
362
  | `have_bg("color").at(row, col)` | Assert background color at position |
349
363
  | `have_style.at(row, col).with(bold: true, ...)` | Assert cell style |
364
+ | `have_button("OK")` | Assert a button with given text is visible |
365
+ | `have_dialog` | Assert a dialog (box-drawing region) is visible |
366
+ | `have_checkbox("Label").checked` | Assert a checkbox with given label (chain `.checked`) |
367
+ | `have_role(:button, text: "OK")` | Generic role assertion with optional text filter |
350
368
  | `have_exit_status(N)` | Assert the driver process exit status equals N |
351
369
 
352
370
  ## MCP Server — AI Integration
@@ -373,6 +391,7 @@ tui-td serve
373
391
  | `tui_wait_for_exit` | Wait until the TUI process exits. Returns exit status. |
374
392
  | `tui_exit_status` | Get the exit status code (nil if still running). |
375
393
  | `tui_find_text` | Search for text or regex in terminal state. Returns positions of all matches. |
394
+ | `tui_find_elements` | Detect UI elements (buttons, checkboxes, dialogs, etc.) with optional role/text filters. |
376
395
  | `tui_close` | Close the TUI and clean up. |
377
396
 
378
397
  ### MCP configuration
@@ -419,10 +438,13 @@ Add to your MCP client configuration:
419
438
  // 8. Search for text in the terminal
420
439
  {"method": "tools/call", "params": {"name": "tui_find_text", "arguments": {"pattern": "error|fail"}}}
421
440
 
422
- // 9. Check exit status (or wait for exit)
441
+ // 9. Find UI elements by role
442
+ {"method": "tools/call", "params": {"name": "tui_find_elements", "arguments": {"role": "button"}}}
443
+
444
+ // 10. Check exit status (or wait for exit)
423
445
  {"method": "tools/call", "params": {"name": "tui_exit_status", "arguments": {}}}
424
446
 
425
- // 10. Clean up
447
+ // 11. Clean up
426
448
  {"method": "tools/call", "params": {"name": "tui_close", "arguments": {}}}
427
449
  ```
428
450
 
data/lib/tui_td/cli.rb CHANGED
@@ -40,6 +40,7 @@ module TUITD
40
40
  opts.separator "Interactive commands (drive mode):"
41
41
  opts.separator " state Show terminal state as pretty JSON"
42
42
  opts.separator " raw Show raw ANSI output"
43
+ opts.separator " elements Show detected UI elements (buttons, dialogs, etc.)"
43
44
  opts.separator " key <name> Send keystroke (enter, tab, escape, up, down, left, right,"
44
45
  opts.separator " backspace, ctrl_c, ctrl_d)"
45
46
  opts.separator " <text> Send text to the TUI"
@@ -195,6 +196,14 @@ module TUITD
195
196
  elsif input == "exitstatus"
196
197
  status = driver.exitstatus
197
198
  puts status ? "Exit status: #{status}" : "Process still running"
199
+ elsif input == "elements"
200
+ state = State.new(driver.state_data)
201
+ selector = Selector.new(state)
202
+ puts "Buttons: #{selector.buttons.map { |e| "#{e.text}@[#{e.row},#{e.col}]" }.join(", ")}"
203
+ puts "Checkboxes: #{selector.checkboxes.map { |e| "#{e.text} (#{e.checked ? "✓" : "☐"})" }.join(", ")}"
204
+ puts "Dialogs: #{selector.dialogs.map { |e| "\"#{e.text}\" #{e.width}x#{e.height}" }.join(", ")}"
205
+ puts "Statusbars: #{selector.statusbars.map(&:text).join(", ")}"
206
+ puts "Progress: #{selector.progress_bars.map(&:text).join(", ")}"
198
207
  elsif input.start_with?("key ")
199
208
  driver.send_keys(input.split(" ", 2).last.to_sym)
200
209
  else
@@ -410,6 +419,22 @@ module TUITD
410
419
  {"close": true}
411
420
  Close the driver session (force-kill if needed).
412
421
 
422
+ {"assert_button": "<text>"}
423
+ Find a button with the given text. Buttons are detected
424
+ by patterns like [ OK ], (Cancel), <Submit>.
425
+
426
+ {"assert_dialog": true}
427
+ Assert that at least one dialog (box-drawing region) is visible.
428
+
429
+ {"assert_checkbox": "<text>", "checked": true}
430
+ Find a checkbox with the given label text. Optional "checked"
431
+ (true/false) to match checked state. Detects [x], [*], [ ] at
432
+ line starts.
433
+
434
+ {"assert_role": ":button", "text": "OK"}
435
+ Generic role assertion. Accepts :button, :checkbox, :dialog,
436
+ :statusbar, :progress. Optional "text" filter.
437
+
413
438
  Example test file: examples/echo_test.json
414
439
  HELP
415
440
  exit 0
@@ -470,6 +495,32 @@ module TUITD
470
495
  Assert style attributes at [row, col] match the given hash.
471
496
  Usage: expect(state).to have_style.at(0, 0).with(bold: true)
472
497
 
498
+ Selector matchers (semantic UI element detection)
499
+ -------------------------------------------------
500
+
501
+ These matchers detect UI elements by their visual appearance:
502
+
503
+ have_button("OK")
504
+ Passes if a button with the given text is visible.
505
+ Detects [ OK ], (Cancel), <Submit> patterns.
506
+ Usage: expect(state).to have_button("OK")
507
+
508
+ have_dialog
509
+ Passes if a dialog (box-drawing character region) is visible.
510
+ Usage: expect(state).to have_dialog
511
+
512
+ have_checkbox("Enable").checked
513
+ Passes if a checkbox with the given label is visible.
514
+ Chain .checked to require the box to be checked.
515
+ Detects [x], [*], [ ] at line starts.
516
+ Usage: expect(state).to have_checkbox("Enable logging")
517
+ Usage: expect(state).to have_checkbox("Auto-save").checked
518
+
519
+ have_role(:button, text: "OK")
520
+ Generic role matcher. Accepts :button, :checkbox, :dialog,
521
+ :statusbar, :progress. Optional text: filter.
522
+ Usage: expect(state).to have_role(:statusbar)
523
+
473
524
  Driver matchers (work on TUITD::Driver, not State)
474
525
  --------------------------------------------------
475
526
 
data/lib/tui_td/driver.rb CHANGED
@@ -26,7 +26,9 @@ module TUITD
26
26
 
27
27
  attr_reader :command, :state
28
28
 
29
- def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {})
29
+ MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10 MB ring buffer
30
+
31
+ def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {}, poll_interval: nil)
30
32
  @command = command
31
33
  @rows = rows
32
34
  @cols = cols
@@ -41,6 +43,7 @@ module TUITD
41
43
  @output_mutex = Mutex.new
42
44
  @reader_thread = nil
43
45
  @reader_running = false
46
+ @poll_interval = poll_interval
44
47
  end
45
48
 
46
49
  # Start the TUI application in a PTY
@@ -92,9 +95,34 @@ module TUITD
92
95
  end
93
96
  end
94
97
 
98
+ # Wait until the predicate returns true for the current terminal state.
99
+ # Polls with adaptive intervals: 10ms → 25ms → 50ms → 100ms.
100
+ # Use a custom poll_interval to bypass adaptive behavior.
101
+ #
102
+ # driver.wait_for { |state| state.find_text("Ready").any? }
103
+ # driver.wait_for(timeout: 5) { |state| state.foreground_at(0, 0) == "green" }
104
+ #
105
+ def wait_for(timeout: nil, &predicate)
106
+ deadline = monotonic + (timeout || @timeout)
107
+ loop_count = 0
108
+ loop do
109
+ raise TimeoutError, "Timeout waiting for predicate" if monotonic > deadline
110
+
111
+ read_available!
112
+ refresh_state!
113
+ state_obj = State.new(@state)
114
+ break if predicate.call(state_obj)
115
+
116
+ adaptive_sleep(loop_count)
117
+ loop_count += 1
118
+ end
119
+ @state
120
+ end
121
+
95
122
  # Wait until output contains the given text
96
123
  def wait_for_text(text)
97
124
  deadline = monotonic + @timeout
125
+ loop_count = 0
98
126
  loop do
99
127
  raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
100
128
 
@@ -102,7 +130,8 @@ module TUITD
102
130
  found = @output_mutex.synchronize { @output_buffer.include?(text) }
103
131
  break if found
104
132
 
105
- sleep 0.05
133
+ adaptive_sleep(loop_count)
134
+ loop_count += 1
106
135
  end
107
136
  refresh_state!
108
137
  end
@@ -111,28 +140,27 @@ module TUITD
111
140
  def wait_for_stable(stable_ms: 300)
112
141
  deadline = monotonic + @timeout
113
142
  last_change = monotonic
114
- last_grid = nil
143
+ last_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize }
144
+ loop_count = 0
115
145
 
116
146
  loop do
117
147
  raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline
118
148
 
119
- had_data = read_available!
149
+ read_available!
150
+ current_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize }
120
151
  process_alive = process_alive?
121
152
 
122
- if had_data
123
- current_grid = parse_grid_snapshot
124
- if current_grid != last_grid
125
- last_grid = current_grid
126
- last_change = monotonic
127
- end
153
+ if current_buffer_size != last_buffer_size
154
+ last_buffer_size = current_buffer_size
155
+ last_change = monotonic
128
156
  elsif !process_alive
129
- # Process exited and no more data — final state reached
130
157
  break
131
- elsif last_grid && (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch
158
+ elsif (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch
132
159
  break
133
160
  end
134
161
 
135
- sleep 0.05
162
+ adaptive_sleep(loop_count)
163
+ loop_count += 1
136
164
  end
137
165
  refresh_state!
138
166
  end
@@ -225,6 +253,7 @@ module TUITD
225
253
  def _start_reader_thread
226
254
  @reader_running = true
227
255
  @reader_thread = Thread.new do
256
+ loop_count = 0
228
257
  loop do
229
258
  break unless @reader_running
230
259
 
@@ -233,7 +262,8 @@ module TUITD
233
262
  rescue IOError, Errno::EIO
234
263
  break
235
264
  end
236
- sleep 0.05
265
+ adaptive_sleep(loop_count)
266
+ loop_count += 1
237
267
  end
238
268
  end
239
269
  end
@@ -260,12 +290,30 @@ module TUITD
260
290
  raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
261
291
  end
262
292
 
293
+ def adaptive_sleep(loop_count)
294
+ interval = if @poll_interval
295
+ @poll_interval
296
+ elsif loop_count < 40 # ~0-2s: 10ms
297
+ 0.01
298
+ elsif loop_count < 160 # ~2-5s: 25ms
299
+ 0.025
300
+ elsif loop_count < 260 # ~5-10s: 50ms
301
+ 0.05
302
+ else # 10s+: 100ms
303
+ 0.1
304
+ end
305
+ sleep interval
306
+ end
307
+
263
308
  def read_available!
264
309
  return false unless @stdout
265
310
 
266
311
  data = @stdout.read_nonblock(4096)
267
312
 
268
- @output_mutex.synchronize { @output_buffer << data }
313
+ @output_mutex.synchronize do
314
+ @output_buffer << data
315
+ @output_buffer = @output_buffer[-MAX_BUFFER_SIZE..] if @output_buffer.bytesize > MAX_BUFFER_SIZE
316
+ end
269
317
 
270
318
  respond_to_dsr if data.include?("\e[6n")
271
319
 
@@ -2,36 +2,57 @@
2
2
 
3
3
  require "rspec/expectations"
4
4
 
5
- # RSpec matchers for TUITD::State objects.
5
+ # RSpec matchers for TUITD::State and TUITD::Driver objects.
6
+ #
7
+ # When given a State, matchers check immediately.
8
+ # When given a Driver, matchers auto-wait (up to 3 seconds) for the condition.
6
9
  #
7
10
  # Usage:
8
11
  # require "tui_td/matchers"
9
12
  #
13
+ # # Immediate check on State
10
14
  # state = TUITD::State.new(driver.state_data)
11
15
  # expect(state).to have_text("Welcome")
12
- # expect(state).to have_fg("cyan").at(0, 0)
16
+ #
17
+ # # Auto-wait on Driver
18
+ # expect(driver).to have_text("Welcome")
13
19
  #
14
20
  module TUITD
15
21
  module Matchers
22
+ AUTO_WAIT_TIMEOUT = 3
23
+
24
+ def self.auto_wait(actual, timeout: AUTO_WAIT_TIMEOUT, &predicate)
25
+ if actual.respond_to?(:wait_for)
26
+ begin
27
+ actual.wait_for(timeout: timeout, &predicate)
28
+ true
29
+ rescue TUITD::TimeoutError
30
+ false
31
+ end
32
+ else
33
+ predicate.call(actual)
34
+ end
35
+ end
36
+
16
37
  RSpec::Matchers.define :have_text do |expected|
17
- match do |state|
18
- state.find_text(expected).any?
38
+ match do |actual|
39
+ Matchers.auto_wait(actual) { |s| s.find_text(expected).any? }
19
40
  end
20
41
 
21
42
  description { "have text #{expected.inspect}" }
22
- failure_message { |_state| "expected terminal to contain #{expected.inspect}" }
23
- failure_message_when_negated { |_state| "expected terminal NOT to contain #{expected.inspect}" }
43
+ failure_message { |_actual| "expected terminal to contain #{expected.inspect}" }
44
+ failure_message_when_negated { |_actual| "expected terminal NOT to contain #{expected.inspect}" }
24
45
  end
25
46
 
26
47
  RSpec::Matchers.define :have_regex do |pattern|
27
- match do |state|
48
+ match do |actual|
28
49
  @regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
29
- state.find_text(@regex).any?
50
+ Matchers.auto_wait(actual) { |s| s.find_text(@regex).any? }
30
51
  end
31
52
 
32
53
  description { "match regex #{pattern.inspect}" }
33
- failure_message { |_state| "expected terminal to match #{pattern.inspect}" }
34
- failure_message_when_negated { |_state| "expected terminal NOT to match #{pattern.inspect}" }
54
+ failure_message { |_actual| "expected terminal to match #{pattern.inspect}" }
55
+ failure_message_when_negated { |_actual| "expected terminal NOT to match #{pattern.inspect}" }
35
56
  end
36
57
 
37
58
  RSpec::Matchers.define :have_fg do |expected|
@@ -40,13 +61,15 @@ module TUITD
40
61
  @col = col
41
62
  end
42
63
 
43
- match do |state|
44
- @actual = state.foreground_at(@row, @col)
45
- @actual == expected
64
+ match do |actual|
65
+ Matchers.auto_wait(actual) do |s|
66
+ @actual = s.foreground_at(@row, @col)
67
+ @actual == expected
68
+ end
46
69
  end
47
70
 
48
71
  description { "have foreground #{expected.inspect} at [#{@row},#{@col}]" }
49
- failure_message do |_state|
72
+ failure_message do |_actual|
50
73
  "expected FG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
51
74
  end
52
75
  end
@@ -57,13 +80,15 @@ module TUITD
57
80
  @col = col
58
81
  end
59
82
 
60
- match do |state|
61
- @actual = state.background_at(@row, @col)
62
- @actual == expected
83
+ match do |actual|
84
+ Matchers.auto_wait(actual) do |s|
85
+ @actual = s.background_at(@row, @col)
86
+ @actual == expected
87
+ end
63
88
  end
64
89
 
65
90
  description { "have background #{expected.inspect} at [#{@row},#{@col}]" }
66
- failure_message do |_state|
91
+ failure_message do |_actual|
67
92
  "expected BG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
68
93
  end
69
94
  end
@@ -75,17 +100,19 @@ module TUITD
75
100
  end
76
101
  chain(:with) { |expected| @expected = expected }
77
102
 
78
- match do |state|
79
- @actual = state.style_at(@row, @col)
80
- @expected ||= {}
81
- @expected.all? { |k, v| @actual[k] == v }
103
+ match do |actual|
104
+ @expected_style = @expected || {}
105
+ Matchers.auto_wait(actual) do |s|
106
+ @actual = s.style_at(@row, @col)
107
+ @expected_style.all? { |k, v| @actual[k] == v }
108
+ end
82
109
  end
83
110
 
84
111
  description do
85
- "have style #{@expected.inspect} at [#{@row},#{@col}]"
112
+ "have style #{@expected_style.inspect} at [#{@row},#{@col}]"
86
113
  end
87
- failure_message do |_state|
88
- "expected style at [#{@row},#{@col}] to be #{@expected.inspect}, but was #{@actual.inspect}"
114
+ failure_message do |_actual|
115
+ "expected style at [#{@row},#{@col}] to be #{@expected_style.inspect}, but was #{@actual.inspect}"
89
116
  end
90
117
  end
91
118
 
@@ -104,5 +131,84 @@ module TUITD
104
131
  "expected exit status not to be #{expected}"
105
132
  end
106
133
  end
134
+
135
+ # Selector-based matchers — work with both State and Driver (auto-wait)
136
+
137
+ RSpec::Matchers.define :have_button do |expected|
138
+ match do |actual|
139
+ Matchers.auto_wait(actual) do |s|
140
+ Selector.new(s).get_by_text(expected).any? { |e| e.role == :button }
141
+ end
142
+ end
143
+
144
+ description { "have button #{expected.inspect}" }
145
+ failure_message { |_actual| "expected terminal to have a button #{expected.inspect}" }
146
+ failure_message_when_negated { |_actual| "expected terminal NOT to have a button #{expected.inspect}" }
147
+ end
148
+
149
+ RSpec::Matchers.define :have_dialog do
150
+ match do |actual|
151
+ Matchers.auto_wait(actual) { |s| Selector.new(s).dialogs.any? }
152
+ end
153
+
154
+ description { "have a dialog" }
155
+ failure_message { |_actual| "expected terminal to have a dialog" }
156
+ failure_message_when_negated { |_actual| "expected terminal NOT to have a dialog" }
157
+ end
158
+
159
+ RSpec::Matchers.define :have_checkbox do |expected|
160
+ chain(:checked) { @checked = true }
161
+
162
+ match do |actual|
163
+ Matchers.auto_wait(actual) do |s|
164
+ checkboxes = Selector.new(s).checkboxes
165
+ found = checkboxes.select { |e| e.text&.include?(expected) }
166
+ found = found.select(&:checked) if @checked
167
+ found.any?
168
+ end
169
+ end
170
+
171
+ description do
172
+ desc = "have checkbox #{expected.inspect}"
173
+ desc += " (checked)" if @checked
174
+ desc
175
+ end
176
+ failure_message do |_actual|
177
+ desc = "expected terminal to have checkbox #{expected.inspect}"
178
+ desc += " (checked)" if @checked
179
+ desc
180
+ end
181
+ failure_message_when_negated do |_actual|
182
+ desc = "expected terminal NOT to have checkbox #{expected.inspect}"
183
+ desc += " (checked)" if @checked
184
+ desc
185
+ end
186
+ end
187
+
188
+ RSpec::Matchers.define :have_role do |role, text: nil|
189
+ match do |actual|
190
+ Matchers.auto_wait(actual) do |s|
191
+ elements = Selector.new(s).get_by_role(role)
192
+ elements = elements.select { |e| e.text&.include?(text) } if text
193
+ elements.any?
194
+ end
195
+ end
196
+
197
+ description do
198
+ desc = "have role :#{role}"
199
+ desc += " with text #{text.inspect}" if text
200
+ desc
201
+ end
202
+ failure_message do |_actual|
203
+ desc = "expected terminal to have a :#{role}"
204
+ desc += " with text #{text.inspect}" if text
205
+ desc
206
+ end
207
+ failure_message_when_negated do |_actual|
208
+ desc = "expected terminal NOT to have a :#{role}"
209
+ desc += " with text #{text.inspect}" if text
210
+ desc
211
+ end
212
+ end
107
213
  end
108
214
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Layout/LineLength
3
+ # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
4
4
 
5
5
  require "json"
6
6
 
@@ -284,6 +284,23 @@ module TUITD
284
284
  required: ["pattern"],
285
285
  },
286
286
  },
287
+ {
288
+ name: "tui_find_elements",
289
+ description: "Search for UI elements in the terminal state. Returns buttons, checkboxes, dialogs, statusbars, and progress bars detected by heuristic analysis. Optionally filter by role and/or text.",
290
+ inputSchema: {
291
+ type: "object",
292
+ properties: {
293
+ role: {
294
+ type: "string",
295
+ description: "Filter by role: button, checkbox, dialog, statusbar, progress. Omit to return all.",
296
+ },
297
+ text: {
298
+ type: "string",
299
+ description: "Filter by visible text (partial match). Optional.",
300
+ },
301
+ },
302
+ },
303
+ },
287
304
  {
288
305
  name: "tui_close",
289
306
  description: "Close the TUI application and clean up the PTY session. Call this when finished.",
@@ -315,7 +332,8 @@ module TUITD
315
332
  when "tui_wait_for_exit" then call_tui_wait_for_exit
316
333
  when "tui_exit_status" then call_tui_exit_status
317
334
  when "tui_find_text" then call_tui_find_text(args)
318
- when "tui_close" then call_tui_close
335
+ when "tui_find_elements" then call_tui_find_elements(args)
336
+ when "tui_close" then call_tui_close
319
337
  else
320
338
  return error_response(id, -32_602, "Unknown tool: #{tool_name}")
321
339
  end
@@ -488,6 +506,39 @@ module TUITD
488
506
  end
489
507
  end
490
508
 
509
+ def call_tui_find_elements(args)
510
+ ensure_driver!
511
+ state = TUITD::State.new(@driver.state_data)
512
+ selector = TUITD::Selector.new(state)
513
+
514
+ role = args["role"]&.to_sym
515
+ text = args["text"]
516
+
517
+ elements = if role
518
+ selector.get_by_role(role)
519
+ else
520
+ selector.elements
521
+ end
522
+ elements = elements.select { |e| e.text&.include?(text) } if text
523
+
524
+ if elements.empty?
525
+ desc = role ? "role :#{role}" : "any role"
526
+ desc += " with text #{text.inspect}" if text
527
+ "No elements found for #{desc}"
528
+ else
529
+ lines = ["Found #{elements.size} element(s):"]
530
+ elements.each do |el|
531
+ parts = [" :#{el.role}"]
532
+ parts << el.text.inspect if el.text
533
+ parts << "at [#{el.row},#{el.col}]"
534
+ parts << "#{el.width}x#{el.height}"
535
+ parts << "(checked)" if el.checked
536
+ lines << parts.join(" ")
537
+ end
538
+ lines.join("\n")
539
+ end
540
+ end
541
+
491
542
  def call_tui_close
492
543
  @driver&.close
493
544
  @driver = nil
@@ -544,4 +595,4 @@ module TUITD
544
595
  end
545
596
  end
546
597
  end
547
- # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Layout/LineLength
598
+ # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tans-parser"
4
+
5
+ module TUITD
6
+ Selector = TansParser::Selector
7
+ Element = TansParser::Element
8
+
9
+ # Extend the aliased Selector with within scoping.
10
+ # within is a testing-specific pattern (scope queries to a dialog region)
11
+ # and therefore lives in tui-td rather than tans-parser.
12
+ Selector.class_eval do
13
+ # Return a new Selector whose elements are filtered to the given bounding box.
14
+ # Coordinates of returned elements are relative to the box origin.
15
+ def within(top_row, left_col, width, height)
16
+ scoped = @elements.select do |e|
17
+ e.row >= top_row && e.row < top_row + height &&
18
+ e.col >= left_col && e.col < left_col + width
19
+ end
20
+ scoped.each do |e|
21
+ e = e.dup
22
+ e.row -= top_row
23
+ e.col -= left_col
24
+ end
25
+ self.class.allocate.tap do |s|
26
+ s.instance_variable_set(:@state, @state)
27
+ s.instance_variable_set(:@elements, scoped)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -109,18 +109,30 @@ module TUITD
109
109
  when "assert_style"
110
110
  ensure_driver!(driver)
111
111
  row, col = coords(step)
112
- state = State.new(driver.state_data)
113
- actual = state.style_at(row, col)
114
- expected = {}
115
- expected[:bold] = step[:bold] unless step[:bold].nil?
116
- expected[:italic] = step[:italic] unless step[:italic].nil?
117
- expected[:underline] = step[:underline] unless step[:underline].nil?
118
- match = expected.all? { |k, v| actual[k] == v }
112
+ expected_style = {}
113
+ expected_style[:bold] = step[:bold] unless step[:bold].nil?
114
+ expected_style[:italic] = step[:italic] unless step[:italic].nil?
115
+ expected_style[:underline] = step[:underline] unless step[:underline].nil?
116
+ actual = nil
117
+ match = begin
118
+ driver.wait_for(timeout: 2) do |s|
119
+ actual = s.style_at(row, col)
120
+ expected_style.all? { |k, v| actual[k] == v }
121
+ end
122
+ true
123
+ rescue TimeoutError
124
+ false
125
+ end
126
+ actual ||= begin
127
+ state = State.new(driver.state_data)
128
+ state.style_at(row, col)
129
+ end
119
130
  if match
120
- Result.new(step: action, passed: true, message: "Style at [#{row},#{col}] matches #{expected}")
131
+ Result.new(step: action, passed: true,
132
+ message: "Style at [#{row},#{col}] matches #{expected_style}",)
121
133
  else
122
134
  Result.new(step: action, passed: false,
123
- message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}",)
135
+ message: "Style at [#{row},#{col}] is #{actual}, expected #{expected_style}",)
124
136
  end
125
137
 
126
138
  when "screenshot"
@@ -151,6 +163,19 @@ module TUITD
151
163
  Result.new(step: action, passed: false, message: "Exit status #{actual}, expected #{expected}")
152
164
  end
153
165
 
166
+ when "assert_button"
167
+ check_role(driver, :button, value.to_s)
168
+
169
+ when "assert_dialog"
170
+ check_role(driver, :dialog, nil)
171
+
172
+ when "assert_checkbox"
173
+ check_role(driver, :checkbox, value.to_s, checked: step[:checked])
174
+
175
+ when "assert_role"
176
+ role = step[:role]&.to_sym
177
+ check_role(driver, role, value.to_s)
178
+
154
179
  when "close"
155
180
  driver&.close
156
181
  driver = nil
@@ -199,6 +224,25 @@ module TUITD
199
224
 
200
225
  private
201
226
 
227
+ def check_role(driver, role, text, checked: nil)
228
+ ensure_driver!(driver)
229
+ state = State.new(driver.state_data)
230
+ selector = Selector.new(state)
231
+ elements = selector.get_by_role(role)
232
+ elements = elements.select { |e| e.text&.include?(text.to_s) } if text
233
+ elements = elements.select { |e| e.checked == checked } unless checked.nil?
234
+
235
+ action = "assert_#{role}"
236
+ if elements.any?
237
+ count = elements.size
238
+ desc = text ? "#{role} #{text.inspect}" : role.to_s
239
+ Result.new(step: action, passed: true, message: "Found #{count} #{desc} element(s)")
240
+ else
241
+ desc = text ? "#{role} with text #{text.inspect}" : role.to_s
242
+ Result.new(step: action, passed: false, message: "No #{desc} found")
243
+ end
244
+ end
245
+
202
246
  def safe_output_path(value, ext)
203
247
  default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
204
248
  resolved = File.expand_path(value.is_a?(String) ? value : default)
@@ -223,7 +267,6 @@ module TUITD
223
267
 
224
268
  def check_text(driver, value, action)
225
269
  ensure_driver!(driver)
226
- state = State.new(driver.state_data)
227
270
  text = value.to_s
228
271
 
229
272
  if action == "assert_regex"
@@ -236,7 +279,17 @@ module TUITD
236
279
  pattern = text
237
280
  end
238
281
 
239
- found = state.find_text(pattern).any?
282
+ found = if action == "assert_not_text"
283
+ state = State.new(driver.state_data)
284
+ state.find_text(pattern).any?
285
+ else
286
+ begin
287
+ driver.wait_for(timeout: 2) { |s| s.find_text(pattern).any? }
288
+ true
289
+ rescue TimeoutError
290
+ false
291
+ end
292
+ end
240
293
 
241
294
  case action
242
295
  when "assert_text"
@@ -266,15 +319,30 @@ module TUITD
266
319
  ensure_driver!(driver)
267
320
  row, col = coords(step)
268
321
  expected = step[:is] || step["is"]
269
- state = State.new(driver.state_data)
270
322
  label = property == :fg ? "FG" : "BG"
271
- actual = property == :fg ? state.foreground_at(row, col) : state.background_at(row, col)
272
323
 
273
- if actual == expected
324
+ actual_val = nil
325
+ found = begin
326
+ driver.wait_for(timeout: 2) do |s|
327
+ actual_val = property == :fg ? s.foreground_at(row, col) : s.background_at(row, col)
328
+ actual_val == expected
329
+ end
330
+ true
331
+ rescue TimeoutError
332
+ # actual_val holds the last observed value
333
+ false
334
+ end
335
+
336
+ actual_val ||= begin
337
+ state = State.new(driver.state_data)
338
+ property == :fg ? state.foreground_at(row, col) : state.background_at(row, col)
339
+ end
340
+
341
+ if found
274
342
  Result.new(step: step.keys.first.to_s, passed: true, message: "#{label} at [#{row},#{col}] is #{expected}")
275
343
  else
276
344
  Result.new(step: step.keys.first.to_s, passed: false,
277
- message: "#{label} at [#{row},#{col}] is #{actual}, expected #{expected}",)
345
+ message: "#{label} at [#{row},#{col}] is #{actual_val}, expected #{expected}",)
278
346
  end
279
347
  end
280
348
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.12"
4
+ VERSION = "0.2.14"
5
5
  end
data/lib/tui_td.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "tui_td/state"
16
16
  require_relative "tui_td/screenshot"
17
17
  require_relative "tui_td/html_renderer"
18
18
  require_relative "tui_td/test_runner"
19
+ require_relative "tui_td/selector"
19
20
  require_relative "tui_td/mcp/server"
20
21
  require_relative "tui_td/cli"
21
22
 
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.2.12
4
+ version: 0.2.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0.1'
74
+ version: 0.1.1
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0.1'
81
+ version: 0.1.1
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: bundler-audit
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -173,6 +173,7 @@ files:
173
173
  - lib/tui_td/matchers.rb
174
174
  - lib/tui_td/mcp/server.rb
175
175
  - lib/tui_td/screenshot.rb
176
+ - lib/tui_td/selector.rb
176
177
  - lib/tui_td/state.rb
177
178
  - lib/tui_td/test_runner.rb
178
179
  - lib/tui_td/unifont_glyphs.rb