tui-td 0.2.14 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +24 -6
- data/lib/tui_td/cli.rb +103 -2
- 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/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 +2 -0
- metadata +5 -3
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)
|
data/lib/tui_td/selector.rb
CHANGED
|
@@ -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
|