tui-td 0.2.11 → 0.2.13

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.
@@ -1,77 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module TUITD
4
- # Shared ANSI color constants and helpers.
5
- # Used by Screenshot, HtmlRenderer, and other color-aware renderers.
6
- module ANSIUtils
7
- ANSI_RGB = {
8
- "black" => [0x00, 0x00, 0x00],
9
- "red" => [0xAA, 0x00, 0x00],
10
- "green" => [0x00, 0xAA, 0x00],
11
- "yellow" => [0xAA, 0x55, 0x00],
12
- "blue" => [0x00, 0x00, 0xAA],
13
- "magenta" => [0xAA, 0x00, 0xAA],
14
- "cyan" => [0x00, 0xAA, 0xAA],
15
- "white" => [0xAA, 0xAA, 0xAA],
16
- "bright_black" => [0x55, 0x55, 0x55],
17
- "bright_red" => [0xFF, 0x55, 0x55],
18
- "bright_green" => [0x55, 0xFF, 0x55],
19
- "bright_yellow" => [0xFF, 0xFF, 0x55],
20
- "bright_blue" => [0x55, 0x55, 0xFF],
21
- "bright_magenta" => [0xFF, 0x55, 0xFF],
22
- "bright_cyan" => [0x55, 0xFF, 0xFF],
23
- "bright_white" => [0xFF, 0xFF, 0xFF],
24
- }.freeze
25
-
26
- CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
27
-
28
- ANSI_INDEX = %w[
29
- black red green yellow blue magenta cyan white
30
- bright_black bright_red bright_green bright_yellow
31
- bright_blue bright_magenta bright_cyan bright_white
32
- ].freeze
33
-
34
- DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
35
- DEFAULT_BG = [0x00, 0x00, 0x00].freeze
3
+ require "tans-parser"
36
4
 
37
- def resolve_color(name, fallback)
38
- case name
39
- when "default"
40
- fallback
41
- when /^#([0-9a-fA-F]{6})$/
42
- [::Regexp.last_match(1)[0..1].to_i(16), ::Regexp.last_match(1)[2..3].to_i(16),
43
- ::Regexp.last_match(1)[4..5].to_i(16),]
44
- when /\Acolor(\d+)\z/
45
- xterm_256(::Regexp.last_match(1).to_i)
46
- when /\Abright_(.+)\z/
47
- ANSI_RGB[name] || fallback
48
- else # rubocop:disable Lint/DuplicateBranch
49
- ANSI_RGB[name] || fallback
50
- end
51
- end
52
-
53
- def xterm_256(index) # rubocop:disable Naming/VariableNumber
54
- if index < 16
55
- name = ANSI_INDEX[index]
56
- ANSI_RGB[name] || DEFAULT_FG
57
- elsif index < 232
58
- r = CUBE[((index - 16) / 36) % 6]
59
- g = CUBE[((index - 16) / 6) % 6]
60
- b = CUBE[(index - 16) % 6]
61
- [r, g, b]
62
- else
63
- v = 8 + ((index - 232) * 10)
64
- [v, v, v]
65
- end
66
- end
67
-
68
- def _dig(hash, *keys)
69
- keys.each do |k|
70
- return nil unless hash
71
-
72
- hash = hash[k] || hash[k.to_s]
73
- end
74
- hash
75
- end
76
- end
5
+ module TUITD
6
+ ANSIUtils = TansParser::ANSIUtils
77
7
  end
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
@@ -6,6 +6,7 @@
6
6
  require "pty"
7
7
  require "io/console"
8
8
  require "json"
9
+ require "shellwords"
9
10
 
10
11
  module TUITD
11
12
  # Drives a TUI application in a pseudo-terminal (PTY).
@@ -20,15 +21,20 @@ module TUITD
20
21
  # driver.close
21
22
  #
22
23
  class Driver
24
+ FORBIDDEN_ENV = %w[PATH LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES
25
+ DYLD_FRAMEWORK_PATH RUBYOPT HOME RUBYLIB GEM_HOME GEM_PATH].freeze
26
+
23
27
  attr_reader :command, :state
24
28
 
25
- 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)
26
32
  @command = command
27
33
  @rows = rows
28
34
  @cols = cols
29
35
  @timeout = timeout
30
36
  @chdir = chdir
31
- @env = env
37
+ @env = sanitize_env(env)
32
38
  @state = nil
33
39
  @stdin = nil
34
40
  @stdout = nil
@@ -37,6 +43,7 @@ module TUITD
37
43
  @output_mutex = Mutex.new
38
44
  @reader_thread = nil
39
45
  @reader_running = false
46
+ @poll_interval = poll_interval
40
47
  end
41
48
 
42
49
  # Start the TUI application in a PTY
@@ -46,7 +53,8 @@ module TUITD
46
53
  spawn_opts = {}
47
54
  spawn_opts[:chdir] = @chdir if @chdir
48
55
 
49
- @stdout, @stdin, @pid = PTY.spawn(env, @command, spawn_opts)
56
+ cmd_args = Shellwords.shellsplit(@command)
57
+ @stdout, @stdin, @pid = PTY.spawn(env, *cmd_args, spawn_opts)
50
58
  @stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
51
59
  @wait_thr = Process.detach(@pid)
52
60
 
@@ -87,9 +95,34 @@ module TUITD
87
95
  end
88
96
  end
89
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
+
90
122
  # Wait until output contains the given text
91
123
  def wait_for_text(text)
92
124
  deadline = monotonic + @timeout
125
+ loop_count = 0
93
126
  loop do
94
127
  raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
95
128
 
@@ -97,7 +130,8 @@ module TUITD
97
130
  found = @output_mutex.synchronize { @output_buffer.include?(text) }
98
131
  break if found
99
132
 
100
- sleep 0.05
133
+ adaptive_sleep(loop_count)
134
+ loop_count += 1
101
135
  end
102
136
  refresh_state!
103
137
  end
@@ -106,28 +140,27 @@ module TUITD
106
140
  def wait_for_stable(stable_ms: 300)
107
141
  deadline = monotonic + @timeout
108
142
  last_change = monotonic
109
- last_grid = nil
143
+ last_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize }
144
+ loop_count = 0
110
145
 
111
146
  loop do
112
147
  raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline
113
148
 
114
- had_data = read_available!
149
+ read_available!
150
+ current_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize }
115
151
  process_alive = process_alive?
116
152
 
117
- if had_data
118
- current_grid = parse_grid_snapshot
119
- if current_grid != last_grid
120
- last_grid = current_grid
121
- last_change = monotonic
122
- end
153
+ if current_buffer_size != last_buffer_size
154
+ last_buffer_size = current_buffer_size
155
+ last_change = monotonic
123
156
  elsif !process_alive
124
- # Process exited and no more data — final state reached
125
157
  break
126
- 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
127
159
  break
128
160
  end
129
161
 
130
- sleep 0.05
162
+ adaptive_sleep(loop_count)
163
+ loop_count += 1
131
164
  end
132
165
  refresh_state!
133
166
  end
@@ -220,6 +253,7 @@ module TUITD
220
253
  def _start_reader_thread
221
254
  @reader_running = true
222
255
  @reader_thread = Thread.new do
256
+ loop_count = 0
223
257
  loop do
224
258
  break unless @reader_running
225
259
 
@@ -228,7 +262,8 @@ module TUITD
228
262
  rescue IOError, Errno::EIO
229
263
  break
230
264
  end
231
- sleep 0.05
265
+ adaptive_sleep(loop_count)
266
+ loop_count += 1
232
267
  end
233
268
  end
234
269
  end
@@ -246,17 +281,39 @@ module TUITD
246
281
  @reader_thread = nil
247
282
  end
248
283
 
284
+ def sanitize_env(env)
285
+ env.reject { |k, _| FORBIDDEN_ENV.include?(k.to_s.upcase) }
286
+ end
287
+
249
288
  def ensure_running!
250
289
  raise Error, "Driver not started. Call #start first." if @stdin.nil?
251
290
  raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
252
291
  end
253
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
+
254
308
  def read_available!
255
309
  return false unless @stdout
256
310
 
257
311
  data = @stdout.read_nonblock(4096)
258
312
 
259
- @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
260
317
 
261
318
  respond_to_dsr if data.include?("\e[6n")
262
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