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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
4
|
+
|
|
5
|
+
require "minitest"
|
|
6
|
+
|
|
7
|
+
module TUITD
|
|
8
|
+
module Minitest
|
|
9
|
+
# Assertions for TUI testing with Minitest.
|
|
10
|
+
#
|
|
11
|
+
# Include this module in your Minitest test class:
|
|
12
|
+
#
|
|
13
|
+
# require "tui_td/minitest/assertions"
|
|
14
|
+
#
|
|
15
|
+
# class MyTUITest < Minitest::Test
|
|
16
|
+
# include TUITD::Minitest::Assertions
|
|
17
|
+
#
|
|
18
|
+
# def test_login_screen
|
|
19
|
+
# driver = TUITD::Driver.new("my_tui", rows: 24, cols: 80)
|
|
20
|
+
# driver.start
|
|
21
|
+
# assert_text(driver, "Welcome")
|
|
22
|
+
# assert_button(driver, "OK")
|
|
23
|
+
# refute_text(driver, "Error")
|
|
24
|
+
# ensure
|
|
25
|
+
# driver&.close
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# Auto-wait: When given a Driver, assertions wait up to 3 seconds.
|
|
30
|
+
# When given a State, assertions check immediately.
|
|
31
|
+
#
|
|
32
|
+
module Assertions
|
|
33
|
+
AUTO_WAIT_TIMEOUT = 3
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def auto_wait(actual, timeout: AUTO_WAIT_TIMEOUT, &predicate)
|
|
38
|
+
if actual.respond_to?(:wait_for)
|
|
39
|
+
begin
|
|
40
|
+
actual.wait_for(timeout: timeout, &predicate)
|
|
41
|
+
true
|
|
42
|
+
rescue TUITD::TimeoutError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
predicate.call(actual)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def state_from(actual)
|
|
51
|
+
if actual.respond_to?(:state_data)
|
|
52
|
+
TUITD::State.new(actual.state_data)
|
|
53
|
+
else
|
|
54
|
+
actual # State or raw hash — pass through
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
public
|
|
59
|
+
|
|
60
|
+
# --- Text / Regex / Color / Style ---
|
|
61
|
+
|
|
62
|
+
def assert_text(actual, expected)
|
|
63
|
+
result = auto_wait(actual) { |s| s.find_text(expected).any? }
|
|
64
|
+
assert(result, "Expected terminal to contain #{expected.inspect}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def refute_text(actual, expected)
|
|
68
|
+
result = auto_wait(actual) { |s| s.find_text(expected).empty? }
|
|
69
|
+
assert(result, "Expected terminal NOT to contain #{expected.inspect}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def assert_regex(actual, pattern)
|
|
73
|
+
regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
74
|
+
result = auto_wait(actual) { |s| s.find_text(regex).any? }
|
|
75
|
+
assert(result, "Expected terminal to match #{pattern.inspect}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def refute_regex(actual, pattern)
|
|
79
|
+
regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
80
|
+
result = auto_wait(actual) { |s| s.find_text(regex).empty? }
|
|
81
|
+
assert(result, "Expected terminal NOT to match #{pattern.inspect}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def assert_fg(actual, expected, row:, col:)
|
|
85
|
+
result = auto_wait(actual) { |s| s.foreground_at(row, col) == expected }
|
|
86
|
+
actual_fg = state_from(actual).foreground_at(row, col)
|
|
87
|
+
assert(result, "Expected FG at [#{row},#{col}] to be #{expected.inspect}, but was #{actual_fg.inspect}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assert_bg(actual, expected, row:, col:)
|
|
91
|
+
result = auto_wait(actual) { |s| s.background_at(row, col) == expected }
|
|
92
|
+
actual_bg = state_from(actual).background_at(row, col)
|
|
93
|
+
assert(result, "Expected BG at [#{row},#{col}] to be #{expected.inspect}, but was #{actual_bg.inspect}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def assert_style(actual, row:, col:, **expected_styles)
|
|
97
|
+
result = auto_wait(actual) do |s|
|
|
98
|
+
style = s.style_at(row, col)
|
|
99
|
+
expected_styles.all? { |k, v| style[k] == v }
|
|
100
|
+
end
|
|
101
|
+
actual_style = state_from(actual).style_at(row, col)
|
|
102
|
+
assert(result,
|
|
103
|
+
"Expected style at [#{row},#{col}] to be #{expected_styles.inspect}, but was #{actual_style.inspect}",)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def assert_exit_status(actual, expected)
|
|
107
|
+
status = actual.exitstatus
|
|
108
|
+
assert(status == expected, "Expected exit status #{expected}, but was #{status}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# --- Selector-based ---
|
|
112
|
+
|
|
113
|
+
def assert_button(actual, expected)
|
|
114
|
+
result = auto_wait(actual) { |s| TUITD::Selector.new(s).button(text: expected) }
|
|
115
|
+
assert(result, "Expected terminal to have a button #{expected.inspect}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def refute_button(actual, expected)
|
|
119
|
+
result = auto_wait(actual) { |s| TUITD::Selector.new(s).button(text: expected).nil? }
|
|
120
|
+
assert(result, "Expected terminal NOT to have a button #{expected.inspect}")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def assert_dialog(actual)
|
|
124
|
+
result = auto_wait(actual) { |s| TUITD::Selector.new(s).dialogs.any? }
|
|
125
|
+
assert(result, "Expected terminal to have a dialog")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def refute_dialog(actual)
|
|
129
|
+
result = auto_wait(actual) { |s| TUITD::Selector.new(s).dialogs.empty? }
|
|
130
|
+
assert(result, "Expected terminal NOT to have a dialog")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def assert_checkbox(actual, expected, checked: nil, unchecked: nil)
|
|
134
|
+
checked = false if unchecked
|
|
135
|
+
result = auto_wait(actual) do |s|
|
|
136
|
+
filters = { text: expected }
|
|
137
|
+
filters[:checked] = checked unless checked.nil?
|
|
138
|
+
TUITD::Selector.new(s).checkbox(**filters)
|
|
139
|
+
end
|
|
140
|
+
msg = "Expected terminal to have checkbox #{expected.inspect}"
|
|
141
|
+
msg += " (checked)" if checked == true
|
|
142
|
+
msg += " (unchecked)" if checked == false
|
|
143
|
+
assert(result, msg)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def assert_role(actual, role, text: nil, checked: nil, disabled: nil)
|
|
147
|
+
result = auto_wait(actual) do |s|
|
|
148
|
+
filters = {}
|
|
149
|
+
filters[:text] = text if text
|
|
150
|
+
filters[:checked] = checked unless checked.nil?
|
|
151
|
+
filters[:disabled] = disabled unless disabled.nil?
|
|
152
|
+
TUITD::Selector.new(s).get_by_role(role, **filters).any?
|
|
153
|
+
end
|
|
154
|
+
msg = "Expected terminal to have role :#{role}"
|
|
155
|
+
msg += " with text #{text.inspect}" if text
|
|
156
|
+
assert(result, msg)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def assert_input(actual, expected = nil)
|
|
160
|
+
result = auto_wait(actual) do |s|
|
|
161
|
+
if expected
|
|
162
|
+
TUITD::Selector.new(s).input(text: expected)
|
|
163
|
+
else
|
|
164
|
+
TUITD::Selector.new(s).inputs.any?
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
msg = "Expected terminal to have an input field"
|
|
168
|
+
msg += " #{expected.inspect}" if expected
|
|
169
|
+
assert(result, msg)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def assert_label(actual, expected = nil)
|
|
173
|
+
result = auto_wait(actual) do |s|
|
|
174
|
+
if expected
|
|
175
|
+
TUITD::Selector.new(s).label(text: expected)
|
|
176
|
+
else
|
|
177
|
+
TUITD::Selector.new(s).labels.any?
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
msg = "Expected terminal to have a label"
|
|
181
|
+
msg += " #{expected.inspect}" if expected
|
|
182
|
+
assert(result, msg)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def assert_menu(actual, expected = nil)
|
|
186
|
+
result = auto_wait(actual) do |s|
|
|
187
|
+
if expected
|
|
188
|
+
TUITD::Selector.new(s).menu(text: expected)
|
|
189
|
+
else
|
|
190
|
+
TUITD::Selector.new(s).menus.any?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
msg = "Expected terminal to have a menu"
|
|
194
|
+
msg += " #{expected.inspect}" if expected
|
|
195
|
+
assert(result, msg)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def assert_tab(actual, expected = nil)
|
|
199
|
+
result = auto_wait(actual) do |s|
|
|
200
|
+
if expected
|
|
201
|
+
TUITD::Selector.new(s).tab(text: expected)
|
|
202
|
+
else
|
|
203
|
+
TUITD::Selector.new(s).tabs.any?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
msg = "Expected terminal to have a tab"
|
|
207
|
+
msg += " #{expected.inspect}" if expected
|
|
208
|
+
assert(result, msg)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def assert_statusbar(actual, expected = nil)
|
|
212
|
+
result = auto_wait(actual) do |s|
|
|
213
|
+
if expected
|
|
214
|
+
TUITD::Selector.new(s).statusbar(text: expected)
|
|
215
|
+
else
|
|
216
|
+
TUITD::Selector.new(s).statusbars.any?
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
msg = "Expected terminal to have a status bar"
|
|
220
|
+
msg += " #{expected.inspect}" if expected
|
|
221
|
+
assert(result, msg)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def assert_progress_bar(actual, expected = nil)
|
|
225
|
+
result = auto_wait(actual) do |s|
|
|
226
|
+
if expected
|
|
227
|
+
TUITD::Selector.new(s).progress_bar(text: expected)
|
|
228
|
+
else
|
|
229
|
+
TUITD::Selector.new(s).progress_bars.any?
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
msg = "Expected terminal to have a progress bar"
|
|
233
|
+
msg += " #{expected.inspect}" if expected
|
|
234
|
+
assert(result, msg)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# --- Snapshot ---
|
|
238
|
+
|
|
239
|
+
def assert_snapshot(actual, name, type: :text, wait: false, region: nil, ignore_rows: nil)
|
|
240
|
+
snap = TUITD::Snapshot.new(name.to_s, type: type)
|
|
241
|
+
|
|
242
|
+
state_data = if actual.respond_to?(:state_data)
|
|
243
|
+
actual.wait_for_stable if wait && actual.respond_to?(:wait_for_stable)
|
|
244
|
+
actual.state_data
|
|
245
|
+
elsif actual.respond_to?(:to_h)
|
|
246
|
+
actual.to_h
|
|
247
|
+
else
|
|
248
|
+
actual
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if TUITD.configuration.update_snapshots? || !snap.exists?
|
|
252
|
+
snap.save(state_data)
|
|
253
|
+
pass
|
|
254
|
+
else
|
|
255
|
+
result = snap.compare(state_data, ignore_rows: ignore_rows, region: region)
|
|
256
|
+
msg = result.passed? ? nil : "Snapshot '#{name}' does not match.\n#{result.message}"
|
|
257
|
+
assert(result.passed?, msg)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
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
|
data/lib/tui_td/test_runner.rb
CHANGED
|
@@ -170,11 +170,62 @@ module TUITD
|
|
|
170
170
|
check_role(driver, :dialog, nil)
|
|
171
171
|
|
|
172
172
|
when "assert_checkbox"
|
|
173
|
-
check_role(driver, :checkbox, value.to_s, checked: step[:checked])
|
|
173
|
+
check_role(driver, :checkbox, value.to_s, checked: step[:checked], disabled: step[:disabled])
|
|
174
174
|
|
|
175
175
|
when "assert_role"
|
|
176
176
|
role = step[:role]&.to_sym
|
|
177
|
-
check_role(driver, role, value.to_s)
|
|
177
|
+
check_role(driver, role, value.to_s, checked: step[:checked], disabled: step[:disabled])
|
|
178
|
+
|
|
179
|
+
when "assert_input"
|
|
180
|
+
check_role(driver, :input, value == true ? nil : value.to_s)
|
|
181
|
+
|
|
182
|
+
when "assert_label"
|
|
183
|
+
check_role(driver, :label, value == true ? nil : value.to_s)
|
|
184
|
+
|
|
185
|
+
when "assert_menu"
|
|
186
|
+
check_role(driver, :menu, value == true ? nil : value.to_s)
|
|
187
|
+
|
|
188
|
+
when "assert_tab"
|
|
189
|
+
check_role(driver, :tab, value == true ? nil : value.to_s)
|
|
190
|
+
|
|
191
|
+
when "assert_statusbar"
|
|
192
|
+
check_role(driver, :statusbar, value == true ? nil : value.to_s)
|
|
193
|
+
|
|
194
|
+
when "assert_progress_bar"
|
|
195
|
+
check_role(driver, :progress, value == true ? nil : value.to_s)
|
|
196
|
+
|
|
197
|
+
when "snapshot"
|
|
198
|
+
ensure_driver!(driver)
|
|
199
|
+
driver.wait_for_stable if step[:wait] || step["wait"]
|
|
200
|
+
snap_type = (step[:type] || step["type"] || :text).to_s.to_sym
|
|
201
|
+
snap = Snapshot.new(value, type: snap_type)
|
|
202
|
+
snap.save(driver.state_data)
|
|
203
|
+
Result.new(step: action, passed: true,
|
|
204
|
+
message: "Snapshot saved: #{value} (#{snap_type})",)
|
|
205
|
+
|
|
206
|
+
when "assert_snapshot"
|
|
207
|
+
ensure_driver!(driver)
|
|
208
|
+
driver.wait_for_stable if step[:wait] || step["wait"]
|
|
209
|
+
snap_type = (step[:type] || step["type"] || :text).to_s.to_sym
|
|
210
|
+
snap = Snapshot.new(value, type: snap_type)
|
|
211
|
+
|
|
212
|
+
if TUITD.configuration.update_snapshots?
|
|
213
|
+
snap.save(driver.state_data)
|
|
214
|
+
Result.new(step: action, passed: true,
|
|
215
|
+
message: "Snapshot updated: #{value} (#{snap_type})",)
|
|
216
|
+
elsif !snap.exists?
|
|
217
|
+
snap.save(driver.state_data)
|
|
218
|
+
Result.new(step: action, passed: true,
|
|
219
|
+
message: "Snapshot created: #{value} (#{snap_type})",)
|
|
220
|
+
else
|
|
221
|
+
result = snap.compare(driver.state_data)
|
|
222
|
+
msg = if result.passed?
|
|
223
|
+
"Snapshot matches: #{value}"
|
|
224
|
+
else
|
|
225
|
+
"Snapshot mismatch: #{value}\n#{result.message}"
|
|
226
|
+
end
|
|
227
|
+
Result.new(step: action, passed: result.passed?, message: msg)
|
|
228
|
+
end
|
|
178
229
|
|
|
179
230
|
when "close"
|
|
180
231
|
driver&.close
|
|
@@ -224,21 +275,29 @@ module TUITD
|
|
|
224
275
|
|
|
225
276
|
private
|
|
226
277
|
|
|
227
|
-
def check_role(driver, role, text, checked: nil)
|
|
278
|
+
def check_role(driver, role, text, checked: nil, disabled: nil)
|
|
228
279
|
ensure_driver!(driver)
|
|
229
280
|
state = State.new(driver.state_data)
|
|
230
281
|
selector = Selector.new(state)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
282
|
+
|
|
283
|
+
filters = {}
|
|
284
|
+
filters[:text] = text.to_s if text
|
|
285
|
+
filters[:checked] = checked unless checked.nil?
|
|
286
|
+
filters[:disabled] = disabled unless disabled.nil?
|
|
287
|
+
elements = selector.get_by_role(role, **filters)
|
|
234
288
|
|
|
235
289
|
action = "assert_#{role}"
|
|
236
290
|
if elements.any?
|
|
237
291
|
count = elements.size
|
|
238
292
|
desc = text ? "#{role} #{text.inspect}" : role.to_s
|
|
293
|
+
desc += " (checked)" if checked == true
|
|
294
|
+
desc += " (unchecked)" if checked == false
|
|
295
|
+
desc += " (disabled)" if disabled == true
|
|
239
296
|
Result.new(step: action, passed: true, message: "Found #{count} #{desc} element(s)")
|
|
240
297
|
else
|
|
241
298
|
desc = text ? "#{role} with text #{text.inspect}" : role.to_s
|
|
299
|
+
desc += " (checked)" if checked == true
|
|
300
|
+
desc += " (disabled)" if disabled == true
|
|
242
301
|
Result.new(step: action, passed: false, message: "No #{desc} found")
|
|
243
302
|
end
|
|
244
303
|
end
|
data/lib/tui_td/version.rb
CHANGED
data/lib/tui_td.rb
CHANGED
|
@@ -13,10 +13,13 @@ require_relative "tui_td/driver"
|
|
|
13
13
|
require_relative "tui_td/ansi_parser"
|
|
14
14
|
require_relative "tui_td/ansi_utils"
|
|
15
15
|
require_relative "tui_td/state"
|
|
16
|
+
require_relative "tui_td/configuration"
|
|
17
|
+
require_relative "tui_td/snapshot"
|
|
16
18
|
require_relative "tui_td/screenshot"
|
|
17
19
|
require_relative "tui_td/html_renderer"
|
|
18
20
|
require_relative "tui_td/test_runner"
|
|
19
21
|
require_relative "tui_td/selector"
|
|
22
|
+
require_relative "tui_td/minitest/assertions"
|
|
20
23
|
require_relative "tui_td/mcp/server"
|
|
21
24
|
require_relative "tui_td/cli"
|
|
22
25
|
|