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.
@@ -272,7 +272,7 @@ module TUITD
272
272
  },
273
273
  {
274
274
  name: "tui_find_text",
275
- description: "Search for text or regex pattern in the current terminal state. Returns positions of all matches with surrounding context.",
275
+ description: "Search for text or regex pattern in the current terminal state. Returns positions of all matches with surrounding context. Supports match modes: partial (default, substring), exact (whole row), regex (Ruby regex).",
276
276
  inputSchema: {
277
277
  type: "object",
278
278
  properties: {
@@ -280,25 +280,148 @@ module TUITD
280
280
  type: "string",
281
281
  description: "Text or regex pattern to search for (e.g., 'error', 'ERROR|FAIL')",
282
282
  },
283
+ match: {
284
+ type: "string",
285
+ enum: %w[partial exact regex],
286
+ description: "Match mode: partial (substring), exact (whole row match), regex (Ruby regex). Default: partial.",
287
+ },
283
288
  },
284
289
  required: ["pattern"],
285
290
  },
286
291
  },
287
292
  {
288
293
  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.",
294
+ description: "Search for UI elements in the terminal state. Returns buttons, checkboxes, dialogs, statusbars, progress bars, inputs, labels, menus, and tabs detected by heuristic analysis. Optionally filter by role, text, checked, and/or disabled state.",
290
295
  inputSchema: {
291
296
  type: "object",
292
297
  properties: {
293
298
  role: {
294
299
  type: "string",
295
- description: "Filter by role: button, checkbox, dialog, statusbar, progress. Omit to return all.",
300
+ description: "Filter by role: button, checkbox, dialog, statusbar, progress, input, label, menu, tab. Omit to return all.",
296
301
  },
297
302
  text: {
298
303
  type: "string",
299
304
  description: "Filter by visible text (partial match). Optional.",
300
305
  },
306
+ checked: {
307
+ type: "boolean",
308
+ description: "Filter by checked state (checkboxes). Optional.",
309
+ },
310
+ disabled: {
311
+ type: "boolean",
312
+ description: "Filter by disabled state. Optional.",
313
+ },
314
+ },
315
+ },
316
+ },
317
+ {
318
+ name: "tui_element_actions",
319
+ description: "Get action hashes for a detected UI element. Returns click/type/press_key actions that can be used to interact with the element via tui_send/tui_send_key.",
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: {
323
+ role: {
324
+ type: "string",
325
+ description: "Element role: button, checkbox, dialog, statusbar, progress, input, label, menu, tab",
326
+ },
327
+ text: {
328
+ type: "string",
329
+ description: "Filter by visible text (partial match). Optional.",
330
+ },
331
+ },
332
+ required: ["role"],
333
+ },
334
+ },
335
+ {
336
+ name: "tui_diff",
337
+ description: "Compare the current terminal state against a previous state. Returns cell-level differences. Use chars_only: true to ignore color/style changes.",
338
+ inputSchema: {
339
+ type: "object",
340
+ properties: {
341
+ snapshot: {
342
+ type: "object",
343
+ description: "A previously saved state snapshot (from tui_state or a prior capture). Must include size, cursor, and rows keys.",
344
+ },
345
+ chars_only: {
346
+ type: "boolean",
347
+ description: "If true, only compare character differences (ignore color/style). Default: false.",
348
+ },
349
+ },
350
+ required: ["snapshot"],
351
+ },
352
+ },
353
+ {
354
+ name: "tui_annotate_element",
355
+ description: "Manually register a UI element at a specific region. The annotation is picked up by tui_find_elements for subsequent queries.",
356
+ inputSchema: {
357
+ type: "object",
358
+ properties: {
359
+ role: {
360
+ type: "string",
361
+ description: "Element role (e.g., button, dialog, statusbar, progress, input, label, menu, tab).",
362
+ },
363
+ row: {
364
+ type: "integer",
365
+ description: "Top row of the element.",
366
+ },
367
+ col: {
368
+ type: "integer",
369
+ description: "Left column of the element.",
370
+ },
371
+ width: {
372
+ type: "integer",
373
+ description: "Width in columns (default: 1).",
374
+ default: 1,
375
+ },
376
+ height: {
377
+ type: "integer",
378
+ description: "Height in rows (default: 1).",
379
+ default: 1,
380
+ },
381
+ text: {
382
+ type: "string",
383
+ description: "Visible text label for the element.",
384
+ },
385
+ },
386
+ required: %w[role row col],
387
+ },
388
+ },
389
+ {
390
+ name: "tui_save_snapshot",
391
+ description: "Save the current terminal state as a named snapshot to disk. Used to create golden masters for snapshot testing.",
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ name: {
396
+ type: "string",
397
+ description: "Snapshot name (e.g., 'login_screen'). Saved as <name>.json in the snapshot directory.",
398
+ },
399
+ type: {
400
+ type: "string",
401
+ enum: %w[text full png html all],
402
+ description: "Snapshot type. text=chars_only (default), full=chars+colors, png=screenshot, html=render, all=all formats.",
403
+ },
301
404
  },
405
+ required: ["name"],
406
+ },
407
+ },
408
+ {
409
+ name: "tui_assert_snapshot",
410
+ description: "Assert the current terminal state matches a named snapshot on disk. On first run, creates the snapshot automatically. Set UPDATE_SNAPSHOTS=1 to force update all snapshots.",
411
+ inputSchema: {
412
+ type: "object",
413
+ properties: {
414
+ name: {
415
+ type: "string",
416
+ description: "Snapshot name to compare against.",
417
+ },
418
+ type: {
419
+ type: "string",
420
+ enum: %w[text full png html all],
421
+ description: "Snapshot type. Default: text.",
422
+ },
423
+ },
424
+ required: ["name"],
302
425
  },
303
426
  },
304
427
  {
@@ -333,6 +456,11 @@ module TUITD
333
456
  when "tui_exit_status" then call_tui_exit_status
334
457
  when "tui_find_text" then call_tui_find_text(args)
335
458
  when "tui_find_elements" then call_tui_find_elements(args)
459
+ when "tui_element_actions" then call_tui_element_actions(args)
460
+ when "tui_diff" then call_tui_diff(args)
461
+ when "tui_annotate_element" then call_tui_annotate_element(args)
462
+ when "tui_save_snapshot" then call_tui_save_snapshot(args)
463
+ when "tui_assert_snapshot" then call_tui_assert_snapshot(args)
336
464
  when "tui_close" then call_tui_close
337
465
  else
338
466
  return error_response(id, -32_602, "Unknown tool: #{tool_name}")
@@ -492,8 +620,9 @@ module TUITD
492
620
  def call_tui_find_text(args)
493
621
  ensure_driver!
494
622
  pattern = args["pattern"] or return "ERROR: 'pattern' argument is required"
623
+ match_mode = (args["match"] || "partial").to_sym
495
624
  state = TUITD::State.new(@driver.state_data)
496
- matches = state.find_text(pattern)
625
+ matches = state.find_text(pattern, match: match_mode)
497
626
 
498
627
  if matches.empty?
499
628
  "No matches found for: #{pattern}"
@@ -513,17 +642,29 @@ module TUITD
513
642
 
514
643
  role = args["role"]&.to_sym
515
644
  text = args["text"]
645
+ checked = args.key?("checked") ? args["checked"] : nil
646
+ disabled = args.key?("disabled") ? args["disabled"] : nil
647
+
648
+ filters = {}
649
+ filters[:text] = text if text
650
+ filters[:checked] = checked unless checked.nil?
651
+ filters[:disabled] = disabled unless disabled.nil?
516
652
 
517
653
  elements = if role
518
- selector.get_by_role(role)
654
+ selector.get_by_role(role, **filters)
519
655
  else
520
- selector.elements
656
+ result = selector.elements
657
+ result = result.select { |e| e.text&.include?(text) } if text
658
+ result = result.select { |e| e.checked == checked } unless checked.nil?
659
+ result = result.select { |e| e.disabled == disabled } unless disabled.nil?
660
+ result
521
661
  end
522
- elements = elements.select { |e| e.text&.include?(text) } if text
523
662
 
524
663
  if elements.empty?
525
664
  desc = role ? "role :#{role}" : "any role"
526
665
  desc += " with text #{text.inspect}" if text
666
+ desc += " checked=#{checked}" unless checked.nil?
667
+ desc += " disabled=#{disabled}" unless disabled.nil?
527
668
  "No elements found for #{desc}"
528
669
  else
529
670
  lines = ["Found #{elements.size} element(s):"]
@@ -533,12 +674,115 @@ module TUITD
533
674
  parts << "at [#{el.row},#{el.col}]"
534
675
  parts << "#{el.width}x#{el.height}"
535
676
  parts << "(checked)" if el.checked
677
+ parts << "(disabled)" if el.disabled
678
+ parts << "(focused)" if el.focused
536
679
  lines << parts.join(" ")
537
680
  end
538
681
  lines.join("\n")
539
682
  end
540
683
  end
541
684
 
685
+ def call_tui_element_actions(args)
686
+ ensure_driver!
687
+ role = args["role"]&.to_sym || (return "ERROR: 'role' argument is required")
688
+ text = args["text"]
689
+
690
+ state = TUITD::State.new(@driver.state_data)
691
+ selector = TUITD::Selector.new(state)
692
+
693
+ filters = {}
694
+ filters[:text] = text if text
695
+ element = selector.get_by_role(role, **filters).first
696
+
697
+ unless element
698
+ desc = "No #{role} element"
699
+ desc << " with text #{text.inspect}" if text
700
+ desc << " found"
701
+ return desc
702
+ end
703
+
704
+ lines = ["Element: :#{element.role} #{element.text.inspect} at [#{element.row},#{element.col}]"]
705
+ lines << "Bounds: #{element.bounds.inspect}"
706
+ lines << "Actions:"
707
+ lines << " click: #{element.click.inspect}"
708
+ lines << " type(text): #{element.type("text").inspect}"
709
+ lines << " press_key: #{element.press_key(:enter).inspect}"
710
+ lines.join("\n")
711
+ end
712
+
713
+ def call_tui_diff(args)
714
+ ensure_driver!
715
+ snapshot = args["snapshot"] or return "ERROR: 'snapshot' argument is required"
716
+ chars_only = args["chars_only"] || false
717
+
718
+ current = TUITD::State.new(@driver.state_data)
719
+ snapshot = deep_symbolize(snapshot) if snapshot.is_a?(Hash)
720
+ diffs = current.diff(snapshot, chars_only: chars_only)
721
+
722
+ if diffs.empty?
723
+ "No differences found (chars_only: #{chars_only})"
724
+ else
725
+ lines = ["Found #{diffs.size} difference(s):"]
726
+ diffs.first(20).each do |d|
727
+ before_char = d[:before][:char].inspect
728
+ after_char = d[:after][:char].inspect
729
+ lines << " [#{d[:row]},#{d[:col]}] #{before_char} -> #{after_char}"
730
+ end
731
+ lines << " ... (truncated)" if diffs.size > 20
732
+ lines.join("\n")
733
+ end
734
+ end
735
+
736
+ def call_tui_annotate_element(args)
737
+ ensure_driver!
738
+ role = args["role"] or return "ERROR: 'role' argument is required"
739
+ row = args["row"] or return "ERROR: 'row' argument is required"
740
+ col = args["col"] or return "ERROR: 'col' argument is required"
741
+ width = args["width"] || 1
742
+ height = args["height"] || 1
743
+ text = args["text"]
744
+
745
+ state = TUITD::State.new(@driver.state_data)
746
+ state.annotate_role(role, row: row, col: col, width: width, height: height, text: text)
747
+
748
+ desc = "OK: Annotated :#{role} at [#{row},#{col}] #{width}x#{height}"
749
+ desc << " with text #{text.inspect}" if text
750
+ desc
751
+ end
752
+
753
+ def call_tui_save_snapshot(args)
754
+ ensure_driver!
755
+ name = args["name"] or return "ERROR: 'name' argument is required"
756
+ type = (args["type"] || "text").to_sym
757
+ snap = Snapshot.new(name, type: type)
758
+ snap.save(@driver.state_data)
759
+ "OK: Snapshot '#{name}' (type: #{type}) saved to #{snap.path}"
760
+ end
761
+
762
+ def call_tui_assert_snapshot(args)
763
+ ensure_driver!
764
+ name = args["name"] or return "ERROR: 'name' argument is required"
765
+ type = (args["type"] || "text").to_sym
766
+ snap = Snapshot.new(name, type: type)
767
+
768
+ if TUITD.configuration.update_snapshots?
769
+ snap.save(@driver.state_data)
770
+ return "OK: Snapshot '#{name}' (type: #{type}) updated (UPDATE_SNAPSHOTS mode)"
771
+ end
772
+
773
+ unless snap.exists?
774
+ snap.save(@driver.state_data)
775
+ return "OK: Snapshot '#{name}' (type: #{type}) created (first run)"
776
+ end
777
+
778
+ result = snap.compare(@driver.state_data)
779
+ if result.passed?
780
+ "OK: Snapshot '#{name}' (type: #{type}) matches"
781
+ else
782
+ "MISMATCH: #{result.message}"
783
+ end
784
+ end
785
+
542
786
  def call_tui_close
543
787
  @driver&.close
544
788
  @driver = nil
@@ -547,6 +791,17 @@ module TUITD
547
791
 
548
792
  # --- Helpers ---
549
793
 
794
+ def deep_symbolize(obj)
795
+ case obj
796
+ when Hash
797
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = deep_symbolize(v) }
798
+ when Array
799
+ obj.map { |v| deep_symbolize(v) }
800
+ else
801
+ obj
802
+ end
803
+ end
804
+
550
805
  def safe_path(user_path, ext:)
551
806
  default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
552
807
  resolved = File.expand_path(user_path || default)
@@ -5,27 +5,4 @@ require "tans-parser"
5
5
  module TUITD
6
6
  Selector = TansParser::Selector
7
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
8
  end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
+
5
+ require "json"
6
+ require "fileutils"
7
+
8
+ module TUITD
9
+ # Named, persisted snapshot for terminal state comparison.
10
+ #
11
+ # First run: saves the snapshot to disk (golden master).
12
+ # Subsequent runs: compares current state against the saved snapshot.
13
+ #
14
+ # Types:
15
+ # :text - chars_only comparison (ignores colors/styles), saved as JSON
16
+ # :full - full cell comparison (includes colors/styles), saved as JSON
17
+ # :png - screenshot PNG, compared byte-by-byte
18
+ # :html - HTML render, compared byte-by-byte
19
+ # :all - saves/compares all three formats
20
+ #
21
+ # Environment:
22
+ # UPDATE_SNAPSHOTS=1 — auto-update all snapshots instead of comparing
23
+ #
24
+ class Snapshot
25
+ EXTENSIONS = {
26
+ text: ".json",
27
+ full: ".json",
28
+ png: ".png",
29
+ html: ".html",
30
+ }.freeze
31
+
32
+ # Result of a snapshot comparison.
33
+ ComparisonResult = Struct.new(
34
+ :passed, :diff_count, :message, :details, :type,
35
+ keyword_init: true,
36
+ ) do
37
+ def passed?
38
+ passed
39
+ end
40
+ end
41
+
42
+ attr_reader :name, :type, :snapshot_dir
43
+
44
+ def initialize(name, type: :text, snapshot_dir: nil)
45
+ @name = name.to_s
46
+ @type = type.to_sym
47
+ @snapshot_dir = snapshot_dir || TUITD.configuration.snapshot_dir || "spec/snapshots"
48
+ FileUtils.mkdir_p(@snapshot_dir)
49
+ end
50
+
51
+ # Return the full filesystem path for the given extension.
52
+ def path(ext = EXTENSIONS.fetch(@type, ".json"))
53
+ File.join(@snapshot_dir, "#{@name}#{ext}")
54
+ end
55
+
56
+ # Check if the primary snapshot file exists on disk.
57
+ def exists?
58
+ if @type == :all
59
+ %i[text png html].all? { |t| File.exist?(path(EXTENSIONS[t])) }
60
+ else
61
+ File.exist?(path)
62
+ end
63
+ end
64
+
65
+ # Save terminal state as a named snapshot.
66
+ def save(state_data)
67
+ data = normalize(state_data)
68
+
69
+ File.write(path(".json"), JSON.pretty_generate(data)) if save_json?
70
+
71
+ Screenshot.new(data).render(path(".png")) if save_png?
72
+
73
+ return unless save_html?
74
+
75
+ HtmlRenderer.new(data).render(path(".html"))
76
+ end
77
+
78
+ # Compare current terminal state against the saved snapshot.
79
+ # Returns ComparisonResult.
80
+ def compare(state_data, ignore_rows: nil, region: nil)
81
+ if @type == :all
82
+ compare_all(state_data, ignore_rows: ignore_rows, region: region)
83
+ elsif png?
84
+ compare_png(state_data)
85
+ elsif html?
86
+ compare_html(state_data)
87
+ else
88
+ compare_json(state_data, chars_only: @type == :text, ignore_rows: ignore_rows, region: region)
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def normalize(state_data)
95
+ return state_data if state_data.is_a?(Hash)
96
+
97
+ # Extract hash from State objects
98
+ if state_data.respond_to?(:grid) && state_data.respond_to?(:rows)
99
+ return {
100
+ size: { rows: state_data.rows, cols: state_data.cols },
101
+ cursor: state_data.cursor,
102
+ rows: state_data.grid,
103
+ }
104
+ end
105
+
106
+ state_data
107
+ end
108
+
109
+ def save_json?
110
+ %i[text full all].include?(@type)
111
+ end
112
+
113
+ def save_png?
114
+ %i[png all].include?(@type)
115
+ end
116
+
117
+ def save_html?
118
+ %i[html all].include?(@type)
119
+ end
120
+
121
+ def png?
122
+ @type == :png
123
+ end
124
+
125
+ def html?
126
+ @type == :html
127
+ end
128
+
129
+ def compare_json(state_data, chars_only:, ignore_rows: nil, region: nil)
130
+ file = path(".json")
131
+ return missing_file_result(file) unless File.exist?(file)
132
+
133
+ begin
134
+ saved = JSON.parse(File.read(file), symbolize_names: true)
135
+ rescue JSON::ParserError => e
136
+ return ComparisonResult.new(
137
+ passed: false, diff_count: 1, type: @type,
138
+ message: "Failed to parse snapshot JSON: #{e.message}",
139
+ )
140
+ end
141
+
142
+ current = TUITD::State.new(normalize(state_data))
143
+ saved_state = TUITD::State.new(saved)
144
+ diffs = current.diff(saved_state, chars_only: chars_only)
145
+
146
+ # Restrict to specified region first, then remove ignored rows
147
+ if region
148
+ region = Array(region)
149
+ diffs.select! { |d| region.include?(d[:row]) }
150
+ end
151
+ if ignore_rows
152
+ ignored = Array(ignore_rows)
153
+ diffs.reject! { |d| ignored.include?(d[:row]) }
154
+ end
155
+
156
+ if diffs.empty?
157
+ ComparisonResult.new(
158
+ passed: true, diff_count: 0, type: @type,
159
+ message: "Snapshot '#{@name}' matches (#{@type})",
160
+ )
161
+ else
162
+ details = diffs.first(20).map do |d|
163
+ {
164
+ row: d[:row], col: d[:col],
165
+ before: d[:before], after: d[:after],
166
+ }
167
+ end
168
+ msg = ["Snapshot '#{@name}' has #{diffs.size} difference(s) (#{@type}):"]
169
+ diffs.first(20).each do |d|
170
+ msg << " [#{d[:row]},#{d[:col]}] #{d[:before][:char].inspect} -> #{d[:after][:char].inspect}"
171
+ end
172
+ msg << " ... (truncated)" if diffs.size > 20
173
+ ComparisonResult.new(
174
+ passed: false, diff_count: diffs.size, type: @type,
175
+ message: msg.join("\n"), details: details,
176
+ )
177
+ end
178
+ end
179
+
180
+ def compare_png(state_data)
181
+ file = path(".png")
182
+ return missing_file_result(file) unless File.exist?(file)
183
+
184
+ expected = File.binread(file)
185
+ tmp = File.join(@snapshot_dir, ".#{@name}_tmp.png")
186
+ Screenshot.new(state_data).render(tmp)
187
+ actual = File.binread(tmp)
188
+ FileUtils.rm_f(tmp)
189
+
190
+ if expected == actual
191
+ ComparisonResult.new(
192
+ passed: true, diff_count: 0, type: :png,
193
+ message: "Snapshot '#{@name}' matches (png)",
194
+ )
195
+ else
196
+ ComparisonResult.new(
197
+ passed: false, diff_count: 1, type: :png,
198
+ message: "Snapshot '#{@name}' does not match (png — pixel difference)",
199
+ )
200
+ end
201
+ end
202
+
203
+ def compare_html(state_data)
204
+ file = path(".html")
205
+ return missing_file_result(file) unless File.exist?(file)
206
+
207
+ expected = File.read(file)
208
+ actual = HtmlRenderer.new(state_data).to_html
209
+
210
+ if expected == actual
211
+ ComparisonResult.new(
212
+ passed: true, diff_count: 0, type: :html,
213
+ message: "Snapshot '#{@name}' matches (html)",
214
+ )
215
+ else
216
+ ComparisonResult.new(
217
+ passed: false, diff_count: 1, type: :html,
218
+ message: "Snapshot '#{@name}' does not match (html — content difference)",
219
+ )
220
+ end
221
+ end
222
+
223
+ def compare_all(state_data, ignore_rows: nil, region: nil)
224
+ results = []
225
+ results << compare_json(state_data, chars_only: true, ignore_rows: ignore_rows, region: region)
226
+ results << compare_png(state_data)
227
+ results << compare_html(state_data)
228
+
229
+ all_passed = results.all?(&:passed?)
230
+ messages = results.map(&:message).join("\n")
231
+ total_diffs = results.sum(&:diff_count)
232
+
233
+ ComparisonResult.new(
234
+ passed: all_passed, diff_count: total_diffs, type: :all,
235
+ message: messages,
236
+ )
237
+ end
238
+
239
+ def missing_file_result(file)
240
+ ComparisonResult.new(
241
+ passed: false, diff_count: 0, type: @type,
242
+ message: "Snapshot '#{@name}' not found at #{file}",
243
+ )
244
+ end
245
+ end
246
+ end
247
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity