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,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
 
@@ -60,6 +60,8 @@ module TUITD
60
60
  @driver&.close
61
61
  end
62
62
 
63
+ ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
64
+
63
65
  private
64
66
 
65
67
  def handle_request(request)
@@ -282,6 +284,23 @@ module TUITD
282
284
  required: ["pattern"],
283
285
  },
284
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
+ },
285
304
  {
286
305
  name: "tui_close",
287
306
  description: "Close the TUI application and clean up the PTY session. Call this when finished.",
@@ -313,7 +332,8 @@ module TUITD
313
332
  when "tui_wait_for_exit" then call_tui_wait_for_exit
314
333
  when "tui_exit_status" then call_tui_exit_status
315
334
  when "tui_find_text" then call_tui_find_text(args)
316
- 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
317
337
  else
318
338
  return error_response(id, -32_602, "Unknown tool: #{tool_name}")
319
339
  end
@@ -433,7 +453,7 @@ module TUITD
433
453
 
434
454
  def call_tui_screenshot(args)
435
455
  ensure_driver!
436
- path = args["path"] || "/tmp/tui_td_#{Time.now.to_i}.png"
456
+ path = safe_path(args["path"], ext: "png")
437
457
  result = @driver.screenshot(path)
438
458
  "OK: Screenshot saved to #{result}"
439
459
  end
@@ -444,8 +464,9 @@ module TUITD
444
464
  renderer = HtmlRenderer.new(@driver.state_data)
445
465
 
446
466
  if path
447
- renderer.render(path)
448
- "OK: HTML saved to #{path}"
467
+ safe = safe_path(path, ext: "html")
468
+ renderer.render(safe)
469
+ "OK: HTML saved to #{safe}"
449
470
  else
450
471
  renderer.to_html
451
472
  end
@@ -485,6 +506,39 @@ module TUITD
485
506
  end
486
507
  end
487
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
+
488
542
  def call_tui_close
489
543
  @driver&.close
490
544
  @driver = nil
@@ -493,6 +547,17 @@ module TUITD
493
547
 
494
548
  # --- Helpers ---
495
549
 
550
+ def safe_path(user_path, ext:)
551
+ default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
552
+ resolved = File.expand_path(user_path || default)
553
+
554
+ unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
555
+ raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
556
+ end
557
+
558
+ resolved
559
+ end
560
+
496
561
  def ensure_driver!
497
562
  raise Error, "No TUI session active. Call tui_start first." if @driver.nil?
498
563
  end
@@ -530,4 +595,4 @@ module TUITD
530
595
  end
531
596
  end
532
597
  end
533
- # 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
data/lib/tui_td/state.rb CHANGED
@@ -1,128 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
3
+ require "tans-parser"
4
4
 
5
5
  module TUITD
6
- # Represents the parsed state of a terminal screen.
7
- # Provides high-level query methods for AI consumption.
8
- class State
9
- attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
10
-
11
- def initialize(data)
12
- raise ArgumentError, "State data must include :size key" unless data[:size]
13
- raise ArgumentError, "State data must include :rows key" unless data[:rows]
14
-
15
- @rows = data[:size][:rows]
16
- @cols = data[:size][:cols]
17
- @grid = data[:rows]
18
- @cursor = data[:cursor]
19
-
20
- cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
21
- @cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
22
- @cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
23
-
24
- @mouse_mode = data[:mouse_mode] || :none
25
- @mouse_format = data[:mouse_format] || :normal
26
- end
27
-
28
- # Get plain text of the entire terminal (no ANSI)
29
- def plain_text
30
- @grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
31
- end
32
-
33
- # Get text at a specific position
34
- def text_at(row, col, length = @cols - col)
35
- return "" if row >= @rows || col >= @cols
36
-
37
- @grid[row][col, length].map { |c| c[:char] }.join
38
- end
39
-
40
- # Search for text across the entire terminal
41
- def find_text(pattern)
42
- results = []
43
- @grid.each_with_index do |row, ri|
44
- text = row.map { |c| c[:char] }.join
45
- pos = 0
46
- while (match = text.index(pattern, pos))
47
- results << { row: ri, col: match, text: pattern, full_line: text }
48
- pos = match + 1
49
- end
50
- end
51
- results
52
- end
53
-
54
- # Get the color at a specific cell
55
- def foreground_at(row, col)
56
- return nil if row >= @rows || col >= @cols
57
-
58
- @grid[row][col][:fg]
59
- end
60
-
61
- def background_at(row, col)
62
- return nil if row >= @rows || col >= @cols
63
-
64
- @grid[row][col][:bg]
65
- end
66
-
67
- def style_at(row, col)
68
- return nil if row >= @rows || col >= @cols
69
-
70
- cell = @grid[row][col]
71
- { bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
72
- end
73
-
74
- def to_ai_json
75
- h = extract_highlights
76
- cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
77
- r = cursor_info[:row] || cursor_info["row"] || 0
78
- c = cursor_info[:col] || cursor_info["col"] || 0
79
- styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
80
-
81
- summary = "Cursor at [#{r},#{c}]. "
82
- summary << "#{styled_count} styled row#{"s" unless styled_count == 1}"
83
- fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
84
- bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
85
- summary << ", colors: fg=#{fgs.sort.join(",")}" unless fgs.empty?
86
- summary << ", bg=#{bgs.sort.join(",")}" unless bgs.empty?
87
- summary << "."
88
-
89
- {
90
- size: { rows: @rows, cols: @cols },
91
- cursor: cursor_info,
92
- text: plain_text,
93
- highlights: h,
94
- summary: summary,
95
- }
96
- end
97
-
98
- private
99
-
100
- def extract_highlights
101
- highlights = []
102
- @grid.each_with_index do |row, ri|
103
- row_text = row.map { |c| c[:char] }.join
104
- next if row_text.strip.empty?
105
-
106
- fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
107
- .uniq.reject { |c| c == "default" }
108
- bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
109
- .uniq.reject { |c| c == "default" }
110
- bold = row.any? { |c| c[:bold] || c["bold"] }
111
- italic = row.any? { |c| c[:italic] || c["italic"] }
112
- underline = row.any? { |c| c[:underline] || c["underline"] }
113
-
114
- next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
115
-
116
- h = { row: ri, text: row_text }
117
- h[:bold] = true if bold
118
- h[:italic] = true if italic
119
- h[:underline] = true if underline
120
- h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
121
- h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
122
- highlights << h
123
- end
124
- highlights
125
- end
126
- end
6
+ State = TansParser::State
127
7
  end
128
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
3
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
4
4
 
5
5
  require "json"
6
+ require "shellwords"
6
7
 
7
8
  module TUITD
8
9
  # Executes TUI tests defined in JSON format.
@@ -108,29 +109,41 @@ module TUITD
108
109
  when "assert_style"
109
110
  ensure_driver!(driver)
110
111
  row, col = coords(step)
111
- state = State.new(driver.state_data)
112
- actual = state.style_at(row, col)
113
- expected = {}
114
- expected[:bold] = step[:bold] unless step[:bold].nil?
115
- expected[:italic] = step[:italic] unless step[:italic].nil?
116
- expected[:underline] = step[:underline] unless step[:underline].nil?
117
- 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
118
130
  if match
119
- 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}",)
120
133
  else
121
134
  Result.new(step: action, passed: false,
122
- message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}",)
135
+ message: "Style at [#{row},#{col}] is #{actual}, expected #{expected_style}",)
123
136
  end
124
137
 
125
138
  when "screenshot"
126
139
  ensure_driver!(driver)
127
- path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.png"
140
+ path = safe_output_path(value, "png")
128
141
  driver.screenshot(path)
129
142
  Result.new(step: action, passed: true, message: "Saved: #{path}")
130
143
 
131
144
  when "html"
132
145
  ensure_driver!(driver)
133
- path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.html"
146
+ path = safe_output_path(value, "html")
134
147
  HtmlRenderer.new(driver.state_data).render(path)
135
148
  Result.new(step: action, passed: true, message: "Saved: #{path}")
136
149
 
@@ -150,6 +163,19 @@ module TUITD
150
163
  Result.new(step: action, passed: false, message: "Exit status #{actual}, expected #{expected}")
151
164
  end
152
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
+
153
179
  when "close"
154
180
  driver&.close
155
181
  driver = nil
@@ -194,8 +220,40 @@ module TUITD
194
220
  }
195
221
  end
196
222
 
223
+ ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
224
+
197
225
  private
198
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
+
246
+ def safe_output_path(value, ext)
247
+ default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
248
+ resolved = File.expand_path(value.is_a?(String) ? value : default)
249
+
250
+ unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
251
+ raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
252
+ end
253
+
254
+ resolved
255
+ end
256
+
199
257
  def ensure_driver!(driver)
200
258
  raise Error, "No session. Add a 'start' step first." if driver.nil?
201
259
  end
@@ -209,7 +267,6 @@ module TUITD
209
267
 
210
268
  def check_text(driver, value, action)
211
269
  ensure_driver!(driver)
212
- state = State.new(driver.state_data)
213
270
  text = value.to_s
214
271
 
215
272
  if action == "assert_regex"
@@ -222,7 +279,17 @@ module TUITD
222
279
  pattern = text
223
280
  end
224
281
 
225
- 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
226
293
 
227
294
  case action
228
295
  when "assert_text"
@@ -252,17 +319,32 @@ module TUITD
252
319
  ensure_driver!(driver)
253
320
  row, col = coords(step)
254
321
  expected = step[:is] || step["is"]
255
- state = State.new(driver.state_data)
256
322
  label = property == :fg ? "FG" : "BG"
257
- actual = property == :fg ? state.foreground_at(row, col) : state.background_at(row, col)
258
323
 
259
- 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
260
342
  Result.new(step: step.keys.first.to_s, passed: true, message: "#{label} at [#{row},#{col}] is #{expected}")
261
343
  else
262
344
  Result.new(step: step.keys.first.to_s, passed: false,
263
- message: "#{label} at [#{row},#{col}] is #{actual}, expected #{expected}",)
345
+ message: "#{label} at [#{row},#{col}] is #{actual_val}, expected #{expected}",)
264
346
  end
265
347
  end
266
348
  end
267
349
  end
268
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
350
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.11"
4
+ VERSION = "0.2.13"
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.11
4
+ version: 0.2.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '3.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tans-parser
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: bundler-audit
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -159,6 +173,7 @@ files:
159
173
  - lib/tui_td/matchers.rb
160
174
  - lib/tui_td/mcp/server.rb
161
175
  - lib/tui_td/screenshot.rb
176
+ - lib/tui_td/selector.rb
162
177
  - lib/tui_td/state.rb
163
178
  - lib/tui_td/test_runner.rb
164
179
  - lib/tui_td/unifont_glyphs.rb