tui-td 0.2.3 → 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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +30 -52
- data/lib/tui_td/ansi_parser.rb +73 -29
- data/lib/tui_td/ansi_utils.rb +75 -0
- data/lib/tui_td/cli.rb +14 -0
- data/lib/tui_td/html_renderer.rb +3 -66
- data/lib/tui_td/matchers.rb +11 -0
- data/lib/tui_td/screenshot.rb +3 -70
- data/lib/tui_td/state.rb +3 -0
- data/lib/tui_td/test_runner.rb +142 -105
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5a923ea75814849ee5cf0633c81c09e2db156e47df52f6f92f2b314f743f3b7
|
|
4
|
+
data.tar.gz: c5cb55618e38bcc06a477bedce42d61d8f0c497ff38f887462943e8d094863b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d36aac819998194c942dc9fc16c96ef0b4ef9fa7d549649f4bdd78cd59687858939ef7e1303d215a13e1dcd0957350d3839e43af94d4b837eb509ea5541cb58
|
|
7
|
+
data.tar.gz: 3d9f2fe282a9e32a274cea860ef5592e7666ce48121d590395c926bd0119d0f970ad2d2f232b88b0323742df8ef42b18686cd60ca75007d9f739cc2e8f4bae5b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.2.4
|
|
10
|
+
|
|
11
|
+
- `assert_regex` JSON test step — match terminal output against a Ruby regex
|
|
12
|
+
- `assert_not_text` JSON test step — fail if text IS present (inverse of assert_text)
|
|
13
|
+
- `have_regex` RSpec matcher — regex assertions in spec files
|
|
14
|
+
- `have_text` negation in RSpec: `expect(state).not_to have_text("Error")`
|
|
15
|
+
- README step reference table updated (all 13 step types listed)
|
|
16
|
+
|
|
3
17
|
## 0.2.3
|
|
4
18
|
|
|
5
19
|
- `have_exit_status` RSpec matcher and `exitstatus` drive command — exit code testing on all three levels (JSON + RSpec + drive)
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
{ "
|
|
248
|
+
{ "assert_regex": "hello|world" },
|
|
249
|
+
{ "assert_fg": [0, 0], "is": "cyan" }
|
|
250
|
+
],
|
|
251
|
+
"after_all": [
|
|
280
252
|
{ "close": true }
|
|
281
253
|
]
|
|
282
254
|
}
|
|
@@ -292,9 +264,13 @@ tui-td test examples/echo_test.json
|
|
|
292
264
|
| `wait_for_text` | `"text"` | Wait until text appears |
|
|
293
265
|
| `wait_for_stable` | — | Wait until output is stable |
|
|
294
266
|
| `assert_text` | `"text"` | Assert that text exists on screen |
|
|
267
|
+
| `assert_not_text` | `"text"` | Assert that text does NOT exist on screen |
|
|
268
|
+
| `assert_regex` | `"pattern"` | Assert that regex pattern matches (e.g. `"error\|fail"`) |
|
|
295
269
|
| `assert_fg` | `[row, col], "is": "color"` | Assert foreground color |
|
|
296
270
|
| `assert_bg` | `[row, col], "is": "color"` | Assert background color |
|
|
297
271
|
| `assert_style` | `[row, col], "bold": true` | Assert cell style (bold, italic, underline) |
|
|
272
|
+
| `wait_for_exit` | — | Wait until the process exits |
|
|
273
|
+
| `assert_exit` | `N` | Assert the process exit code equals N |
|
|
298
274
|
| `screenshot` | `"path"` | Save PNG screenshot |
|
|
299
275
|
| `html` | `"path"` | Save HTML render for browser viewing |
|
|
300
276
|
| `close` | — | Close the TUI |
|
|
@@ -367,9 +343,11 @@ end
|
|
|
367
343
|
| Matcher | Usage |
|
|
368
344
|
|---------|-------|
|
|
369
345
|
| `have_text("...")` | Assert text is present on screen |
|
|
346
|
+
| `have_regex(/pattern/)` | Assert regex pattern matches anywhere |
|
|
370
347
|
| `have_fg("color").at(row, col)` | Assert foreground color at position |
|
|
371
348
|
| `have_bg("color").at(row, col)` | Assert background color at position |
|
|
372
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 |
|
|
373
351
|
|
|
374
352
|
## MCP Server — AI Integration
|
|
375
353
|
|
data/lib/tui_td/ansi_parser.rb
CHANGED
|
@@ -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)
|
|
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 =
|
|
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@`
|
|
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
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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] =
|
|
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@`
|
|
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 "
|
|
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
|
-
|
|
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
|
data/lib/tui_td/cli.rb
CHANGED
|
@@ -366,6 +366,13 @@ module TUITD
|
|
|
366
366
|
{"assert_text": "<substring>"}
|
|
367
367
|
Fail if the text is not found in the current state.
|
|
368
368
|
|
|
369
|
+
{"assert_not_text": "<substring>"}
|
|
370
|
+
Fail if the text IS found in the current state.
|
|
371
|
+
|
|
372
|
+
{"assert_regex": "<pattern>"}
|
|
373
|
+
Fail if the regex pattern does not match anywhere.
|
|
374
|
+
Pattern syntax is Ruby regex (e.g. "error|fail|warn").
|
|
375
|
+
|
|
369
376
|
{"assert_fg": [row, col], "is": "<color>"}
|
|
370
377
|
Assert foreground color at cell. Colors: "default",
|
|
371
378
|
named ANSI (red, green, blue, cyan, ...), "bright_*",
|
|
@@ -432,6 +439,13 @@ module TUITD
|
|
|
432
439
|
have_text(expected)
|
|
433
440
|
Passes if expected text appears anywhere in the terminal state.
|
|
434
441
|
Usage: expect(state).to have_text("Hello")
|
|
442
|
+
Negate: expect(state).not_to have_text("Error")
|
|
443
|
+
|
|
444
|
+
have_regex(pattern)
|
|
445
|
+
Passes if the regex pattern matches anywhere. Accepts a Regexp
|
|
446
|
+
or a string (parsed as Ruby regex).
|
|
447
|
+
Usage: expect(state).to have_regex(/error|fail/)
|
|
448
|
+
Usage: expect(state).to have_regex("\\d{3}")
|
|
435
449
|
|
|
436
450
|
have_fg(expected).at(row, col)
|
|
437
451
|
Assert foreground color at [row, col] matches expected.
|
data/lib/tui_td/html_renderer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/tui_td/matchers.rb
CHANGED
|
@@ -23,6 +23,17 @@ module TUITD
|
|
|
23
23
|
failure_message_when_negated { |state| "expected terminal NOT to contain #{expected.inspect}" }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
RSpec::Matchers.define :have_regex do |pattern|
|
|
27
|
+
match do |state|
|
|
28
|
+
@regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern.to_s)
|
|
29
|
+
state.find_text(@regex).any?
|
|
30
|
+
end
|
|
31
|
+
|
|
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}" }
|
|
35
|
+
end
|
|
36
|
+
|
|
26
37
|
RSpec::Matchers.define :have_fg do |expected|
|
|
27
38
|
chain(:at) { |row, col| @row, @col = row, col }
|
|
28
39
|
|
data/lib/tui_td/screenshot.rb
CHANGED
|
@@ -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]
|
data/lib/tui_td/test_runner.rb
CHANGED
|
@@ -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,122 +94,98 @@ module TUITD
|
|
|
88
94
|
Result.new(step: action, passed: true, message: "Found: #{value}")
|
|
89
95
|
|
|
90
96
|
when "wait_for_stable"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
row, col = coords(step)
|
|
107
|
-
expected = step[:is] || step["is"]
|
|
108
|
-
state = State.new(driver.state_data)
|
|
109
|
-
actual = state.foreground_at(row, col)
|
|
110
|
-
if actual == expected
|
|
111
|
-
Result.new(step: action, passed: true, message: "FG at [#{row},#{col}] is #{expected}")
|
|
112
|
-
else
|
|
113
|
-
Result.new(step: action, passed: false, message: "FG at [#{row},#{col}] is #{actual}, expected #{expected}")
|
|
114
|
-
end
|
|
101
|
+
when "assert_text", "assert_not_text", "assert_regex"
|
|
102
|
+
check_text(driver, value, action)
|
|
115
103
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
row, col = coords(step)
|
|
119
|
-
expected = step[:is] || step["is"]
|
|
120
|
-
state = State.new(driver.state_data)
|
|
121
|
-
actual = state.background_at(row, col)
|
|
122
|
-
if actual == expected
|
|
123
|
-
Result.new(step: action, passed: true, message: "BG at [#{row},#{col}] is #{expected}")
|
|
124
|
-
else
|
|
125
|
-
Result.new(step: action, passed: false, message: "BG at [#{row},#{col}] is #{actual}, expected #{expected}")
|
|
126
|
-
end
|
|
104
|
+
when "assert_fg"
|
|
105
|
+
check_color(driver, step, :fg)
|
|
127
106
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
row, col = coords(step)
|
|
131
|
-
state = State.new(driver.state_data)
|
|
132
|
-
actual = state.style_at(row, col)
|
|
133
|
-
expected = {}
|
|
134
|
-
expected[:bold] = step[:bold] unless step[:bold].nil?
|
|
135
|
-
expected[:italic] = step[:italic] unless step[:italic].nil?
|
|
136
|
-
expected[:underline] = step[:underline] unless step[:underline].nil?
|
|
137
|
-
match = expected.all? { |k, v| actual[k] == v }
|
|
138
|
-
if match
|
|
139
|
-
Result.new(step: action, passed: true, message: "Style at [#{row},#{col}] matches #{expected}")
|
|
140
|
-
else
|
|
141
|
-
Result.new(step: action, passed: false, message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}")
|
|
142
|
-
end
|
|
107
|
+
when "assert_bg"
|
|
108
|
+
check_color(driver, step, :bg)
|
|
143
109
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
status = driver.exitstatus
|
|
160
|
-
Result.new(step: action, passed: true, message: "Exited with status #{status}")
|
|
161
|
-
|
|
162
|
-
when "assert_exit"
|
|
163
|
-
ensure_driver!(driver)
|
|
164
|
-
expected = value.to_s.to_i
|
|
165
|
-
actual = driver.exitstatus
|
|
166
|
-
if actual == expected
|
|
167
|
-
Result.new(step: action, passed: true, message: "Exit status #{expected} matches")
|
|
168
|
-
else
|
|
169
|
-
Result.new(step: action, passed: false, message: "Exit status #{actual}, expected #{expected}")
|
|
170
|
-
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
|
|
171
125
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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}")
|
|
176
131
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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}")
|
|
180
137
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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}")
|
|
184
143
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
187
153
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
154
|
+
when "close"
|
|
155
|
+
driver&.close
|
|
156
|
+
driver = nil
|
|
157
|
+
Result.new(step: action, passed: true, message: "Closed")
|
|
158
|
+
|
|
159
|
+
else
|
|
160
|
+
Result.new(step: action, passed: false, message: "Unknown action: #{action}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
r = Result.new(step: action, passed: false, message: "#{e.class}: #{e.message}")
|
|
165
|
+
end
|
|
166
|
+
|
|
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
|
+
)
|
|
194
186
|
end
|
|
195
|
-
@on_step.call(
|
|
196
|
-
index: all_results.size - 1,
|
|
197
|
-
total: total_steps,
|
|
198
|
-
action: action,
|
|
199
|
-
value: value,
|
|
200
|
-
result: r,
|
|
201
|
-
driver: driver,
|
|
202
|
-
state_data: state_data
|
|
203
|
-
)
|
|
204
187
|
end
|
|
205
188
|
end
|
|
206
|
-
end
|
|
207
189
|
|
|
208
190
|
driver&.close
|
|
209
191
|
|
|
@@ -222,10 +204,65 @@ module TUITD
|
|
|
222
204
|
|
|
223
205
|
def coords(step)
|
|
224
206
|
pos = step[:assert_fg] || step[:assert_bg] || step[:assert_style]
|
|
225
|
-
pos = value if pos.nil? && (value = step.values.first).is_a?(Array)
|
|
226
207
|
row = pos.is_a?(Array) ? pos[0] : (pos[:row] || pos["row"] || 0)
|
|
227
208
|
col = pos.is_a?(Array) ? pos[1] : (pos[:col] || pos["col"] || 0)
|
|
228
209
|
[row, col]
|
|
229
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
|
|
230
267
|
end
|
|
231
268
|
end
|
data/lib/tui_td/version.rb
CHANGED
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
|
+
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
|