tui-td 0.2.9 → 0.2.11
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 +17 -0
- data/README.md +16 -1
- data/lib/tui_td/ansi_parser.rb +139 -131
- data/lib/tui_td/ansi_utils.rb +22 -20
- data/lib/tui_td/cairo_renderer.rb +5 -2
- data/lib/tui_td/cli.rb +12 -10
- data/lib/tui_td/driver.rb +43 -12
- data/lib/tui_td/html_renderer.rb +19 -17
- data/lib/tui_td/matchers.rb +21 -12
- data/lib/tui_td/mcp/server.rb +146 -76
- data/lib/tui_td/screenshot.rb +70 -52
- data/lib/tui_td/state.rb +11 -4
- data/lib/tui_td/test_runner.rb +25 -25
- data/lib/tui_td/unifont_glyphs.rb +2142 -2141
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +7 -3
- metadata +40 -11
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
|
|
4
|
+
|
|
3
5
|
module TUITD
|
|
4
6
|
module CairoRenderer
|
|
5
7
|
CELL_W = 8
|
|
@@ -76,9 +78,9 @@ module TUITD
|
|
|
76
78
|
CELL_W.times do |dx|
|
|
77
79
|
sum = 0
|
|
78
80
|
scale.times do |sy|
|
|
79
|
-
row_off = (dy * scale + sy) * stride
|
|
81
|
+
row_off = ((dy * scale) + sy) * stride
|
|
80
82
|
scale.times do |sx|
|
|
81
|
-
sum += data.getbyte(row_off + (dx * scale + sx) * 4 + 3)
|
|
83
|
+
sum += data.getbyte(row_off + (((dx * scale) + sx) * 4) + 3)
|
|
82
84
|
end
|
|
83
85
|
end
|
|
84
86
|
alpha_grid[dy][dx] = sum / scale_sq
|
|
@@ -107,3 +109,4 @@ module TUITD
|
|
|
107
109
|
end
|
|
108
110
|
end
|
|
109
111
|
end
|
|
112
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
|
data/lib/tui_td/cli.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
4
|
+
|
|
3
5
|
require "optparse"
|
|
4
6
|
|
|
5
7
|
module TUITD
|
|
@@ -11,7 +13,7 @@ module TUITD
|
|
|
11
13
|
|
|
12
14
|
def run(argv)
|
|
13
15
|
global_opts = {}
|
|
14
|
-
|
|
16
|
+
nil
|
|
15
17
|
command_opts = {}
|
|
16
18
|
|
|
17
19
|
OptionParser.new do |opts|
|
|
@@ -131,7 +133,7 @@ module TUITD
|
|
|
131
133
|
server = MCP::Server.new(
|
|
132
134
|
rows: globals[:rows] || 40,
|
|
133
135
|
cols: globals[:cols] || 120,
|
|
134
|
-
timeout: globals[:timeout] || 30
|
|
136
|
+
timeout: globals[:timeout] || 30,
|
|
135
137
|
)
|
|
136
138
|
server.start
|
|
137
139
|
end
|
|
@@ -148,7 +150,7 @@ module TUITD
|
|
|
148
150
|
|
|
149
151
|
driver.wait_for_stable
|
|
150
152
|
|
|
151
|
-
if
|
|
153
|
+
if %i[json pretty_json].include?(globals[:format])
|
|
152
154
|
puts driver.state_json(pretty: globals[:format] == :pretty_json)
|
|
153
155
|
else
|
|
154
156
|
_render_text(driver.state_data)
|
|
@@ -182,6 +184,7 @@ module TUITD
|
|
|
182
184
|
print "> "
|
|
183
185
|
input = $stdin.gets
|
|
184
186
|
break unless input
|
|
187
|
+
|
|
185
188
|
input = input.chomp
|
|
186
189
|
break if input == "exit"
|
|
187
190
|
|
|
@@ -195,7 +198,7 @@ module TUITD
|
|
|
195
198
|
elsif input.start_with?("key ")
|
|
196
199
|
driver.send_keys(input.split(" ", 2).last.to_sym)
|
|
197
200
|
else
|
|
198
|
-
driver.send(input
|
|
201
|
+
driver.send("#{input}\n")
|
|
199
202
|
end
|
|
200
203
|
end
|
|
201
204
|
rescue Interrupt
|
|
@@ -258,16 +261,14 @@ module TUITD
|
|
|
258
261
|
|
|
259
262
|
on_step = if verbose || live || step_mode
|
|
260
263
|
lambda do |info|
|
|
261
|
-
if live && info[:driver]
|
|
262
|
-
info[:driver].wait_for_stable(stable_ms: 200)
|
|
263
|
-
end
|
|
264
|
+
info[:driver].wait_for_stable(stable_ms: 200) if live && info[:driver]
|
|
264
265
|
if verbose
|
|
265
266
|
status = info[:result].passed ? "PASS" : "FAIL"
|
|
266
267
|
puts "[#{info[:index] + 1}/#{info[:total]}] #{info[:action]}: #{info[:result].message}"
|
|
267
268
|
puts " → #{status}"
|
|
268
269
|
end
|
|
269
270
|
if live && info[:driver]
|
|
270
|
-
print "\e[2J\e[H"
|
|
271
|
+
print "\e[2J\e[H" # clear screen, home cursor
|
|
271
272
|
_render_text(info[:driver].state_data)
|
|
272
273
|
end
|
|
273
274
|
if step_mode
|
|
@@ -285,7 +286,7 @@ module TUITD
|
|
|
285
286
|
|
|
286
287
|
puts
|
|
287
288
|
puts "Test: #{result[:name]}"
|
|
288
|
-
puts "Status: #{result[:passed] ?
|
|
289
|
+
puts "Status: #{result[:passed] ? "PASSED" : "FAILED"}"
|
|
289
290
|
puts "-" * 40
|
|
290
291
|
|
|
291
292
|
result[:results].each do |r|
|
|
@@ -312,7 +313,7 @@ module TUITD
|
|
|
312
313
|
end
|
|
313
314
|
|
|
314
315
|
def _help_main
|
|
315
|
-
puts
|
|
316
|
+
puts(OptionParser.new { |o| o.banner = "Usage: tui-td <command> [options]" })
|
|
316
317
|
puts
|
|
317
318
|
puts "For more: tui-td help test (JSON test step types)"
|
|
318
319
|
puts " tui-td help rspec (RSpec matchers)"
|
|
@@ -480,3 +481,4 @@ module TUITD
|
|
|
480
481
|
end
|
|
481
482
|
end
|
|
482
483
|
end
|
|
484
|
+
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
data/lib/tui_td/driver.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
4
|
+
# rubocop:disable Naming/PredicateMethod
|
|
5
|
+
|
|
3
6
|
require "pty"
|
|
4
7
|
require "io/console"
|
|
5
8
|
require "json"
|
|
@@ -38,12 +41,13 @@ module TUITD
|
|
|
38
41
|
|
|
39
42
|
# Start the TUI application in a PTY
|
|
40
43
|
def start
|
|
41
|
-
env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s,
|
|
44
|
+
env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s,
|
|
45
|
+
"LINES" => @rows.to_s, }.merge(@env.transform_keys(&:to_s))
|
|
42
46
|
spawn_opts = {}
|
|
43
47
|
spawn_opts[:chdir] = @chdir if @chdir
|
|
44
48
|
|
|
45
49
|
@stdout, @stdin, @pid = PTY.spawn(env, @command, spawn_opts)
|
|
46
|
-
@stdout.winsize = [@rows, @cols]
|
|
50
|
+
@stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
|
|
47
51
|
@wait_thr = Process.detach(@pid)
|
|
48
52
|
|
|
49
53
|
# Read until initial output stabilizes
|
|
@@ -88,9 +92,11 @@ module TUITD
|
|
|
88
92
|
deadline = monotonic + @timeout
|
|
89
93
|
loop do
|
|
90
94
|
raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
|
|
95
|
+
|
|
91
96
|
read_available!
|
|
92
97
|
found = @output_mutex.synchronize { @output_buffer.include?(text) }
|
|
93
98
|
break if found
|
|
99
|
+
|
|
94
100
|
sleep 0.05
|
|
95
101
|
end
|
|
96
102
|
refresh_state!
|
|
@@ -117,7 +123,7 @@ module TUITD
|
|
|
117
123
|
elsif !process_alive
|
|
118
124
|
# Process exited and no more data — final state reached
|
|
119
125
|
break
|
|
120
|
-
elsif last_grid && (monotonic - last_change) * 1000 >= stable_ms
|
|
126
|
+
elsif last_grid && (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch
|
|
121
127
|
break
|
|
122
128
|
end
|
|
123
129
|
|
|
@@ -134,6 +140,7 @@ module TUITD
|
|
|
134
140
|
# Get the process exit status (nil if still running)
|
|
135
141
|
def exitstatus
|
|
136
142
|
return nil unless @wait_thr
|
|
143
|
+
|
|
137
144
|
status = @wait_thr.value
|
|
138
145
|
status&.exitstatus
|
|
139
146
|
rescue NoMethodError
|
|
@@ -179,16 +186,32 @@ module TUITD
|
|
|
179
186
|
if @pid
|
|
180
187
|
begin
|
|
181
188
|
if Process.waitpid(@pid, Process::WNOHANG).nil?
|
|
182
|
-
|
|
189
|
+
begin
|
|
190
|
+
Process.kill("TERM", @pid)
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
183
194
|
sleep 0.05
|
|
184
|
-
|
|
195
|
+
begin
|
|
196
|
+
Process.kill("KILL", @pid)
|
|
197
|
+
rescue StandardError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
185
200
|
end
|
|
186
201
|
rescue Errno::ECHILD
|
|
187
202
|
# Already reaped by Process.detach
|
|
188
203
|
end
|
|
189
204
|
end
|
|
190
|
-
|
|
191
|
-
|
|
205
|
+
begin
|
|
206
|
+
@stdin&.close
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
begin
|
|
211
|
+
@stdout&.close
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
192
215
|
@stdin = @stdout = @pid = nil
|
|
193
216
|
end
|
|
194
217
|
|
|
@@ -199,6 +222,7 @@ module TUITD
|
|
|
199
222
|
@reader_thread = Thread.new do
|
|
200
223
|
loop do
|
|
201
224
|
break unless @reader_running
|
|
225
|
+
|
|
202
226
|
begin
|
|
203
227
|
read_available!
|
|
204
228
|
rescue IOError, Errno::EIO
|
|
@@ -211,11 +235,15 @@ module TUITD
|
|
|
211
235
|
|
|
212
236
|
def _stop_reader_thread
|
|
213
237
|
@reader_running = false
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
238
|
+
return unless @reader_thread
|
|
239
|
+
|
|
240
|
+
@reader_thread.join(1)
|
|
241
|
+
begin
|
|
242
|
+
@reader_thread.kill
|
|
243
|
+
rescue StandardError
|
|
244
|
+
nil
|
|
218
245
|
end
|
|
246
|
+
@reader_thread = nil
|
|
219
247
|
end
|
|
220
248
|
|
|
221
249
|
def ensure_running!
|
|
@@ -265,6 +293,7 @@ module TUITD
|
|
|
265
293
|
|
|
266
294
|
def process_alive?
|
|
267
295
|
return false unless @pid
|
|
296
|
+
|
|
268
297
|
Process.waitpid(@pid, Process::WNOHANG).nil?
|
|
269
298
|
rescue Errno::ECHILD
|
|
270
299
|
false
|
|
@@ -273,7 +302,9 @@ module TUITD
|
|
|
273
302
|
def monotonic
|
|
274
303
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
275
304
|
end
|
|
276
|
-
|
|
305
|
+
end
|
|
277
306
|
|
|
278
307
|
class TimeoutError < Error; end
|
|
279
308
|
end
|
|
309
|
+
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
310
|
+
# rubocop:enable Naming/PredicateMethod
|
data/lib/tui_td/html_renderer.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
4
|
+
|
|
3
5
|
require_relative "ansi_utils"
|
|
4
6
|
|
|
5
7
|
module TUITD
|
|
@@ -121,14 +123,14 @@ module TUITD
|
|
|
121
123
|
def render_body
|
|
122
124
|
lines = @grid.map.with_index do |row, ri|
|
|
123
125
|
line_html = if row.nil? || row.empty?
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
'<span class="line"></span>'
|
|
127
|
+
else
|
|
128
|
+
runs = build_runs(row, ri)
|
|
129
|
+
spans = runs.map do |run|
|
|
130
|
+
render_run(run)
|
|
131
|
+
end
|
|
132
|
+
%(<span class="line">#{spans.join}</span>)
|
|
133
|
+
end
|
|
132
134
|
line_html
|
|
133
135
|
end
|
|
134
136
|
|
|
@@ -140,7 +142,7 @@ module TUITD
|
|
|
140
142
|
current_run = nil
|
|
141
143
|
|
|
142
144
|
row.each_with_index do |cell, ci|
|
|
143
|
-
char =
|
|
145
|
+
char = cell[:char] || cell["char"] || " "
|
|
144
146
|
fg = cell[:fg] || cell["fg"] || "default"
|
|
145
147
|
bg = cell[:bg] || cell["bg"] || "default"
|
|
146
148
|
bold = cell[:bold] || cell["bold"] || false
|
|
@@ -149,7 +151,7 @@ module TUITD
|
|
|
149
151
|
blink = cell[:blink] || cell["blink"] || false
|
|
150
152
|
|
|
151
153
|
style_key = [fg, bg, bold, italic, underline, blink]
|
|
152
|
-
is_cur =
|
|
154
|
+
is_cur = cursor_at?(ri, ci)
|
|
153
155
|
|
|
154
156
|
if current_run && current_run[:key] == style_key && !current_run[:has_cursor] && !is_cur
|
|
155
157
|
current_run[:chars] << char
|
|
@@ -159,7 +161,7 @@ module TUITD
|
|
|
159
161
|
chars: [char],
|
|
160
162
|
style: cell_style(fg, bg, bold, italic, underline),
|
|
161
163
|
has_cursor: is_cur,
|
|
162
|
-
blink: blink
|
|
164
|
+
blink: blink,
|
|
163
165
|
}
|
|
164
166
|
runs << current_run
|
|
165
167
|
end
|
|
@@ -186,9 +188,7 @@ module TUITD
|
|
|
186
188
|
if run[:has_cursor]
|
|
187
189
|
classes << "cursor-cell"
|
|
188
190
|
cursor_vis = @cursor[:visible] != false && @cursor["visible"] != false
|
|
189
|
-
if
|
|
190
|
-
classes << "cursor-hidden"
|
|
191
|
-
else
|
|
191
|
+
if cursor_vis
|
|
192
192
|
style_val = @cursor[:style] || @cursor["style"]
|
|
193
193
|
case style_val
|
|
194
194
|
when 0, 1
|
|
@@ -204,6 +204,8 @@ module TUITD
|
|
|
204
204
|
when 6
|
|
205
205
|
classes << "cursor-bar"
|
|
206
206
|
end
|
|
207
|
+
else
|
|
208
|
+
classes << "cursor-hidden"
|
|
207
209
|
end
|
|
208
210
|
end
|
|
209
211
|
classes << "term-blink" if run[:blink]
|
|
@@ -213,12 +215,12 @@ module TUITD
|
|
|
213
215
|
%(<span#{cls}#{style}>#{chars}</span>)
|
|
214
216
|
end
|
|
215
217
|
|
|
216
|
-
def
|
|
218
|
+
def cursor_at?(ri, ci)
|
|
217
219
|
(@cursor[:row] || @cursor["row"]) == ri && (@cursor[:col] || @cursor["col"]) == ci
|
|
218
220
|
end
|
|
219
221
|
|
|
220
222
|
def css_color(rgb)
|
|
221
|
-
format("
|
|
223
|
+
format("#%<r>02x%<g>02x%<b>02x", r: rgb[0], g: rgb[1], b: rgb[2])
|
|
222
224
|
end
|
|
223
225
|
|
|
224
226
|
def escape_html(char)
|
|
@@ -230,6 +232,6 @@ module TUITD
|
|
|
230
232
|
else char
|
|
231
233
|
end
|
|
232
234
|
end
|
|
233
|
-
|
|
234
235
|
end
|
|
235
236
|
end
|
|
237
|
+
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
data/lib/tui_td/matchers.rb
CHANGED
|
@@ -19,8 +19,8 @@ module TUITD
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
description { "have text #{expected.inspect}" }
|
|
22
|
-
failure_message { |
|
|
23
|
-
failure_message_when_negated { |
|
|
22
|
+
failure_message { |_state| "expected terminal to contain #{expected.inspect}" }
|
|
23
|
+
failure_message_when_negated { |_state| "expected terminal NOT to contain #{expected.inspect}" }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
RSpec::Matchers.define :have_regex do |pattern|
|
|
@@ -30,12 +30,15 @@ module TUITD
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
description { "match regex #{pattern.inspect}" }
|
|
33
|
-
failure_message { |
|
|
34
|
-
failure_message_when_negated { |
|
|
33
|
+
failure_message { |_state| "expected terminal to match #{pattern.inspect}" }
|
|
34
|
+
failure_message_when_negated { |_state| "expected terminal NOT to match #{pattern.inspect}" }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
RSpec::Matchers.define :have_fg do |expected|
|
|
38
|
-
chain(:at)
|
|
38
|
+
chain(:at) do |row, col|
|
|
39
|
+
@row = row
|
|
40
|
+
@col = col
|
|
41
|
+
end
|
|
39
42
|
|
|
40
43
|
match do |state|
|
|
41
44
|
@actual = state.foreground_at(@row, @col)
|
|
@@ -43,13 +46,16 @@ module TUITD
|
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
description { "have foreground #{expected.inspect} at [#{@row},#{@col}]" }
|
|
46
|
-
failure_message do |
|
|
49
|
+
failure_message do |_state|
|
|
47
50
|
"expected FG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
RSpec::Matchers.define :have_bg do |expected|
|
|
52
|
-
chain(:at)
|
|
55
|
+
chain(:at) do |row, col|
|
|
56
|
+
@row = row
|
|
57
|
+
@col = col
|
|
58
|
+
end
|
|
53
59
|
|
|
54
60
|
match do |state|
|
|
55
61
|
@actual = state.background_at(@row, @col)
|
|
@@ -57,13 +63,16 @@ module TUITD
|
|
|
57
63
|
end
|
|
58
64
|
|
|
59
65
|
description { "have background #{expected.inspect} at [#{@row},#{@col}]" }
|
|
60
|
-
failure_message do |
|
|
66
|
+
failure_message do |_state|
|
|
61
67
|
"expected BG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
|
|
62
68
|
end
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
RSpec::Matchers.define :have_style do
|
|
66
|
-
chain(:at)
|
|
72
|
+
chain(:at) do |row, col|
|
|
73
|
+
@row = row
|
|
74
|
+
@col = col
|
|
75
|
+
end
|
|
67
76
|
chain(:with) { |expected| @expected = expected }
|
|
68
77
|
|
|
69
78
|
match do |state|
|
|
@@ -75,7 +84,7 @@ module TUITD
|
|
|
75
84
|
description do
|
|
76
85
|
"have style #{@expected.inspect} at [#{@row},#{@col}]"
|
|
77
86
|
end
|
|
78
|
-
failure_message do |
|
|
87
|
+
failure_message do |_state|
|
|
79
88
|
"expected style at [#{@row},#{@col}] to be #{@expected.inspect}, but was #{@actual.inspect}"
|
|
80
89
|
end
|
|
81
90
|
end
|
|
@@ -88,10 +97,10 @@ module TUITD
|
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
description { "have exit status #{expected}" }
|
|
91
|
-
failure_message do |
|
|
100
|
+
failure_message do |_driver|
|
|
92
101
|
"expected exit status #{expected}, but was #{@actual}"
|
|
93
102
|
end
|
|
94
|
-
failure_message_when_negated do |
|
|
103
|
+
failure_message_when_negated do |_driver|
|
|
95
104
|
"expected exit status not to be #{expected}"
|
|
96
105
|
end
|
|
97
106
|
end
|