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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -1
- data/README.md +31 -9
- data/lib/tui_td/ansi_parser.rb +3 -727
- data/lib/tui_td/ansi_utils.rb +3 -73
- data/lib/tui_td/cli.rb +51 -0
- data/lib/tui_td/driver.rb +74 -17
- data/lib/tui_td/matchers.rb +131 -25
- data/lib/tui_td/mcp/server.rb +71 -6
- data/lib/tui_td/selector.rb +31 -0
- data/lib/tui_td/state.rb +2 -123
- data/lib/tui_td/test_runner.rb +101 -19
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +1 -0
- metadata +16 -1
data/lib/tui_td/ansi_utils.rb
CHANGED
|
@@ -1,77 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
read_available!
|
|
150
|
+
current_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize }
|
|
115
151
|
process_alive = process_alive?
|
|
116
152
|
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
158
|
+
elsif (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch
|
|
127
159
|
break
|
|
128
160
|
end
|
|
129
161
|
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/tui_td/matchers.rb
CHANGED
|
@@ -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
|
-
#
|
|
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 |
|
|
18
|
-
|
|
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 { |
|
|
23
|
-
failure_message_when_negated { |
|
|
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 |
|
|
48
|
+
match do |actual|
|
|
28
49
|
@regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
29
|
-
|
|
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 { |
|
|
34
|
-
failure_message_when_negated { |
|
|
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 |
|
|
44
|
-
|
|
45
|
-
|
|
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 |
|
|
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 |
|
|
61
|
-
|
|
62
|
-
|
|
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 |
|
|
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 |
|
|
79
|
-
@
|
|
80
|
-
|
|
81
|
-
|
|
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 #{@
|
|
112
|
+
"have style #{@expected_style.inspect} at [#{@row},#{@col}]"
|
|
86
113
|
end
|
|
87
|
-
failure_message do |
|
|
88
|
-
"expected style at [#{@row},#{@col}] to be #{@
|
|
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
|