tui-td 0.2.10 → 0.2.12

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,75 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module TUITD
4
- # Shared ANSI color constants and helpers.
5
- # Used by Screenshot, HtmlRenderer, and other color-aware renderers.
6
- module ANSIUtils
7
- ANSI_RGB = {
8
- "black" => [0x00, 0x00, 0x00],
9
- "red" => [0xAA, 0x00, 0x00],
10
- "green" => [0x00, 0xAA, 0x00],
11
- "yellow" => [0xAA, 0x55, 0x00],
12
- "blue" => [0x00, 0x00, 0xAA],
13
- "magenta" => [0xAA, 0x00, 0xAA],
14
- "cyan" => [0x00, 0xAA, 0xAA],
15
- "white" => [0xAA, 0xAA, 0xAA],
16
- "bright_black" => [0x55, 0x55, 0x55],
17
- "bright_red" => [0xFF, 0x55, 0x55],
18
- "bright_green" => [0x55, 0xFF, 0x55],
19
- "bright_yellow" => [0xFF, 0xFF, 0x55],
20
- "bright_blue" => [0x55, 0x55, 0xFF],
21
- "bright_magenta"=> [0xFF, 0x55, 0xFF],
22
- "bright_cyan" => [0x55, 0xFF, 0xFF],
23
- "bright_white" => [0xFF, 0xFF, 0xFF],
24
- }.freeze
25
-
26
- CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
27
-
28
- ANSI_INDEX = %w[
29
- black red green yellow blue magenta cyan white
30
- bright_black bright_red bright_green bright_yellow
31
- bright_blue bright_magenta bright_cyan bright_white
32
- ].freeze
33
-
34
- DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
35
- DEFAULT_BG = [0x00, 0x00, 0x00].freeze
3
+ require "tans-parser"
36
4
 
37
- def resolve_color(name, fallback)
38
- case name
39
- when "default"
40
- fallback
41
- when /^#([0-9a-fA-F]{6})$/
42
- [$1[0..1].to_i(16), $1[2..3].to_i(16), $1[4..5].to_i(16)]
43
- when /\Acolor(\d+)\z/
44
- xterm_256($1.to_i)
45
- when /\Abright_(.+)\z/
46
- ANSI_RGB[name] || fallback
47
- else
48
- ANSI_RGB[name] || fallback
49
- end
50
- end
51
-
52
- def xterm_256(index)
53
- if index < 16
54
- name = ANSI_INDEX[index]
55
- ANSI_RGB[name] || DEFAULT_FG
56
- elsif index < 232
57
- r = CUBE[((index - 16) / 36) % 6]
58
- g = CUBE[((index - 16) / 6) % 6]
59
- b = CUBE[(index - 16) % 6]
60
- [r, g, b]
61
- else
62
- v = 8 + (index - 232) * 10
63
- [v, v, v]
64
- end
65
- end
66
-
67
- def _dig(hash, *keys)
68
- keys.each do |k|
69
- return nil unless hash
70
- hash = hash[k] || hash[k.to_s]
71
- end
72
- hash
73
- end
74
- end
5
+ module TUITD
6
+ ANSIUtils = TansParser::ANSIUtils
75
7
  end
@@ -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
- command = nil
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 globals[:format] == :json || globals[:format] == :pretty_json
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 + "\n")
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" # clear screen, home cursor
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] ? 'PASSED' : 'FAILED'}"
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 OptionParser.new { |o| o.banner = "Usage: tui-td <command> [options]" }
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,8 +1,12 @@
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"
9
+ require "shellwords"
6
10
 
7
11
  module TUITD
8
12
  # Drives a TUI application in a pseudo-terminal (PTY).
@@ -17,6 +21,9 @@ module TUITD
17
21
  # driver.close
18
22
  #
19
23
  class Driver
24
+ FORBIDDEN_ENV = %w[PATH LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES
25
+ DYLD_FRAMEWORK_PATH RUBYOPT HOME RUBYLIB GEM_HOME GEM_PATH].freeze
26
+
20
27
  attr_reader :command, :state
21
28
 
22
29
  def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {})
@@ -25,7 +32,7 @@ module TUITD
25
32
  @cols = cols
26
33
  @timeout = timeout
27
34
  @chdir = chdir
28
- @env = env
35
+ @env = sanitize_env(env)
29
36
  @state = nil
30
37
  @stdin = nil
31
38
  @stdout = nil
@@ -38,12 +45,14 @@ module TUITD
38
45
 
39
46
  # Start the TUI application in a PTY
40
47
  def start
41
- env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.to_s }.merge(@env.transform_keys(&:to_s))
48
+ env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s,
49
+ "LINES" => @rows.to_s, }.merge(@env.transform_keys(&:to_s))
42
50
  spawn_opts = {}
43
51
  spawn_opts[:chdir] = @chdir if @chdir
44
52
 
45
- @stdout, @stdin, @pid = PTY.spawn(env, @command, spawn_opts)
46
- @stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
53
+ cmd_args = Shellwords.shellsplit(@command)
54
+ @stdout, @stdin, @pid = PTY.spawn(env, *cmd_args, spawn_opts)
55
+ @stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
47
56
  @wait_thr = Process.detach(@pid)
48
57
 
49
58
  # Read until initial output stabilizes
@@ -88,9 +97,11 @@ module TUITD
88
97
  deadline = monotonic + @timeout
89
98
  loop do
90
99
  raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
100
+
91
101
  read_available!
92
102
  found = @output_mutex.synchronize { @output_buffer.include?(text) }
93
103
  break if found
104
+
94
105
  sleep 0.05
95
106
  end
96
107
  refresh_state!
@@ -117,7 +128,7 @@ module TUITD
117
128
  elsif !process_alive
118
129
  # Process exited and no more data — final state reached
119
130
  break
120
- elsif last_grid && (monotonic - last_change) * 1000 >= stable_ms
131
+ elsif last_grid && (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch
121
132
  break
122
133
  end
123
134
 
@@ -134,6 +145,7 @@ module TUITD
134
145
  # Get the process exit status (nil if still running)
135
146
  def exitstatus
136
147
  return nil unless @wait_thr
148
+
137
149
  status = @wait_thr.value
138
150
  status&.exitstatus
139
151
  rescue NoMethodError
@@ -179,16 +191,32 @@ module TUITD
179
191
  if @pid
180
192
  begin
181
193
  if Process.waitpid(@pid, Process::WNOHANG).nil?
182
- Process.kill("TERM", @pid) rescue nil
194
+ begin
195
+ Process.kill("TERM", @pid)
196
+ rescue StandardError
197
+ nil
198
+ end
183
199
  sleep 0.05
184
- Process.kill("KILL", @pid) rescue nil
200
+ begin
201
+ Process.kill("KILL", @pid)
202
+ rescue StandardError
203
+ nil
204
+ end
185
205
  end
186
206
  rescue Errno::ECHILD
187
207
  # Already reaped by Process.detach
188
208
  end
189
209
  end
190
- @stdin&.close rescue nil
191
- @stdout&.close rescue nil
210
+ begin
211
+ @stdin&.close
212
+ rescue StandardError
213
+ nil
214
+ end
215
+ begin
216
+ @stdout&.close
217
+ rescue StandardError
218
+ nil
219
+ end
192
220
  @stdin = @stdout = @pid = nil
193
221
  end
194
222
 
@@ -199,6 +227,7 @@ module TUITD
199
227
  @reader_thread = Thread.new do
200
228
  loop do
201
229
  break unless @reader_running
230
+
202
231
  begin
203
232
  read_available!
204
233
  rescue IOError, Errno::EIO
@@ -211,11 +240,19 @@ module TUITD
211
240
 
212
241
  def _stop_reader_thread
213
242
  @reader_running = false
214
- if @reader_thread
215
- @reader_thread.join(1)
216
- @reader_thread.kill rescue nil
217
- @reader_thread = nil
243
+ return unless @reader_thread
244
+
245
+ @reader_thread.join(1)
246
+ begin
247
+ @reader_thread.kill
248
+ rescue StandardError
249
+ nil
218
250
  end
251
+ @reader_thread = nil
252
+ end
253
+
254
+ def sanitize_env(env)
255
+ env.reject { |k, _| FORBIDDEN_ENV.include?(k.to_s.upcase) }
219
256
  end
220
257
 
221
258
  def ensure_running!
@@ -265,6 +302,7 @@ module TUITD
265
302
 
266
303
  def process_alive?
267
304
  return false unless @pid
305
+
268
306
  Process.waitpid(@pid, Process::WNOHANG).nil?
269
307
  rescue Errno::ECHILD
270
308
  false
@@ -273,7 +311,9 @@ module TUITD
273
311
  def monotonic
274
312
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
275
313
  end
276
- end
314
+ end
277
315
 
278
316
  class TimeoutError < Error; end
279
317
  end
318
+ # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
319
+ # rubocop:enable Naming/PredicateMethod
@@ -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
- '<span class="line"></span>'
125
- else
126
- runs = build_runs(row, ri)
127
- spans = runs.map do |run|
128
- render_run(run)
129
- end
130
- %(<span class="line">#{spans.join}</span>)
131
- end
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 = (cell[:char] || cell["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 = is_cursor?(ri, ci)
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 !cursor_vis
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 is_cursor?(ri, ci)
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("#%02x%02x%02x", *rgb)
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
@@ -19,8 +19,8 @@ module TUITD
19
19
  end
20
20
 
21
21
  description { "have text #{expected.inspect}" }
22
- failure_message { |state| "expected terminal to contain #{expected.inspect}" }
23
- failure_message_when_negated { |state| "expected terminal NOT to contain #{expected.inspect}" }
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 { |state| "expected terminal to match #{pattern.inspect}" }
34
- failure_message_when_negated { |state| "expected terminal NOT to match #{pattern.inspect}" }
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) { |row, col| @row, @col = row, col }
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 |state|
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) { |row, col| @row, @col = row, col }
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 |state|
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) { |row, col| @row, @col = row, col }
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 |state|
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 |driver|
100
+ failure_message do |_driver|
92
101
  "expected exit status #{expected}, but was #{@actual}"
93
102
  end
94
- failure_message_when_negated do |driver|
103
+ failure_message_when_negated do |_driver|
95
104
  "expected exit status not to be #{expected}"
96
105
  end
97
106
  end