tui-td 0.2.13 → 0.2.17

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: 88f553e060b5f2836421a48eadf8aa6b0341974e7081f7e9dd98e024b0665a59
4
- data.tar.gz: ddd0b1fd0f0666382b8f8d1fb9cb8b170cef2829299685da4a56d51d4cfc7945
3
+ metadata.gz: cadf6d0ad5e362e3704a7a87673e0ad7254573eba2f53575aea38fc45f949877
4
+ data.tar.gz: fbfd589b84e911d6c46b16976b34dc46943019c52bd7519b04ffc6422a71b659
5
5
  SHA512:
6
- metadata.gz: 5d478553f03b514f380d46c6aff519d3f6aec2f44dcd790ab51a162eca9cba8e73b136f79ebdf72cecaa7f66c9939fcb028edf80c1d904c00efb026ec0b92fa2
7
- data.tar.gz: d16aef5de09e4be72370113d5ec06ecebc6a3ec2ceda55837bf61ade4953906ec500513f24d3eef0127a8aa6a2919339660b922615f26c4b01848c496c6ff359
6
+ metadata.gz: c0e8f58b7f193a556d2dee0e1f8c15f005b669fe3823d31763e57efd8c35d511f5e2e5dbb9ff03a61abe534030c1673cc1de437bcbe705b8c008c1ff9af8878a
7
+ data.tar.gz: 520f31800ee26803d9ec56b9128b729f59a057246a11e2b70c1647f253b76bb95e06f99631fb4d6c23597415e8a48a92725e22c36b99faca932cb75d00c84d8c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.2.16
4
+
5
+ ### Added
6
+
7
+ - Driver#find_text: convenience delegation to State#find_text with match: modes
8
+ - Driver#snapshot: return a State snapshot for later diff comparison
9
+ - match_snapshot RSpec matcher: compare current state against saved snapshot,
10
+ supports chars_only: true to ignore color/style changes
11
+ - MCP tui_diff tool: compare current terminal state against a previous snapshot,
12
+ returns cell-level differences
13
+ - MCP tui_annotate_element tool: manually register UI element annotations that
14
+ are picked up by tui_find_elements
15
+ - Drive mode: snapshot and diff commands for interactive state comparison
16
+
17
+ ### Changed
18
+
19
+ - Tighten tans-parser dependency to ~> 0.1.3 (includes State#diff,
20
+ State#annotate_role, improved dialog/statusbar detection)
21
+
22
+ ## 0.2.15
23
+
24
+ ### Added
25
+
26
+ - Integration with tans-parser 0.1.2: new UI element roles (:input, :label, :menu, :tab)
27
+ - Filter kwargs on get_by_role: text:, checked:, disabled:
28
+ - Singular convenience methods: button(), checkbox(), input(), label(), menu(), tab(),
29
+ dialog(), statusbar(), progress_bar()
30
+ - Element action methods: click, type(text), press_key(key)
31
+ - Element predicates: checked?, disabled?
32
+ - Element bounds accessor
33
+ - disabled field on Element
34
+ - ScopedSelector via Selector#within(element, &block) with full query API
35
+ - State#find_text match modes: :partial (default), :exact, :regex
36
+ - RSpec matchers: have_input, have_label, have_menu, have_tab, have_statusbar,
37
+ have_progress_bar
38
+ - JSON test steps: assert_input, assert_label, assert_menu, assert_tab,
39
+ assert_statusbar, assert_progress_bar
40
+ - MCP tool: tui_element_actions — returns click/type/press_key action hashes
41
+ - Enhanced MCP tui_find_elements: checked/disabled filters, new roles, (disabled)/
42
+ (focused) output
43
+ - Enhanced MCP tui_find_text: match: parameter for exact/regex mode
44
+ - CLI drive mode: elements command now shows inputs, labels, menus, tabs
45
+ - Updated help texts (tui-td help test, tui-td help rspec) with new steps and matchers
46
+
47
+ ### Changed
48
+
49
+ - RSpec matchers (have_button, have_checkbox, have_role) now use tans-parser filter
50
+ kwargs instead of manual .select post-filtering
51
+ - JSON test runner check_role helper uses tans-parser filter kwargs
52
+ - have_checkbox now supports .unchecked chain
53
+
54
+ ### Removed
55
+
56
+ - Custom coordinate-based Selector#within (replaced by tans-parser's element-based
57
+ within with ScopedSelector)
58
+
59
+ ## 0.2.14
60
+
61
+ ### Fixed
62
+
63
+ - Tighten tans-parser dependency from `~> 0.1` to `~> 0.1.1` to ensure the required
64
+ Selector/Element classes are present (0.1.0 lacks them)
65
+
3
66
  ## 0.2.13
4
67
 
5
68
  ### Added
data/README.md CHANGED
@@ -286,7 +286,13 @@ tui-td test examples/echo_test.json
286
286
  | `assert_button` | `"text"` | Assert a button with given text is visible (`[ OK ]`, `(Cancel)`, `<Submit>`) |
287
287
  | `assert_dialog` | — | Assert a dialog (box-drawing region) is visible |
288
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`) |
289
+ | `assert_role` | `":button", "text": "OK"` | Generic role assertion (`:button`, `:checkbox`, `:dialog`, `:statusbar`, `:progress`, `:input`, `:label`, `:menu`, `:tab`) |
290
+ | `assert_input` | `"text"` (optional) | Assert an input field (`[____]`) is visible |
291
+ | `assert_label` | `"text"` | Assert a label (text ending with colon) is visible |
292
+ | `assert_menu` | `"text"` (optional) | Assert a menu bar or dropdown item is visible |
293
+ | `assert_tab` | `"text"` | Assert a tab (`[Tab1]`) is visible |
294
+ | `assert_statusbar` | — | Assert a status bar (bottom row with background) is visible |
295
+ | `assert_progress_bar` | `"text"` (optional) | Assert a progress bar (`[####]`) is visible |
290
296
  | `close` | — | Close the TUI |
291
297
 
292
298
  Example with `html` step for before/after snapshots:
@@ -364,7 +370,13 @@ end
364
370
  | `have_button("OK")` | Assert a button with given text is visible |
365
371
  | `have_dialog` | Assert a dialog (box-drawing region) is visible |
366
372
  | `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 |
373
+ | `have_role(:button, text: "OK", checked: true, disabled: false)` | Generic role assertion with optional text, checked, disabled filters |
374
+ | `have_input` | Assert an input field (`[____]`) is visible |
375
+ | `have_label("Name")` | Assert a label (text ending with colon) is visible |
376
+ | `have_menu` | Assert a menu bar or dropdown item is visible |
377
+ | `have_tab("File")` | Assert a tab is visible |
378
+ | `have_statusbar` | Assert a status bar (bottom row with background) is visible |
379
+ | `have_progress_bar("50%")` | Assert a progress bar (`[####]`) is visible |
368
380
  | `have_exit_status(N)` | Assert the driver process exit status equals N |
369
381
 
370
382
  ## MCP Server — AI Integration
@@ -390,8 +402,11 @@ tui-td serve
390
402
  | `tui_html_render` | Render terminal state as a self-contained HTML document. Returns HTML inline or saves to file. |
391
403
  | `tui_wait_for_exit` | Wait until the TUI process exits. Returns exit status. |
392
404
  | `tui_exit_status` | Get the exit status code (nil if still running). |
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. |
405
+ | `tui_find_text` | Search for text or regex in terminal state. Supports match modes: `partial` (default), `exact`, `regex`. |
406
+ | `tui_find_elements` | Detect UI elements (buttons, checkboxes, dialogs, inputs, labels, menus, tabs, etc.) with optional role, text, checked, and disabled filters. |
407
+ | `tui_element_actions` | Get click/type/press_key action hashes for a detected UI element. For AI-driven interaction. |
408
+ | `tui_diff` | Compare current state against a previous snapshot. Returns cell-level differences. |
409
+ | `tui_annotate_element` | Manually register a UI element annotation. Picked up by tui_find_elements. |
395
410
  | `tui_close` | Close the TUI and clean up. |
396
411
 
397
412
  ### MCP configuration
@@ -441,10 +456,13 @@ Add to your MCP client configuration:
441
456
  // 9. Find UI elements by role
442
457
  {"method": "tools/call", "params": {"name": "tui_find_elements", "arguments": {"role": "button"}}}
443
458
 
444
- // 10. Check exit status (or wait for exit)
459
+ // 10. Get actions for an element
460
+ {"method": "tools/call", "params": {"name": "tui_element_actions", "arguments": {"role": "button", "text": "OK"}}}
461
+
462
+ // 11. Check exit status (or wait for exit)
445
463
  {"method": "tools/call", "params": {"name": "tui_exit_status", "arguments": {}}}
446
464
 
447
- // 11. Clean up
465
+ // 12. Clean up
448
466
  {"method": "tools/call", "params": {"name": "tui_close", "arguments": {}}}
449
467
  ```
450
468
 
data/lib/tui_td/cli.rb CHANGED
@@ -41,6 +41,10 @@ module TUITD
41
41
  opts.separator " state Show terminal state as pretty JSON"
42
42
  opts.separator " raw Show raw ANSI output"
43
43
  opts.separator " elements Show detected UI elements (buttons, dialogs, etc.)"
44
+ opts.separator " snapshot <name> Save current state as named snapshot to disk"
45
+ opts.separator " snapshot Save current state in-memory (legacy)"
46
+ opts.separator " diff <name> Compare current state against named snapshot on disk"
47
+ opts.separator " diff Compare against in-memory snapshot (legacy)"
44
48
  opts.separator " key <name> Send keystroke (enter, tab, escape, up, down, left, right,"
45
49
  opts.separator " backspace, ctrl_c, ctrl_d)"
46
50
  opts.separator " <text> Send text to the TUI"
@@ -202,8 +206,53 @@ module TUITD
202
206
  puts "Buttons: #{selector.buttons.map { |e| "#{e.text}@[#{e.row},#{e.col}]" }.join(", ")}"
203
207
  puts "Checkboxes: #{selector.checkboxes.map { |e| "#{e.text} (#{e.checked ? "✓" : "☐"})" }.join(", ")}"
204
208
  puts "Dialogs: #{selector.dialogs.map { |e| "\"#{e.text}\" #{e.width}x#{e.height}" }.join(", ")}"
209
+ puts "Inputs: #{selector.inputs.map { |e| "[__]@[#{e.row},#{e.col}]" }.join(", ")}"
210
+ puts "Labels: #{selector.labels.map(&:text).join(", ")}"
211
+ puts "Menus: #{selector.menus.map(&:text).join(", ")}"
212
+ puts "Tabs: #{selector.tabs.map { |e| "#{e.text}#{" (focused)" if e.focused}" }.join(", ")}"
205
213
  puts "Statusbars: #{selector.statusbars.map(&:text).join(", ")}"
206
214
  puts "Progress: #{selector.progress_bars.map(&:text).join(", ")}"
215
+ elsif input.start_with?("snapshot ")
216
+ name = input.split(" ", 2).last.strip
217
+ unless name.empty?
218
+ snap = Snapshot.new(name)
219
+ snap.save(driver.state_data)
220
+ puts "Snapshot '#{name}' saved to #{snap.path}."
221
+ end
222
+ elsif input == "snapshot"
223
+ @last_snapshot = State.new(driver.state_data)
224
+ puts "In-memory snapshot saved."
225
+ elsif input.start_with?("diff ")
226
+ name = input.split(" ", 2).last.strip
227
+ unless name.empty?
228
+ snap = Snapshot.new(name)
229
+ unless snap.exists?
230
+ puts "No snapshot '#{name}' found at #{snap.path}."
231
+ next
232
+ end
233
+ result = snap.compare(driver.state_data)
234
+ if result.passed?
235
+ puts "No differences. Snapshot '#{name}' matches."
236
+ else
237
+ puts result.message
238
+ end
239
+ end
240
+ elsif input == "diff"
241
+ if @last_snapshot
242
+ current = State.new(driver.state_data)
243
+ diffs = current.diff(@last_snapshot)
244
+ if diffs.empty?
245
+ puts "No differences."
246
+ else
247
+ puts "#{diffs.size} difference(s):"
248
+ diffs.first(10).each do |d|
249
+ puts " [#{d[:row]},#{d[:col]}] #{d[:before][:char].inspect} -> #{d[:after][:char].inspect}"
250
+ end
251
+ puts " ..." if diffs.size > 10
252
+ end
253
+ else
254
+ puts "No snapshot saved. Use 'snapshot <name>' or 'snapshot' first."
255
+ end
207
256
  elsif input.start_with?("key ")
208
257
  driver.send_keys(input.split(" ", 2).last.to_sym)
209
258
  else
@@ -433,7 +482,27 @@ module TUITD
433
482
 
434
483
  {"assert_role": ":button", "text": "OK"}
435
484
  Generic role assertion. Accepts :button, :checkbox, :dialog,
436
- :statusbar, :progress. Optional "text" filter.
485
+ :statusbar, :progress, :input, :label, :menu, :tab.
486
+ Optional "text", "checked", and "disabled" filters.
487
+
488
+ {"assert_input": true} or {"assert_input": "text"}
489
+ Assert that an input field ([____]) is visible. Optional text
490
+ filter to match adjacent label.
491
+
492
+ {"assert_label": "Name"}
493
+ Assert that a label (text ending with colon) is visible.
494
+
495
+ {"assert_menu": true} or {"assert_menu": "File | Edit"}
496
+ Assert that a menu bar or dropdown item is visible.
497
+
498
+ {"assert_tab": "File"}
499
+ Assert that a tab ([Tab1]) is visible.
500
+
501
+ {"assert_statusbar": true}
502
+ Assert that a status bar (bottom row with background) is visible.
503
+
504
+ {"assert_progress_bar": true} or {"assert_progress_bar": "50%"}
505
+ Assert that a progress bar ([#### ]) is visible.
437
506
 
438
507
  Example test file: examples/echo_test.json
439
508
  HELP
@@ -518,9 +587,41 @@ module TUITD
518
587
 
519
588
  have_role(:button, text: "OK")
520
589
  Generic role matcher. Accepts :button, :checkbox, :dialog,
521
- :statusbar, :progress. Optional text: filter.
590
+ :statusbar, :progress, :input, :label, :menu, :tab.
591
+ Optional text:, checked:, disabled: filters.
522
592
  Usage: expect(state).to have_role(:statusbar)
523
593
 
594
+ have_input
595
+ Passes if an input field ([____]) is visible.
596
+ Usage: expect(state).to have_input
597
+ Usage: expect(state).to have_input("Name")
598
+
599
+ have_label("Name")
600
+ Passes if a label (text ending with colon) is visible.
601
+ Usage: expect(state).to have_label("Name")
602
+
603
+ have_menu
604
+ Passes if a menu bar or dropdown item is visible.
605
+ Usage: expect(state).to have_menu
606
+
607
+ have_tab("File")
608
+ Passes if a tab is visible.
609
+ Usage: expect(state).to have_tab("File")
610
+
611
+ have_statusbar
612
+ Passes if a status bar (bottom row with background) is visible.
613
+ Usage: expect(state).to have_statusbar
614
+
615
+ have_progress_bar
616
+ Passes if a progress bar ([#### ]) is visible.
617
+ Usage: expect(state).to have_progress_bar
618
+
619
+ match_snapshot(snapshot, chars_only: false)
620
+ Passes if the current state matches a previously saved snapshot.
621
+ Use chars_only: true to ignore color/style changes.
622
+ Usage: pre = driver.snapshot; ... ; expect(driver).to match_snapshot(pre)
623
+ Usage: expect(state).to match_snapshot(snap, chars_only: true)
624
+
524
625
  Driver matchers (work on TUITD::Driver, not State)
525
626
  --------------------------------------------------
526
627
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUITD
4
+ # Global configuration for tui-td.
5
+ #
6
+ # Usage:
7
+ # TUITD.configure do |c|
8
+ # c.snapshot_dir = "spec/snapshots"
9
+ # end
10
+ #
11
+ class Configuration
12
+ attr_accessor :snapshot_dir
13
+
14
+ def initialize
15
+ @snapshot_dir = nil
16
+ end
17
+
18
+ # Check if UPDATE_SNAPSHOTS env var is set to update mode.
19
+ def update_snapshots?
20
+ %w[1 true].include?(ENV["UPDATE_SNAPSHOTS"].to_s)
21
+ end
22
+ end
23
+
24
+ class << self
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ def configure
30
+ yield configuration
31
+ end
32
+ end
33
+ end
data/lib/tui_td/driver.rb CHANGED
@@ -211,6 +211,19 @@ module TUITD
211
211
  Screenshot.new(@state).render(output_path)
212
212
  end
213
213
 
214
+ # Search for text or regex pattern in the current terminal state.
215
+ # Delegates to TansParser::State#find_text.
216
+ # Supports match modes: :partial (default, substring), :exact, :regex.
217
+ def find_text(pattern, match: :partial)
218
+ TUITD::State.new(state_data).find_text(pattern, match: match)
219
+ end
220
+
221
+ # Return a snapshot of the current terminal state as a TUITD::State object.
222
+ # Can be compared later with match_snapshot or State#diff.
223
+ def snapshot
224
+ TUITD::State.new(state_data)
225
+ end
226
+
214
227
  # Close the driver and clean up
215
228
  def close
216
229
  _stop_reader_thread
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/ModuleLength, Metrics/BlockLength, Metrics/ParameterLists, Layout/LineLength
4
+
3
5
  require "rspec/expectations"
4
6
 
5
7
  # RSpec matchers for TUITD::State and TUITD::Driver objects.
@@ -137,7 +139,7 @@ module TUITD
137
139
  RSpec::Matchers.define :have_button do |expected|
138
140
  match do |actual|
139
141
  Matchers.auto_wait(actual) do |s|
140
- Selector.new(s).get_by_text(expected).any? { |e| e.role == :button }
142
+ Selector.new(s).button(text: expected)
141
143
  end
142
144
  end
143
145
 
@@ -158,50 +160,59 @@ module TUITD
158
160
 
159
161
  RSpec::Matchers.define :have_checkbox do |expected|
160
162
  chain(:checked) { @checked = true }
163
+ chain(:unchecked) { @checked = false }
161
164
 
162
165
  match do |actual|
163
166
  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?
167
+ filters = { text: expected }
168
+ filters[:checked] = @checked unless @checked.nil?
169
+ Selector.new(s).checkbox(**filters)
168
170
  end
169
171
  end
170
172
 
171
173
  description do
172
174
  desc = "have checkbox #{expected.inspect}"
173
- desc += " (checked)" if @checked
175
+ desc += " (checked)" if @checked == true
176
+ desc += " (unchecked)" if @checked == false
174
177
  desc
175
178
  end
176
179
  failure_message do |_actual|
177
180
  desc = "expected terminal to have checkbox #{expected.inspect}"
178
- desc += " (checked)" if @checked
181
+ desc += " (checked)" if @checked == true
182
+ desc += " (unchecked)" if @checked == false
179
183
  desc
180
184
  end
181
185
  failure_message_when_negated do |_actual|
182
186
  desc = "expected terminal NOT to have checkbox #{expected.inspect}"
183
- desc += " (checked)" if @checked
187
+ desc += " (checked)" if @checked == true
188
+ desc += " (unchecked)" if @checked == false
184
189
  desc
185
190
  end
186
191
  end
187
192
 
188
- RSpec::Matchers.define :have_role do |role, text: nil|
193
+ RSpec::Matchers.define :have_role do |role, text: nil, checked: nil, disabled: nil|
189
194
  match do |actual|
190
195
  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?
196
+ filters = {}
197
+ filters[:text] = text if text
198
+ filters[:checked] = checked unless checked.nil?
199
+ filters[:disabled] = disabled unless disabled.nil?
200
+ Selector.new(s).get_by_role(role, **filters).any?
194
201
  end
195
202
  end
196
203
 
197
204
  description do
198
205
  desc = "have role :#{role}"
199
206
  desc += " with text #{text.inspect}" if text
207
+ desc += " (checked)" if checked == true
208
+ desc += " (disabled)" if disabled == true
200
209
  desc
201
210
  end
202
211
  failure_message do |_actual|
203
212
  desc = "expected terminal to have a :#{role}"
204
213
  desc += " with text #{text.inspect}" if text
214
+ desc += " (checked)" if checked == true
215
+ desc += " (disabled)" if disabled == true
205
216
  desc
206
217
  end
207
218
  failure_message_when_negated do |_actual|
@@ -210,5 +221,240 @@ module TUITD
210
221
  desc
211
222
  end
212
223
  end
224
+
225
+ # New role matchers (tans-parser 0.1.2)
226
+
227
+ RSpec::Matchers.define :have_input do |expected = nil|
228
+ match do |actual|
229
+ Matchers.auto_wait(actual) do |s|
230
+ if expected
231
+ Selector.new(s).input(text: expected)
232
+ else
233
+ Selector.new(s).inputs.any?
234
+ end
235
+ end
236
+ end
237
+
238
+ description do
239
+ expected ? "have input #{expected.inspect}" : "have an input field"
240
+ end
241
+ failure_message do |_actual|
242
+ expected ? "expected terminal to have an input #{expected.inspect}" : "expected terminal to have an input field"
243
+ end
244
+ failure_message_when_negated do |_actual|
245
+ expected ? "expected terminal NOT to have an input #{expected.inspect}" : "expected terminal NOT to have an input field"
246
+ end
247
+ end
248
+
249
+ RSpec::Matchers.define :have_label do |expected = nil|
250
+ match do |actual|
251
+ Matchers.auto_wait(actual) do |s|
252
+ if expected
253
+ Selector.new(s).label(text: expected)
254
+ else
255
+ Selector.new(s).labels.any?
256
+ end
257
+ end
258
+ end
259
+
260
+ description do
261
+ expected ? "have label #{expected.inspect}" : "have a label"
262
+ end
263
+ failure_message do |_actual|
264
+ expected ? "expected terminal to have a label #{expected.inspect}" : "expected terminal to have a label"
265
+ end
266
+ failure_message_when_negated do |_actual|
267
+ expected ? "expected terminal NOT to have a label #{expected.inspect}" : "expected terminal NOT to have a label"
268
+ end
269
+ end
270
+
271
+ RSpec::Matchers.define :have_menu do |expected = nil|
272
+ match do |actual|
273
+ Matchers.auto_wait(actual) do |s|
274
+ if expected
275
+ Selector.new(s).menu(text: expected)
276
+ else
277
+ Selector.new(s).menus.any?
278
+ end
279
+ end
280
+ end
281
+
282
+ description do
283
+ expected ? "have menu #{expected.inspect}" : "have a menu"
284
+ end
285
+ failure_message do |_actual|
286
+ expected ? "expected terminal to have a menu #{expected.inspect}" : "expected terminal to have a menu"
287
+ end
288
+ failure_message_when_negated do |_actual|
289
+ expected ? "expected terminal NOT to have a menu #{expected.inspect}" : "expected terminal NOT to have a menu"
290
+ end
291
+ end
292
+
293
+ RSpec::Matchers.define :have_tab do |expected = nil|
294
+ match do |actual|
295
+ Matchers.auto_wait(actual) do |s|
296
+ if expected
297
+ Selector.new(s).tab(text: expected)
298
+ else
299
+ Selector.new(s).tabs.any?
300
+ end
301
+ end
302
+ end
303
+
304
+ description do
305
+ expected ? "have tab #{expected.inspect}" : "have a tab"
306
+ end
307
+ failure_message do |_actual|
308
+ expected ? "expected terminal to have a tab #{expected.inspect}" : "expected terminal to have a tab"
309
+ end
310
+ failure_message_when_negated do |_actual|
311
+ expected ? "expected terminal NOT to have a tab #{expected.inspect}" : "expected terminal NOT to have a tab"
312
+ end
313
+ end
314
+
315
+ RSpec::Matchers.define :have_statusbar do |expected = nil|
316
+ match do |actual|
317
+ Matchers.auto_wait(actual) do |s|
318
+ if expected
319
+ Selector.new(s).statusbar(text: expected)
320
+ else
321
+ Selector.new(s).statusbars.any?
322
+ end
323
+ end
324
+ end
325
+
326
+ description do
327
+ expected ? "have status bar #{expected.inspect}" : "have a status bar"
328
+ end
329
+ failure_message do |_actual|
330
+ expected ? "expected terminal to have a status bar #{expected.inspect}" : "expected terminal to have a status bar"
331
+ end
332
+ failure_message_when_negated do |_actual|
333
+ expected ? "expected terminal NOT to have a status bar #{expected.inspect}" : "expected terminal NOT to have a status bar"
334
+ end
335
+ end
336
+
337
+ RSpec::Matchers.define :have_progress_bar do |expected = nil|
338
+ match do |actual|
339
+ Matchers.auto_wait(actual) do |s|
340
+ if expected
341
+ Selector.new(s).progress_bar(text: expected)
342
+ else
343
+ Selector.new(s).progress_bars.any?
344
+ end
345
+ end
346
+ end
347
+
348
+ description do
349
+ expected ? "have progress bar #{expected.inspect}" : "have a progress bar"
350
+ end
351
+ failure_message do |_actual|
352
+ expected ? "expected terminal to have a progress bar #{expected.inspect}" : "expected terminal to have a progress bar"
353
+ end
354
+ failure_message_when_negated do |_actual|
355
+ expected ? "expected terminal NOT to have a progress bar #{expected.inspect}" : "expected terminal NOT to have a progress bar"
356
+ end
357
+ end
358
+
359
+ # Snapshot comparison matcher — works with both State and Driver (auto-wait).
360
+ # Snapshot comparison matcher — supports both named (disk-based) and
361
+ # legacy in-memory State objects.
362
+ #
363
+ # Named snapshots (recommended):
364
+ # expect(driver).to match_snapshot("login_screen")
365
+ # expect(driver).to match_snapshot("login", type: :all, wait: true)
366
+ #
367
+ # First run creates the snapshot, subsequent runs compare.
368
+ # UPDATE_SNAPSHOTS=1 overwrites all snapshots.
369
+ #
370
+ # Legacy (backward compatible):
371
+ # pre = driver.snapshot
372
+ # expect(driver).to match_snapshot(pre, chars_only: true)
373
+ #
374
+ RSpec::Matchers.define :match_snapshot do |ref, type: nil, wait: false, chars_only: nil, ignore_rows: nil, region: nil|
375
+ match do |actual|
376
+ # Normalize type: backward compat for chars_only parameter
377
+ effective_type = type
378
+ if effective_type.nil? && !chars_only.nil?
379
+ effective_type = chars_only ? :text : :full
380
+ end
381
+ effective_type ||= :text
382
+
383
+ @snapshot_name = nil
384
+ @diff_result = nil
385
+
386
+ # Legacy path: snapshot_ref is a State object (responds to diff)
387
+ if ref.respond_to?(:diff)
388
+ Matchers.auto_wait(actual) do |s|
389
+ chars = effective_type == :text
390
+ diffs = ref.diff(s, chars_only: chars)
391
+ diffs.select! { |d| Array(region).include?(d[:row]) } if region
392
+ diffs.reject! { |d| Array(ignore_rows).include?(d[:row]) } if ignore_rows
393
+ @diff_result = diffs
394
+ @diff_result.empty?
395
+ end
396
+ else
397
+ # Named snapshot path
398
+ @snapshot_name = ref.to_s
399
+ snap = Snapshot.new(@snapshot_name, type: effective_type)
400
+
401
+ # Get state_data from actual
402
+ if wait && actual.respond_to?(:wait_for_stable)
403
+ begin
404
+ actual.wait_for_stable
405
+ rescue StandardError
406
+ nil
407
+ end
408
+ end
409
+ state_data = if actual.respond_to?(:state_data)
410
+ actual.state_data
411
+ elsif actual.respond_to?(:to_h)
412
+ actual.to_h
413
+ else
414
+ actual
415
+ end
416
+
417
+ if TUITD.configuration.update_snapshots?
418
+ snap.save(state_data)
419
+ true
420
+ elsif !snap.exists?
421
+ snap.save(state_data)
422
+ @diff_result = TUITD::Snapshot::ComparisonResult.new(passed: true, diff_count: 0, type: effective_type,
423
+ message: "Snapshot '#{@snapshot_name}' created (#{effective_type})",)
424
+ true
425
+ else
426
+ @diff_result = snap.compare(state_data, ignore_rows: ignore_rows, region: region)
427
+ @diff_result.passed?
428
+ end
429
+ end
430
+ end
431
+
432
+ description do
433
+ if @snapshot_name
434
+ "match snapshot #{@snapshot_name.inspect} (type: #{effective_type})"
435
+ else
436
+ desc = "match snapshot"
437
+ desc << " (chars only)" if effective_type == :text
438
+ desc
439
+ end
440
+ end
441
+
442
+ failure_message do |_actual|
443
+ if @snapshot_name
444
+ "Snapshot #{@snapshot_name.inspect} does not match.\n#{@diff_result&.message}"
445
+ else
446
+ "expected terminal to match snapshot"
447
+ end
448
+ end
449
+
450
+ failure_message_when_negated do |_actual|
451
+ if @snapshot_name
452
+ "expected snapshot #{@snapshot_name.inspect} NOT to match, but it did."
453
+ else
454
+ "expected terminal NOT to match snapshot, but it did."
455
+ end
456
+ end
457
+ end
213
458
  end
214
459
  end
460
+ # rubocop:enable Metrics/ModuleLength, Metrics/BlockLength, Metrics/ParameterLists, Layout/LineLength