tui-td 0.2.1 → 0.2.3

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: 2434731fd0c15251a24a4e29168f4b9c7b5332d83eb9d50143225f5e13102404
4
- data.tar.gz: c2827f1bbeef39368e675960b896f93e0369ee64e167094108c1ca8a0838afde
3
+ metadata.gz: 739ccd98a6d9a5ee8f29eadfdaaaa86cde8ee67cebf7bd9c293a20e1785f4bdb
4
+ data.tar.gz: bbeb1b4fcc9def290a68ee73df31d63d4fb3cb5a7a9a55b8d8cf5b8a57394885
5
5
  SHA512:
6
- metadata.gz: 50f1ade06d2f2c921a77c4af6a4ff5a770754de165bf4e619c0d8ba21b73365d6172ea55f1863345e874ff32f5c5b997b0cae9cdb25ea844190a10d2f67cb324
7
- data.tar.gz: e7c60585ec015b1b86d1b9f9a2546297f7eb4605f3af1ecf1048182fbfb09f4916a0c590d4ee4c955174b555f736b2b11fd2255df07bca3d4ae0fa53c44475f5
6
+ metadata.gz: 6bb830eee8c25605c0cc1ecf7e5692fe5db1144c5c7cc2a6144367139056c6a2aa8650390429647d51b263058c21e78b46ee01c085fd0662ef3879ca551184e3
7
+ data.tar.gz: cbefe0d9f7483787190763563d5a1919aa8c476c1b57cddd704b981974b652b3b1734c59c60031ffd12e59d940a4a07f1abe937ab35012249a88a7303ca29073
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.2.3
4
+
5
+ - `have_exit_status` RSpec matcher and `exitstatus` drive command — exit code testing on all three levels (JSON + RSpec + drive)
6
+ - `env` in start step — inject environment variables per test run
7
+ - Per-step `timeout` override
8
+ - `before_all` / `after_all` hooks for setup and teardown steps
9
+
10
+ ## 0.2.2
11
+
12
+ - `wait_for_exit` and `assert_exit` JSON test steps — test process exit codes
13
+
3
14
  ## 0.2.1
4
15
 
5
16
  - `Driver#refresh` — explicit state re-parse for MCP server clients
data/README.md CHANGED
@@ -120,6 +120,7 @@ Interactive commands (drive mode):
120
120
  key <name> Send keystroke (enter, tab, escape, up, down, left, right,
121
121
  backspace, ctrl_c, ctrl_d)
122
122
  <text> Send text to the TUI
123
+ exitstatus Show process exit status (nil if running)
123
124
  exit Quit drive mode
124
125
 
125
126
  Global options:
data/lib/tui_td/cli.rb CHANGED
@@ -41,6 +41,7 @@ module TUITD
41
41
  opts.separator " key <name> Send keystroke (enter, tab, escape, up, down, left, right,"
42
42
  opts.separator " backspace, ctrl_c, ctrl_d)"
43
43
  opts.separator " <text> Send text to the TUI"
44
+ opts.separator " exitstatus Show process exit status (nil if running)"
44
45
  opts.separator " exit Quit drive mode"
45
46
  opts.separator ""
46
47
  opts.separator "Global options:"
@@ -188,6 +189,9 @@ module TUITD
188
189
  puts driver.state_json(pretty: true)
189
190
  elsif input == "raw"
190
191
  puts driver.raw_output[0..2000]
192
+ elsif input == "exitstatus"
193
+ status = driver.exitstatus
194
+ puts status ? "Exit status: #{status}" : "Process still running"
191
195
  elsif input.start_with?("key ")
192
196
  driver.send_keys(input.split(" ", 2).last.to_sym)
193
197
  else
@@ -327,12 +331,24 @@ module TUITD
327
331
  A test is a Hash or JSON string: {"name": "...", "steps": [...]}
328
332
 
329
333
  Top-level keys: name, steps, rows (default 40), cols (default 120),
330
- timeout (default 30), chdir
334
+ timeout (default 30), chdir, before_all, after_all
335
+
336
+ before_all / after_all are arrays of steps that run before and
337
+ after the main steps list. Useful for setup/teardown:
338
+
339
+ "before_all": [{"start": "my_tui"}, {"wait_for_text": "> "}],
340
+ "steps": [{"send": "hello\\n"}],
341
+ "after_all": [{"close": true}]
342
+
343
+ Each step can also set a per-step "timeout" (in seconds):
344
+
345
+ {"wait_for_text": "Slow", "timeout": 60}
331
346
 
332
347
  Each step is an object with a single action key:
333
348
 
334
349
  {"start": "<command>"}
335
- Start a TUI process in a PTY.
350
+ Start a TUI process in a PTY. Environment variables can be
351
+ passed via "env": {"FOO": "bar", "BAZ": "qux"}.
336
352
 
337
353
  {"send": "<text>"}
338
354
  Send text to the TUI. Use "\\n" for Enter.
@@ -367,8 +383,14 @@ module TUITD
367
383
  {"html": "<path>"}
368
384
  Save an HTML render. Path defaults to /tmp/tui_td_<ts>.html.
369
385
 
386
+ {"wait_for_exit": true}
387
+ Wait until the process exits naturally.
388
+
389
+ {"assert_exit": <N>}
390
+ Assert the process exit status equals N.
391
+
370
392
  {"close": true}
371
- Close the driver session.
393
+ Close the driver session (force-kill if needed).
372
394
 
373
395
  Example test file: examples/echo_test.json
374
396
  HELP
@@ -422,6 +444,13 @@ module TUITD
422
444
  have_style.at(row, col).with(bold: true, italic: false, ...)
423
445
  Assert style attributes at [row, col] match the given hash.
424
446
  Usage: expect(state).to have_style.at(0, 0).with(bold: true)
447
+
448
+ Driver matchers (work on TUITD::Driver, not State)
449
+ --------------------------------------------------
450
+
451
+ have_exit_status(expected)
452
+ Assert the process exit status matches expected.
453
+ Usage: expect(driver).to have_exit_status(0)
425
454
  HELP
426
455
  exit 0
427
456
  end
data/lib/tui_td/driver.rb CHANGED
@@ -19,12 +19,13 @@ module TUITD
19
19
  class Driver
20
20
  attr_reader :command, :state
21
21
 
22
- def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil)
22
+ def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {})
23
23
  @command = command
24
24
  @rows = rows
25
25
  @cols = cols
26
26
  @timeout = timeout
27
27
  @chdir = chdir
28
+ @env = env
28
29
  @state = nil
29
30
  @stdin = nil
30
31
  @stdout = nil
@@ -37,7 +38,7 @@ module TUITD
37
38
 
38
39
  # Start the TUI application in a PTY
39
40
  def start
40
- env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.to_s }
41
+ env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.to_s }.merge(@env.transform_keys(&:to_s))
41
42
  spawn_opts = {}
42
43
  spawn_opts[:chdir] = @chdir if @chdir
43
44
 
@@ -117,6 +118,15 @@ module TUITD
117
118
  @wait_thr&.value
118
119
  end
119
120
 
121
+ # Get the process exit status (nil if still running)
122
+ def exitstatus
123
+ return nil unless @wait_thr
124
+ status = @wait_thr.value
125
+ status&.exitstatus
126
+ rescue NoMethodError
127
+ nil
128
+ end
129
+
120
130
  # Get the terminal output (raw ANSI + text)
121
131
  def raw_output
122
132
  read_available!
@@ -68,5 +68,21 @@ module TUITD
68
68
  "expected style at [#{@row},#{@col}] to be #{@expected.inspect}, but was #{@actual.inspect}"
69
69
  end
70
70
  end
71
+
72
+ # Works on a Driver instance, not State
73
+ RSpec::Matchers.define :have_exit_status do |expected|
74
+ match do |driver|
75
+ @actual = driver.exitstatus
76
+ @actual == expected
77
+ end
78
+
79
+ description { "have exit status #{expected}" }
80
+ failure_message do |driver|
81
+ "expected exit status #{expected}, but was #{@actual}"
82
+ end
83
+ failure_message_when_negated do |driver|
84
+ "expected exit status not to be #{expected}"
85
+ end
86
+ end
71
87
  end
72
88
  end
@@ -13,16 +13,20 @@ module TUITD
13
13
  # {
14
14
  # "name": "My test",
15
15
  # "rows": 24, "cols": 80, "timeout": 10,
16
+ # "chdir": "/path/to/workdir",
17
+ # "before_all": [{"start": "my_tui", "env": {"FOO": "bar"}}],
16
18
  # "steps": [
17
- # {"start": "my_tui"},
18
19
  # {"wait_for_text": "> "},
19
20
  # {"send": "hello\n"},
20
21
  # {"assert_text": "hello"},
21
- # {"assert_fg": [0, 0], "is": "cyan"},
22
- # {"close": true}
23
- # ]
22
+ # {"assert_fg": [0, 0], "is": "cyan"}
23
+ # ],
24
+ # "after_all": [{"close": true}]
24
25
  # }
25
26
  #
27
+ # Per-step "timeout" overrides the top-level default:
28
+ # {"wait_for_text": "Slow", "timeout": 60}
29
+ #
26
30
  class TestRunner
27
31
  Result = Struct.new(:step, :passed, :message, keyword_init: true)
28
32
 
@@ -30,47 +34,60 @@ module TUITD
30
34
  raw = source.is_a?(String) ? JSON.parse(source) : source
31
35
  @plan = raw.transform_keys(&:to_sym)
32
36
  @plan[:steps] = @plan[:steps].map { |s| s.transform_keys(&:to_sym) }
37
+ @plan[:before_all] = @plan[:before_all]&.map { |s| s.transform_keys(&:to_sym) }
38
+ @plan[:after_all] = @plan[:after_all]&.map { |s| s.transform_keys(&:to_sym) }
33
39
  @on_step = on_step
34
40
  end
35
41
 
36
42
  def run
37
- results = []
38
- all_passed = true
39
43
  driver = nil
40
44
  rows = @plan[:rows] || 40
41
45
  cols = @plan[:cols] || 120
42
46
  timeout = @plan[:timeout] || 30
43
47
  chdir = @plan[:chdir]
44
48
 
45
- @plan[:steps].each do |step|
46
- action = step.keys.first.to_s
47
- value = step.values.first
48
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ hooks = [
50
+ { label: :before_all, steps: @plan[:before_all] || [] },
51
+ { label: :main, steps: @plan[:steps] },
52
+ { label: :after_all, steps: @plan[:after_all] || [] }
53
+ ]
49
54
 
50
- begin
51
- r = case action
52
- when "start"
53
- driver&.close
54
- driver = Driver.new(value.to_s, rows: rows, cols: cols, timeout: timeout, chdir: chdir)
55
- driver.start
56
- Result.new(step: action, passed: true, message: "Started: #{value}")
57
-
58
- when "send"
59
- ensure_driver!(driver)
60
- driver.send(value.to_s)
61
- Result.new(step: action, passed: true, message: "Sent #{value.to_s.length} characters")
62
-
63
- when "send_key"
64
- ensure_driver!(driver)
65
- driver.send_keys(value.to_s.to_sym)
66
- Result.new(step: action, passed: true, message: "Sent key: #{value}")
55
+ all_results = []
56
+ all_passed = true
57
+ total_steps = hooks.sum { |p| p[:steps].size }
67
58
 
68
- when "wait_for_text"
69
- ensure_driver!(driver)
70
- driver.wait_for_text(value.to_s)
71
- Result.new(step: action, passed: true, message: "Found: #{value}")
59
+ hooks.each do |phase|
60
+ phase[:steps].each do |step|
61
+ action = step.keys.first.to_s
62
+ value = step.values.first
72
63
 
73
- when "wait_for_stable"
64
+ begin
65
+ step_timeout = step[:timeout] || timeout
66
+ r = case action
67
+ when "start"
68
+ driver&.close
69
+ env = step[:env] || {}
70
+ env = env.transform_keys(&:to_sym).transform_values(&:to_s) if env.is_a?(Hash)
71
+ driver = Driver.new(value.to_s, rows: rows, cols: cols, timeout: step_timeout, chdir: chdir, env: env)
72
+ driver.start
73
+ Result.new(step: action, passed: true, message: "Started: #{value}")
74
+
75
+ when "send"
76
+ ensure_driver!(driver)
77
+ driver.send(value.to_s)
78
+ Result.new(step: action, passed: true, message: "Sent #{value.to_s.length} characters")
79
+
80
+ when "send_key"
81
+ ensure_driver!(driver)
82
+ driver.send_keys(value.to_s.to_sym)
83
+ Result.new(step: action, passed: true, message: "Sent key: #{value}")
84
+
85
+ when "wait_for_text"
86
+ ensure_driver!(driver)
87
+ driver.wait_for_text(value.to_s)
88
+ Result.new(step: action, passed: true, message: "Found: #{value}")
89
+
90
+ when "wait_for_stable"
74
91
  ensure_driver!(driver)
75
92
  driver.wait_for_stable
76
93
  Result.new(step: action, passed: true, message: "Stable")
@@ -136,6 +153,22 @@ module TUITD
136
153
  HtmlRenderer.new(driver.state_data).render(path)
137
154
  Result.new(step: action, passed: true, message: "Saved: #{path}")
138
155
 
156
+ when "wait_for_exit"
157
+ ensure_driver!(driver)
158
+ driver.wait_for_exit
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
171
+
139
172
  when "close"
140
173
  driver&.close
141
174
  driver = nil
@@ -149,7 +182,7 @@ module TUITD
149
182
  r = Result.new(step: action, passed: false, message: "#{e.class}: #{e.message}")
150
183
  end
151
184
 
152
- results << r
185
+ all_results << r
153
186
  all_passed &&= r.passed
154
187
 
155
188
  if @on_step
@@ -160,8 +193,8 @@ module TUITD
160
193
  # ignore — state retrieval is best-effort
161
194
  end
162
195
  @on_step.call(
163
- index: results.size - 1,
164
- total: @plan[:steps].size,
196
+ index: all_results.size - 1,
197
+ total: total_steps,
165
198
  action: action,
166
199
  value: value,
167
200
  result: r,
@@ -170,13 +203,14 @@ module TUITD
170
203
  )
171
204
  end
172
205
  end
206
+ end
173
207
 
174
208
  driver&.close
175
209
 
176
210
  {
177
211
  name: @plan[:name] || "(unnamed)",
178
212
  passed: all_passed,
179
- results: results.map(&:to_h)
213
+ results: all_results.map(&:to_h)
180
214
  }
181
215
  end
182
216
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.3"
5
5
  end
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.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus