tui-td 0.2.4 → 0.2.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97ed62eebe255ca568235b0ffe8bb428812d0dbc0408e24e768d76e8b11beb8a
4
- data.tar.gz: 8b66334f5408398dab8dcf02535e8473c2d9af7357f4fc1db45bc67ca792706c
3
+ metadata.gz: b5a923ea75814849ee5cf0633c81c09e2db156e47df52f6f92f2b314f743f3b7
4
+ data.tar.gz: c5cb55618e38bcc06a477bedce42d61d8f0c497ff38f887462943e8d094863b0
5
5
  SHA512:
6
- metadata.gz: 404c5b2c0c411b25846855d0d5ad297493be90672ee3eba53980df429ec1263170b605d023983a4fbc17c2b4563b10ff91c98324a08651cf0997d4a9cde21667
7
- data.tar.gz: 5b73ca7cccaa81b434ca418e5f246573033ac5abd6c18b7094adad6f65d8fa453be2e8c84d842f2ba82e182a9189989a2389599874e1ab9448606db30677d901
6
+ metadata.gz: 1d36aac819998194c942dc9fc16c96ef0b4ef9fa7d549649f4bdd78cd59687858939ef7e1303d215a13e1dcd0957350d3839e43af94d4b837eb509ea5541cb58
7
+ data.tar.gz: 3d9f2fe282a9e32a274cea860ef5592e7666ce48121d590395c926bd0119d0f970ad2d2f232b88b0323742df8ef42b18686cd60ca75007d9f739cc2e8f4bae5b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.2.5
4
+
5
+ - MCP smoke test expanded: 20 → 54 assertions, covers all 10 tools plus error paths (88% server coverage)
6
+ - Extracted `ansi_utils.rb` — shared ANSI helpers used by parser, renderer, and screenshot
7
+ - New RSpec specs for `ansi_utils`, enhanced specs for `test_runner`, `html_renderer`, `matchers`, `state`, `ansi_parser`
8
+
3
9
  ## 0.2.4
4
10
 
5
11
  - `assert_regex` JSON test step — match terminal output against a Ruby regex
data/README.md CHANGED
@@ -1,57 +1,23 @@
1
1
  # TUI Test Drive
2
2
 
3
- *Like Playwright or Puppeteer, but built specifically for Terminal UIs and optimized for AI Coding Agents.*
4
-
5
3
  Testing framework for Terminal User Interfaces (TUIs) with MCP support.
6
4
 
7
- A Ruby library, but language-agnostic through its JSON test format and MCP server — use it from Python, JavaScript, Go, or any other programming language on Linux and macOS.
8
-
9
5
  **tui-td** lets you:
10
6
  1. Start a TUI application in a virtual terminal (PTY)
11
7
  2. See the output — as structured JSON, plain text, PNG screenshots, or HTML renders
12
8
  3. Send input — keystrokes, text, control sequences
13
9
  4. Analyze output — find text, check colors, detect cursor position
14
10
  5. Loop — adjust and retest without manual intervention
15
- 6. Integrate — works with any language via JSON test files or MCP
16
11
 
17
12
  ## Installation
18
13
 
19
- ### 1. Install Ruby
20
-
21
- **rbenv (recommended):**
22
-
23
- ```bash
24
- # macOS
25
- brew install rbenv ruby-build
26
- echo 'eval "$(rbenv init - zsh)"' >> ~/.zshrc
27
-
28
- # Linux
29
- git clone https://github.com/rbenv/rbenv.git ~/.rbenv
30
- git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
31
- echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
32
- ```
33
-
34
- Then install Ruby 3 and activate it:
35
-
36
- ```bash
37
- rbenv install 3.4.1
38
- rbenv global 3.4.1
39
- ruby --version # must show 3.0+
40
- ```
41
-
42
- **Alternative — Homebrew (macOS):**
43
-
44
- ```bash
45
- brew install ruby
46
- ```
47
-
48
- ### 2. Install tui-td
14
+ Ruby 3.0+ is required. Install via [rbenv](https://github.com/rbenv/rbenv#installation) or `brew install ruby`.
49
15
 
50
16
  ```bash
51
17
  gem install tui-td
52
18
  ```
53
19
 
54
- ### 3. Test
20
+ Quick test:
55
21
 
56
22
  ```bash
57
23
  tui-td capture "echo Hello World"
@@ -124,20 +90,20 @@ Interactive commands (drive mode):
124
90
  exit Quit drive mode
125
91
 
126
92
  Global options:
127
- -r, --rows N Terminal rows (default: 40)
128
- -c, --cols N Terminal cols (default: 120)
129
- -t, --timeout N Timeout in seconds (default: 30)
130
- -C, --chdir PATH Working directory for the command
131
- --screenshot PATH Save PNG screenshot
132
- --html PATH Save HTML render for browser viewing
133
- --json Output state as compact JSON (includes raw ANSI)
134
- --pretty Output state as pretty JSON
135
- --text Output state as plain text table
136
- -v, --verbose Show each test step as it runs
137
- -l, --live Show terminal state after each test step (screen-refresh)
138
- -s, --step Pause after each test step for confirmation
139
- --version Show version
140
- -h, --help Show complete reference
93
+ -r, --rows N Terminal rows (default: 40)
94
+ -c, --cols N Terminal cols (default: 120)
95
+ -t, --timeout SECONDS Timeout in seconds (default: 30)
96
+ -C, --chdir PATH Working directory for the command
97
+ --screenshot PATH Save screenshot (e.g., output.png)
98
+ --html PATH Save HTML render (e.g., output.html)
99
+ --json Output state as compact JSON
100
+ --pretty Output state as pretty JSON
101
+ --text Output state as plain text table
102
+ -v, --verbose Show each test step as it runs
103
+ -l, --live Show terminal state after each step (screen-refresh)
104
+ -s, --step Pause after each test step for confirmation
105
+ --version Show version
106
+ -h, --help Show help
141
107
  ```
142
108
 
143
109
  `tui-td --help` serves as the full CLI reference. `tui-td help test` shows all JSON test
@@ -271,12 +237,18 @@ tui-td test examples/echo_test.json
271
237
  "rows": 24,
272
238
  "cols": 80,
273
239
  "timeout": 10,
240
+ "chdir": "/path/to/project",
241
+ "before_all": [
242
+ { "start": "my_tui_app", "env": { "DATABASE_URL": "test://" } }
243
+ ],
274
244
  "steps": [
275
- { "start": "my_tui_app" },
276
245
  { "wait_for_text": "> " },
277
246
  { "send": "hello\n" },
278
247
  { "assert_text": "hello" },
279
- { "assert_fg": [0, 0], "is": "cyan" },
248
+ { "assert_regex": "hello|world" },
249
+ { "assert_fg": [0, 0], "is": "cyan" }
250
+ ],
251
+ "after_all": [
280
252
  { "close": true }
281
253
  ]
282
254
  }
@@ -371,9 +343,11 @@ end
371
343
  | Matcher | Usage |
372
344
  |---------|-------|
373
345
  | `have_text("...")` | Assert text is present on screen |
346
+ | `have_regex(/pattern/)` | Assert regex pattern matches anywhere |
374
347
  | `have_fg("color").at(row, col)` | Assert foreground color at position |
375
348
  | `have_bg("color").at(row, col)` | Assert background color at position |
376
349
  | `have_style.at(row, col).with(bold: true, ...)` | Assert cell style |
350
+ | `have_exit_status(N)` | Assert the driver process exit status equals N |
377
351
 
378
352
  ## MCP Server — AI Integration
379
353
 
@@ -83,15 +83,13 @@ module TUITD
83
83
  # Parse raw terminal output into a structured state Hash
84
84
  def self.parse(raw, rows = 40, cols = 120)
85
85
  grid = Array.new(rows) do
86
- Array.new(cols) do
87
- { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false }
88
- end
86
+ Array.new(cols) { default_cell.dup }
89
87
  end
90
88
 
91
89
  cursor = { row: 0, col: 0 }
92
90
  attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false }
93
91
  saved_cursor = nil
94
- scroll_region = nil
92
+ scroll_region = { top: 0, bottom: rows - 1 }
95
93
  pending_dsr = false
96
94
 
97
95
  # Strip everything before the last full clear (if any)
@@ -103,11 +101,12 @@ module TUITD
103
101
  if processed[i] == "\e" && processed[i + 1] == "["
104
102
  # Find end of CSI sequence
105
103
  j = i + 2
106
- j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnR]/)
104
+ j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnRrsu]/)
107
105
  seq = processed[i..j]
108
106
 
109
- dsr = _apply_csi(seq, cursor, attrs, grid, rows, cols)
107
+ dsr, new_saved = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
110
108
  pending_dsr ||= dsr
109
+ saved_cursor = new_saved if new_saved
111
110
 
112
111
  i = j + 1
113
112
  elsif processed[i] == "\n" || processed[i] == "\r\n"
@@ -128,11 +127,20 @@ module TUITD
128
127
  # Bell — ignore
129
128
  i += 1
130
129
  elsif processed[i] == "\e"
131
- # Skip escape sequences:
132
- # CSI: \e[... (already handled above)
133
- # ISO 2022 charset: \e( B \e) 0 etc. (3 chars total)
134
- # Other: just the ESC
135
- if processed[i + 1] && processed[i + 1].match?(/[()*+\-.\/]/)
130
+ # Handle non-CSI escape sequences
131
+ if processed[i + 1] == "7"
132
+ # DECSC Save Cursor
133
+ saved_cursor = { row: cursor[:row], col: cursor[:col] }
134
+ i += 2
135
+ elsif processed[i + 1] == "8"
136
+ # DECRC — Restore Cursor
137
+ if saved_cursor
138
+ cursor[:row] = saved_cursor[:row]
139
+ cursor[:col] = saved_cursor[:col]
140
+ end
141
+ i += 2
142
+ elsif processed[i + 1] && processed[i + 1].match?(/[()*+\-.\/]/)
143
+ # ISO 2022 charset: \e( B \e) 0 etc. (3 chars total)
136
144
  i += 3
137
145
  else
138
146
  i += 1
@@ -151,14 +159,22 @@ module TUITD
151
159
  i += 1
152
160
  end
153
161
 
154
- # Handle scrolling
155
- if cursor[:row] >= rows
156
- scroll_lines = cursor[:row] - rows + 1
157
- grid.shift(scroll_lines)
158
- scroll_lines.times do
159
- grid << Array.new(cols) { { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false } }
162
+ # Handle scrolling within the defined scroll region
163
+ region_top = scroll_region[:top]
164
+ region_bottom = scroll_region[:bottom]
165
+
166
+ if cursor[:row] > region_bottom
167
+ scroll_lines = [cursor[:row] - region_bottom, rows].min
168
+ # Shift lines within the scroll region up
169
+ (region_top..(region_bottom - scroll_lines)).each do |ri|
170
+ src = ri + scroll_lines
171
+ grid[ri] = src <= region_bottom ? grid[src] : Array.new(cols) { default_cell.dup }
172
+ end
173
+ # Fill bottom of scroll region with blank lines
174
+ ((region_bottom - scroll_lines + 1)..region_bottom).each do |ri|
175
+ grid[ri] = Array.new(cols) { default_cell.dup }
160
176
  end
161
- cursor[:row] = rows - 1
177
+ cursor[:row] = region_bottom
162
178
  end
163
179
  end
164
180
 
@@ -211,15 +227,17 @@ module TUITD
211
227
  out
212
228
  end
213
229
 
214
- def self._apply_csi(seq, cursor, attrs, grid, rows, cols)
230
+ def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
215
231
  # Strip leading escape char if present
216
232
  cleaned = seq.sub(/^\e/, "")
217
- match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhlmnR])$/)
218
- return unless match
233
+ match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhlmnRrsu])$/)
234
+ return [false, nil] unless match
219
235
 
220
236
  params = match[1].split(";").map(&:to_i)
221
237
  command = match[2]
222
238
 
239
+ new_saved = nil
240
+
223
241
  case command
224
242
  when "m"
225
243
  _apply_sgr(params, attrs)
@@ -270,14 +288,36 @@ module TUITD
270
288
  next unless cursor[:row] < rows && cursor[:col] + i < cols
271
289
  grid[cursor[:row]][cursor[:col] + i][:char] = " "
272
290
  end
273
- when "h", "l" # DEC private mode set/reset — skip (alternate screen, cursor show/hide, etc.)
291
+ when "s" # DECSC Save Cursor (CSI variant)
292
+ new_saved = { row: cursor[:row], col: cursor[:col] }
293
+ when "u" # DECRC — Restore Cursor (CSI variant)
294
+ if saved_cursor
295
+ cursor[:row] = saved_cursor[:row]
296
+ cursor[:col] = saved_cursor[:col]
297
+ end
298
+ when "r" # DECSTBM — Set Scroll Region
299
+ top = (params[0] || 1) - 1
300
+ bottom = (params[1] || rows) - 1
301
+ top = top.clamp(0, rows - 1)
302
+ bottom = bottom.clamp(0, rows - 1)
303
+ if top < bottom
304
+ scroll_region[:top] = top
305
+ scroll_region[:bottom] = bottom
306
+ else
307
+ scroll_region[:top] = 0
308
+ scroll_region[:bottom] = rows - 1
309
+ end
310
+ cursor[:row] = 0
311
+ cursor[:col] = 0
312
+ when "h", "l" # DEC private mode set/reset — skip
274
313
  nil
275
314
  when "n" # DSR — Device Status Report request
276
- # \e[6n = request cursor position → caller must respond with \e[row;colR
277
- return params[0] == 6
315
+ return [params[0] == 6, nil]
278
316
  when "R" # DSR response (from terminal side) or CPR — ignore
279
317
  nil
280
318
  end
319
+
320
+ [false, new_saved]
281
321
  end
282
322
 
283
323
  def self._apply_sgr(params, attrs)
@@ -397,21 +437,25 @@ module TUITD
397
437
  def self._color_code(name, prefix)
398
438
  case name
399
439
  when "default" then nil
400
- when /^(bright_)?(.+)$/
401
- base_name = $2
402
- index = SGR_16_TO_NAME.key(base_name)
403
- index += 8 if $1 && index && index < 8
404
- index ? "#{prefix};5;#{index}" : nil
405
440
  when /^#([0-9a-fA-F]{6})$/
406
441
  r = $1[0..1].to_i(16)
407
442
  g = $1[2..3].to_i(16)
408
443
  b = $1[4..5].to_i(16)
409
444
  "#{prefix};2;#{r};#{g};#{b}"
445
+ when /^(bright_)?(.+)$/
446
+ base_name = $2
447
+ index = SGR_16_TO_NAME.key(base_name)
448
+ index += 8 if $1 && index && index < 8
449
+ index ? "#{prefix};5;#{index}" : nil
410
450
  else
411
451
  nil
412
452
  end
413
453
  end
414
454
 
455
+ def self.default_cell
456
+ { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false }
457
+ end
458
+
415
459
  # Extract a single UTF-8 character at position i in a binary string.
416
460
  # Returns [char_string, byte_length] or nil if the byte is not printable/valid.
417
461
  def self._utf8_char_at(str, i)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
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
36
+
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
75
+ end
@@ -1,39 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "ansi_utils"
4
+
3
5
  module TUITD
4
6
  # Renders terminal state as a self-contained HTML document.
5
7
  # Faithfully reproduces what a TUI application shows — colors, styles,
6
8
  # cursor position — so an LLM or human can "see" the terminal.
7
9
  class HtmlRenderer
8
- ANSI_RGB = {
9
- "black" => [0x00, 0x00, 0x00],
10
- "red" => [0xAA, 0x00, 0x00],
11
- "green" => [0x00, 0xAA, 0x00],
12
- "yellow" => [0xAA, 0x55, 0x00],
13
- "blue" => [0x00, 0x00, 0xAA],
14
- "magenta" => [0xAA, 0x00, 0xAA],
15
- "cyan" => [0x00, 0xAA, 0xAA],
16
- "white" => [0xAA, 0xAA, 0xAA],
17
- "bright_black" => [0x55, 0x55, 0x55],
18
- "bright_red" => [0xFF, 0x55, 0x55],
19
- "bright_green" => [0x55, 0xFF, 0x55],
20
- "bright_yellow" => [0xFF, 0xFF, 0x55],
21
- "bright_blue" => [0x55, 0x55, 0xFF],
22
- "bright_magenta"=> [0xFF, 0x55, 0xFF],
23
- "bright_cyan" => [0x55, 0xFF, 0xFF],
24
- "bright_white" => [0xFF, 0xFF, 0xFF],
25
- }.freeze
26
-
27
- CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
28
-
29
- ANSI_INDEX = %w[
30
- black red green yellow blue magenta cyan white
31
- bright_black bright_red bright_green bright_yellow
32
- bright_blue bright_magenta bright_cyan bright_white
33
- ].freeze
34
-
35
- DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
36
- DEFAULT_BG = [0x00, 0x00, 0x00].freeze
10
+ include ANSIUtils
37
11
 
38
12
  def initialize(state)
39
13
  @state = state
@@ -173,36 +147,6 @@ module TUITD
173
147
  @cursor[:row] == ri && @cursor[:col] == ci
174
148
  end
175
149
 
176
- def resolve_color(name, fallback)
177
- case name
178
- when "default"
179
- fallback
180
- when /^#([0-9a-fA-F]{6})$/
181
- [$1[0..1].to_i(16), $1[2..3].to_i(16), $1[4..5].to_i(16)]
182
- when /\Acolor(\d+)\z/
183
- xterm_256($1.to_i)
184
- when /\Abright_(.+)\z/
185
- ANSI_RGB[name] || fallback
186
- else
187
- ANSI_RGB[name] || fallback
188
- end
189
- end
190
-
191
- def xterm_256(index)
192
- if index < 16
193
- name = ANSI_INDEX[index]
194
- ANSI_RGB[name] || DEFAULT_FG
195
- elsif index < 232
196
- r = CUBE[((index - 16) / 36) % 6]
197
- g = CUBE[((index - 16) / 6) % 6]
198
- b = CUBE[(index - 16) % 6]
199
- [r, g, b]
200
- else
201
- v = 8 + (index - 232) * 10
202
- [v, v, v]
203
- end
204
- end
205
-
206
150
  def css_color(rgb)
207
151
  format("#%02x%02x%02x", *rgb)
208
152
  end
@@ -217,12 +161,5 @@ module TUITD
217
161
  end
218
162
  end
219
163
 
220
- def _dig(hash, *keys)
221
- keys.each do |k|
222
- return nil unless hash
223
- hash = hash[k] || hash[k.to_s]
224
- end
225
- hash
226
- end
227
164
  end
228
165
  end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "chunky_png"
4
+ require_relative "ansi_utils"
4
5
 
5
6
  module TUITD
6
7
  class Screenshot
8
+ include ANSIUtils
9
+
7
10
  CELL_W = 8
8
11
  CELL_H = 16
9
12
 
@@ -106,39 +109,6 @@ module TUITD
106
109
  ].freeze
107
110
  private_constant :FONT
108
111
 
109
- ANSI_RGB = {
110
- "black" => [0x00, 0x00, 0x00],
111
- "red" => [0xAA, 0x00, 0x00],
112
- "green" => [0x00, 0xAA, 0x00],
113
- "yellow" => [0xAA, 0x55, 0x00],
114
- "blue" => [0x00, 0x00, 0xAA],
115
- "magenta" => [0xAA, 0x00, 0xAA],
116
- "cyan" => [0x00, 0xAA, 0xAA],
117
- "white" => [0xAA, 0xAA, 0xAA],
118
- "bright_black" => [0x55, 0x55, 0x55],
119
- "bright_red" => [0xFF, 0x55, 0x55],
120
- "bright_green" => [0x55, 0xFF, 0x55],
121
- "bright_yellow" => [0xFF, 0xFF, 0x55],
122
- "bright_blue" => [0x55, 0x55, 0xFF],
123
- "bright_magenta"=> [0xFF, 0x55, 0xFF],
124
- "bright_cyan" => [0x55, 0xFF, 0xFF],
125
- "bright_white" => [0xFF, 0xFF, 0xFF],
126
- }.freeze
127
- private_constant :ANSI_RGB
128
-
129
- CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
130
- private_constant :CUBE
131
-
132
- ANSI_INDEX = %w[
133
- black red green yellow blue magenta cyan white
134
- bright_black bright_red bright_green bright_yellow
135
- bright_blue bright_magenta bright_cyan bright_white
136
- ].freeze
137
- private_constant :ANSI_INDEX
138
-
139
- DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
140
- DEFAULT_BG = [0x00, 0x00, 0x00].freeze
141
-
142
112
  def initialize(state)
143
113
  @state = state
144
114
  @rows = _dig(state, :size, :rows) || 40
@@ -191,36 +161,6 @@ module TUITD
191
161
  draw_underline(image, px, py, CELL_W, fg_rgb) if underline
192
162
  end
193
163
 
194
- def resolve_color(name, fallback)
195
- case name
196
- when "default"
197
- fallback
198
- when /^#([0-9a-fA-F]{6})$/
199
- [$1[0..1].to_i(16), $1[2..3].to_i(16), $1[4..5].to_i(16)]
200
- when /\Acolor(\d+)\z/
201
- xterm_256($1.to_i)
202
- when /\Abright_(.+)\z/
203
- ANSI_RGB[name] || fallback
204
- else
205
- ANSI_RGB[name] || fallback
206
- end
207
- end
208
-
209
- def xterm_256(index)
210
- if index < 16
211
- name = ANSI_INDEX[index]
212
- ANSI_RGB[name] || DEFAULT_FG
213
- elsif index < 232
214
- r = CUBE[((index - 16) / 36) % 6]
215
- g = CUBE[((index - 16) / 6) % 6]
216
- b = CUBE[(index - 16) % 6]
217
- [r, g, b]
218
- else
219
- v = 8 + (index - 232) * 10
220
- [v, v, v]
221
- end
222
- end
223
-
224
164
  def fill_rect(image, x, y, w, h, rgb)
225
165
  color = ChunkyPNG::Color.rgb(*rgb)
226
166
  h.times do |dy|
@@ -260,12 +200,5 @@ module TUITD
260
200
  w.times { |dx| image[px + dx, y] = color }
261
201
  end
262
202
 
263
- def _dig(hash, *keys)
264
- keys.each do |k|
265
- return nil unless hash
266
- hash = hash[k] || hash[k.to_s]
267
- end
268
- hash
269
- end
270
203
  end
271
204
  end
data/lib/tui_td/state.rb CHANGED
@@ -7,6 +7,9 @@ module TUITD
7
7
  attr_reader :rows, :cols, :grid, :cursor
8
8
 
9
9
  def initialize(data)
10
+ raise ArgumentError, "State data must include :size key" unless data[:size]
11
+ raise ArgumentError, "State data must include :rows key" unless data[:rows]
12
+
10
13
  @rows = data[:size][:rows]
11
14
  @cols = data[:size][:cols]
12
15
  @grid = data[:rows]
@@ -37,6 +37,12 @@ module TUITD
37
37
  @plan[:before_all] = @plan[:before_all]&.map { |s| s.transform_keys(&:to_sym) }
38
38
  @plan[:after_all] = @plan[:after_all]&.map { |s| s.transform_keys(&:to_sym) }
39
39
  @on_step = on_step
40
+ rescue JSON::ParserError => e
41
+ raise Error, "Invalid JSON: #{e.message}"
42
+ @plan[:steps] = @plan[:steps].map { |s| s.transform_keys(&:to_sym) }
43
+ @plan[:before_all] = @plan[:before_all]&.map { |s| s.transform_keys(&:to_sym) }
44
+ @plan[:after_all] = @plan[:after_all]&.map { |s| s.transform_keys(&:to_sym) }
45
+ @on_step = on_step
40
46
  end
41
47
 
42
48
  def run
@@ -88,141 +94,98 @@ module TUITD
88
94
  Result.new(step: action, passed: true, message: "Found: #{value}")
89
95
 
90
96
  when "wait_for_stable"
91
- ensure_driver!(driver)
92
- driver.wait_for_stable
93
- Result.new(step: action, passed: true, message: "Stable")
94
-
95
- when "assert_text"
96
- ensure_driver!(driver)
97
- state = State.new(driver.state_data)
98
- if state.find_text(value.to_s).any?
99
- Result.new(step: action, passed: true, message: "Text found: #{value}")
100
- else
101
- Result.new(step: action, passed: false, message: "Text NOT found: #{value}")
102
- end
97
+ ensure_driver!(driver)
98
+ driver.wait_for_stable
99
+ Result.new(step: action, passed: true, message: "Stable")
103
100
 
104
- when "assert_not_text"
105
- ensure_driver!(driver)
106
- state = State.new(driver.state_data)
107
- if state.find_text(value.to_s).any?
108
- Result.new(step: action, passed: false, message: "Text found but should not be: #{value}")
109
- else
110
- Result.new(step: action, passed: true, message: "Text not found: #{value}")
111
- end
101
+ when "assert_text", "assert_not_text", "assert_regex"
102
+ check_text(driver, value, action)
112
103
 
113
- when "assert_regex"
114
- ensure_driver!(driver)
115
- state = State.new(driver.state_data)
116
- pattern = Regexp.new(value.to_s)
117
- if state.find_text(pattern).any?
118
- Result.new(step: action, passed: true, message: "Regex matched: #{value}")
119
- else
120
- Result.new(step: action, passed: false, message: "Regex did not match: #{value}")
121
- end
104
+ when "assert_fg"
105
+ check_color(driver, step, :fg)
122
106
 
123
- when "assert_fg"
124
- ensure_driver!(driver)
125
- row, col = coords(step)
126
- expected = step[:is] || step["is"]
127
- state = State.new(driver.state_data)
128
- actual = state.foreground_at(row, col)
129
- if actual == expected
130
- Result.new(step: action, passed: true, message: "FG at [#{row},#{col}] is #{expected}")
131
- else
132
- Result.new(step: action, passed: false, message: "FG at [#{row},#{col}] is #{actual}, expected #{expected}")
133
- end
134
-
135
- when "assert_bg"
136
- ensure_driver!(driver)
137
- row, col = coords(step)
138
- expected = step[:is] || step["is"]
139
- state = State.new(driver.state_data)
140
- actual = state.background_at(row, col)
141
- if actual == expected
142
- Result.new(step: action, passed: true, message: "BG at [#{row},#{col}] is #{expected}")
143
- else
144
- Result.new(step: action, passed: false, message: "BG at [#{row},#{col}] is #{actual}, expected #{expected}")
145
- end
107
+ when "assert_bg"
108
+ check_color(driver, step, :bg)
146
109
 
147
- when "assert_style"
148
- ensure_driver!(driver)
149
- row, col = coords(step)
150
- state = State.new(driver.state_data)
151
- actual = state.style_at(row, col)
152
- expected = {}
153
- expected[:bold] = step[:bold] unless step[:bold].nil?
154
- expected[:italic] = step[:italic] unless step[:italic].nil?
155
- expected[:underline] = step[:underline] unless step[:underline].nil?
156
- match = expected.all? { |k, v| actual[k] == v }
157
- if match
158
- Result.new(step: action, passed: true, message: "Style at [#{row},#{col}] matches #{expected}")
159
- else
160
- Result.new(step: action, passed: false, message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}")
161
- end
110
+ when "assert_style"
111
+ ensure_driver!(driver)
112
+ row, col = coords(step)
113
+ state = State.new(driver.state_data)
114
+ actual = state.style_at(row, col)
115
+ expected = {}
116
+ expected[:bold] = step[:bold] unless step[:bold].nil?
117
+ expected[:italic] = step[:italic] unless step[:italic].nil?
118
+ expected[:underline] = step[:underline] unless step[:underline].nil?
119
+ match = expected.all? { |k, v| actual[k] == v }
120
+ if match
121
+ Result.new(step: action, passed: true, message: "Style at [#{row},#{col}] matches #{expected}")
122
+ else
123
+ Result.new(step: action, passed: false, message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}")
124
+ end
125
+
126
+ when "screenshot"
127
+ ensure_driver!(driver)
128
+ path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.png"
129
+ driver.screenshot(path)
130
+ Result.new(step: action, passed: true, message: "Saved: #{path}")
162
131
 
163
- when "screenshot"
164
- ensure_driver!(driver)
165
- path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.png"
166
- driver.screenshot(path)
167
- Result.new(step: action, passed: true, message: "Saved: #{path}")
168
-
169
- when "html"
170
- ensure_driver!(driver)
171
- path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.html"
172
- HtmlRenderer.new(driver.state_data).render(path)
173
- Result.new(step: action, passed: true, message: "Saved: #{path}")
174
-
175
- when "wait_for_exit"
176
- ensure_driver!(driver)
177
- driver.wait_for_exit
178
- status = driver.exitstatus
179
- Result.new(step: action, passed: true, message: "Exited with status #{status}")
180
-
181
- when "assert_exit"
182
- ensure_driver!(driver)
183
- expected = value.to_s.to_i
184
- actual = driver.exitstatus
185
- if actual == expected
186
- Result.new(step: action, passed: true, message: "Exit status #{expected} matches")
187
- else
188
- Result.new(step: action, passed: false, message: "Exit status #{actual}, expected #{expected}")
189
- end
132
+ when "html"
133
+ ensure_driver!(driver)
134
+ path = value.is_a?(String) ? value : "/tmp/tui_td_#{Time.now.to_i}.html"
135
+ HtmlRenderer.new(driver.state_data).render(path)
136
+ Result.new(step: action, passed: true, message: "Saved: #{path}")
190
137
 
191
- when "close"
192
- driver&.close
193
- driver = nil
194
- Result.new(step: action, passed: true, message: "Closed")
138
+ when "wait_for_exit"
139
+ ensure_driver!(driver)
140
+ driver.wait_for_exit
141
+ status = driver.exitstatus
142
+ Result.new(step: action, passed: true, message: "Exited with status #{status}")
195
143
 
196
- else
197
- Result.new(step: action, passed: false, message: "Unknown action: #{action}")
198
- end
144
+ when "assert_exit"
145
+ ensure_driver!(driver)
146
+ expected = value.to_s.to_i
147
+ actual = driver.exitstatus
148
+ if actual == expected
149
+ Result.new(step: action, passed: true, message: "Exit status #{expected} matches")
150
+ else
151
+ Result.new(step: action, passed: false, message: "Exit status #{actual}, expected #{expected}")
152
+ end
153
+
154
+ when "close"
155
+ driver&.close
156
+ driver = nil
157
+ Result.new(step: action, passed: true, message: "Closed")
199
158
 
200
- rescue StandardError => e
201
- r = Result.new(step: action, passed: false, message: "#{e.class}: #{e.message}")
202
- end
159
+ else
160
+ Result.new(step: action, passed: false, message: "Unknown action: #{action}")
161
+ end
203
162
 
204
- all_results << r
205
- all_passed &&= r.passed
163
+ rescue StandardError => e
164
+ r = Result.new(step: action, passed: false, message: "#{e.class}: #{e.message}")
165
+ end
206
166
 
207
- if @on_step
208
- state_data = nil
209
- begin
210
- state_data = driver.state_data if driver
211
- rescue StandardError
212
- # ignore — state retrieval is best-effort
167
+ all_results << r
168
+ all_passed &&= r.passed
169
+
170
+ if @on_step
171
+ state_data = nil
172
+ begin
173
+ state_data = driver.state_data if driver
174
+ rescue StandardError
175
+ # ignore — state retrieval is best-effort
176
+ end
177
+ @on_step.call(
178
+ index: all_results.size - 1,
179
+ total: total_steps,
180
+ action: action,
181
+ value: value,
182
+ result: r,
183
+ driver: driver,
184
+ state_data: state_data
185
+ )
213
186
  end
214
- @on_step.call(
215
- index: all_results.size - 1,
216
- total: total_steps,
217
- action: action,
218
- value: value,
219
- result: r,
220
- driver: driver,
221
- state_data: state_data
222
- )
223
187
  end
224
188
  end
225
- end
226
189
 
227
190
  driver&.close
228
191
 
@@ -241,10 +204,65 @@ module TUITD
241
204
 
242
205
  def coords(step)
243
206
  pos = step[:assert_fg] || step[:assert_bg] || step[:assert_style]
244
- pos = value if pos.nil? && (value = step.values.first).is_a?(Array)
245
207
  row = pos.is_a?(Array) ? pos[0] : (pos[:row] || pos["row"] || 0)
246
208
  col = pos.is_a?(Array) ? pos[1] : (pos[:col] || pos["col"] || 0)
247
209
  [row, col]
248
210
  end
211
+
212
+ def check_text(driver, value, action)
213
+ ensure_driver!(driver)
214
+ state = State.new(driver.state_data)
215
+ text = value.to_s
216
+
217
+ if action == "assert_regex"
218
+ begin
219
+ pattern = Regexp.new(text)
220
+ rescue RegexpError => e
221
+ return Result.new(step: action, passed: false, message: "Invalid regex: #{e.message}")
222
+ end
223
+ else
224
+ pattern = text
225
+ end
226
+
227
+ found = state.find_text(pattern).any?
228
+
229
+ case action
230
+ when "assert_text"
231
+ if found
232
+ Result.new(step: action, passed: true, message: "Text found: #{value}")
233
+ else
234
+ Result.new(step: action, passed: false, message: "Text NOT found: #{value}")
235
+ end
236
+ when "assert_not_text"
237
+ if found
238
+ Result.new(step: action, passed: false, message: "Text found but should not be: #{value}")
239
+ else
240
+ Result.new(step: action, passed: true, message: "Text not found: #{value}")
241
+ end
242
+ when "assert_regex"
243
+ if found
244
+ Result.new(step: action, passed: true, message: "Regex matched: #{value}")
245
+ else
246
+ Result.new(step: action, passed: false, message: "Regex did not match: #{value}")
247
+ end
248
+ else
249
+ Result.new(step: action, passed: false, message: "Unknown text check: #{action}")
250
+ end
251
+ end
252
+
253
+ def check_color(driver, step, property)
254
+ ensure_driver!(driver)
255
+ row, col = coords(step)
256
+ expected = step[:is] || step["is"]
257
+ state = State.new(driver.state_data)
258
+ label = property == :fg ? "FG" : "BG"
259
+ actual = property == :fg ? state.foreground_at(row, col) : state.background_at(row, col)
260
+
261
+ if actual == expected
262
+ Result.new(step: step.keys.first.to_s, passed: true, message: "#{label} at [#{row},#{col}] is #{expected}")
263
+ else
264
+ Result.new(step: step.keys.first.to_s, passed: false, message: "#{label} at [#{row},#{col}] is #{actual}, expected #{expected}")
265
+ end
266
+ end
249
267
  end
250
268
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.5"
5
5
  end
data/lib/tui_td.rb CHANGED
@@ -7,6 +7,7 @@ end
7
7
  require_relative "tui_td/version"
8
8
  require_relative "tui_td/driver"
9
9
  require_relative "tui_td/ansi_parser"
10
+ require_relative "tui_td/ansi_utils"
10
11
  require_relative "tui_td/state"
11
12
  require_relative "tui_td/screenshot"
12
13
  require_relative "tui_td/html_renderer"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tui-td
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -123,6 +123,7 @@ files:
123
123
  - bin/tui-td
124
124
  - lib/tui_td.rb
125
125
  - lib/tui_td/ansi_parser.rb
126
+ - lib/tui_td/ansi_utils.rb
126
127
  - lib/tui_td/cli.rb
127
128
  - lib/tui_td/driver.rb
128
129
  - lib/tui_td/html_renderer.rb