tui-td 0.2.14 → 0.2.19
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 +85 -0
- data/README.md +76 -6
- data/lib/tui_td/cli.rb +229 -4
- data/lib/tui_td/configuration.rb +33 -0
- data/lib/tui_td/driver.rb +13 -0
- data/lib/tui_td/matchers.rb +258 -12
- data/lib/tui_td/mcp/server.rb +262 -7
- data/lib/tui_td/minitest/assertions.rb +263 -0
- data/lib/tui_td/selector.rb +0 -23
- data/lib/tui_td/snapshot.rb +247 -0
- data/lib/tui_td/test_runner.rb +65 -6
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +3 -0
- metadata +9 -5
data/lib/tui_td/matchers.rb
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
data/lib/tui_td/mcp/server.rb
CHANGED
|
@@ -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,
|
|
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)
|