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.
@@ -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
@@ -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)