tui-td 0.2.19 → 0.2.21

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: 48cd9df54d8ad7bc1d41b8d4cfd961462ecff6e8056c4fd37e46abb56fa50f38
4
- data.tar.gz: c77b9a6eb3a048806d17d4f5b2245dcf82dd1c5e818a247db48912e9b590d78d
3
+ metadata.gz: fcd0909e5284e4bdd97bd38154ba64108c9c6e95903ca067126b8fb16215ae28
4
+ data.tar.gz: 9b2bf1430a37e58a72c8f185adbbfb500a70f78a3a80bb66e083dee3e9124aa8
5
5
  SHA512:
6
- metadata.gz: ce4bdb6d4397369717686756508ec43219340ed4a0cc0ec8a4851018a19dc19bbdec3050147238a221cd4b282c92d848df1d71a1377b4915bfb42282af400d95
7
- data.tar.gz: 6053e73dd915f8839d92ef8b046578bae6fbf481b0fda21872851e7e9052816833ce62ab5d6a4d15a685ce517f161fe31e75d5a9309a0dacb3521a3df4e893af
6
+ metadata.gz: 4c084254da02d47fe56471eab086e89f35025c875153e0987b8399de324894a270a7488992c64ad78aa888c7886810898b0365b1a288667caef4c17f68096b82
7
+ data.tar.gz: 97aafa9d1056b7db02f84cb9b378edfb869e0651bfebaeceedfcb0f75d05ad76f97994a2986af80e84d8d8cbf77c9e0641600860247e771bb58b404b4aabd492
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.2.20
4
+
5
+ ### Added
6
+
7
+ - Video recording via ffmpeg: `VideoRecorder` class pipes PNG frames from the
8
+ existing Screenshot pipeline directly to ffmpeg for incremental encoding
9
+ - Driver methods: `start_recording`, `stop_recording`, `recording?`
10
+ - CLI flags: `--record <path>`, `--framerate <N>`, `--codec <name>`
11
+ - MCP tools: `tui_record_start`, `tui_record_stop`, `tui_record_status`
12
+ - JSON test steps: `start_recording`, `stop_recording`, `assert_recording`
13
+ - Minitest assertions: `assert_record_start`, `assert_record_stop`,
14
+ `assert_recording`, `refute_recording`
15
+ - RSpec matchers: `be_recording`, `have_recorded_video`
16
+ - Configuration: `ffmpeg_path`, `record_default_fps`, `record_default_codec`
17
+ - `Screenshot#to_blob` for in-memory PNG rendering (no temp files)
18
+
19
+ ### Changed
20
+
21
+ - `Driver#close` auto-stops recording if active
22
+ - `TestRunner#run` auto-stops recording on `close` step
23
+
24
+ ## 0.2.19
25
+
26
+ ### Added
27
+
28
+ - Minitest integration: `TUITD::Minitest::Assertions` module with 34 assertion methods
29
+ (17 assert + 17 refute) covering text, regex, colors, styles, exit status, all 9
30
+ element roles, and named snapshots with region/ignore_rows support
31
+ - Auto-wait for Minitest: Driver assertions wait up to 3s, State checks immediately
32
+ - `tui-td help minitest` — complete assertion reference
33
+ - Example: `examples/minitest_example_test.rb` with 7 test cases
34
+ - Smoke test: `test/minitest_smoke_test.rb` (10 runs, 19 assertions)
35
+
36
+ ### Changed
37
+
38
+ - Updated gemspec metadata (summary, description) for rubygems.org
39
+
40
+ ## 0.2.18
41
+
42
+ (yanked — same as 0.2.19 but with outdated gemspec metadata)
43
+
3
44
  ## 0.2.17
4
45
 
5
46
  ### Added
data/README.md CHANGED
@@ -3,7 +3,11 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/tui-td.svg)](https://rubygems.org/gems/tui-td)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
5
5
 
6
- Testing framework for Terminal User Interfaces (TUIs). Start a TUI in a PTY, send input, analyze output — as structured JSON, plain text, PNG screenshots, or HTML renders. Includes an MCP server for AI-driven testing, auto-wait RSpec matchers, and semantic selectors.
6
+ Testing framework and general-purpose TUI driver. Start a TUI in a PTY, send input, analyze output — as structured JSON, plain text, PNG screenshots, or HTML renders. Use it for testing, automation, or as a bridge between AI agents and terminal apps.
7
+
8
+ **Language-agnostic:** JSON tests + CLI + MCP let you drive TUIs from any language (Python, JavaScript, Rust, Go, …). Write a `.json` test plan, script via `tui-td drive`, or let an AI agent control a TUI through `tui-td serve`.
9
+
10
+ **Ruby-native:** In the Ruby ecosystem, tui-td integrates with RSpec and Minitest — auto-wait matchers and assertions included.
7
11
 
8
12
  > New to tui-td? Jump to [Quick Start](docs/quick_start.md).
9
13
 
@@ -13,10 +17,12 @@ Testing framework for Terminal User Interfaces (TUIs). Start a TUI in a PTY, sen
13
17
  2. **Auto-wait assertions** — matchers automatically retry until the condition is met or timeout
14
18
  3. **Semantic selectors** — `get_by_role(:button)`, `get_by_role(:dialog)`, `within { }` scoping
15
19
  4. **Multiple output formats** — structured JSON, plain text, PNG screenshots, HTML renders
16
- 5. **JSON test runner** — language-agnostic, 15+ step types, CI-friendly
20
+ 5. **JSON test runner** — language-agnostic, 23+ step types, CI-friendly
17
21
  6. **RSpec matchers** — `have_text`, `have_fg`, `have_button`, `have_dialog`, and more
18
- 7. **MCP server** — AI agents can drive TUIs via JSON-RPC over stdio
19
- 8. **Pure Ruby rendering** — embedded Spleen font + 2766 Unifont glyphs, no native deps required
22
+ 7. **Minitest assertions** — `assert_text`, `assert_button`, `assert_snapshot`, and more
23
+ 8. **MCP server** — AI agents can drive TUIs via JSON-RPC over stdio
24
+ 9. **Pure Ruby rendering** — embedded Spleen font + 2766 Unifont glyphs, no native deps required
25
+ 10. **Video recording** — record TUI sessions as MP4 via ffmpeg for demos, debugging, and AI agent playback
20
26
 
21
27
  ## Installation
22
28
 
@@ -53,6 +59,9 @@ tui-td --html output.html capture "htop"
53
59
  # Save as a PNG screenshot
54
60
  tui-td --screenshot output.png capture "htop"
55
61
 
62
+ # Record a session as video (requires ffmpeg)
63
+ tui-td --record demo.mp4 capture "htop" --timeout 10
64
+
56
65
  # Drive a TUI interactively
57
66
  tui-td drive "htop"
58
67
  # At the > prompt:
@@ -106,6 +115,9 @@ Global options:
106
115
  -C, --chdir PATH Working directory for the command
107
116
  --screenshot PATH Save screenshot (e.g., output.png)
108
117
  --html PATH Save HTML render (e.g., output.html)
118
+ --record PATH Record session as video (MP4/WebM, requires ffmpeg)
119
+ --framerate N Recording framerate (default: 30)
120
+ --codec NAME Video codec: libx264, libx265, libvpx-vp9 (default: libx264)
109
121
  --json Output state as compact JSON
110
122
  --pretty Output state as pretty JSON
111
123
  --text Output state as plain text table
@@ -117,7 +129,7 @@ Global options:
117
129
  ```
118
130
 
119
131
  `tui-td --help` serves as the full CLI reference. `tui-td help test` shows all JSON test
120
- step types and `tui-td help rspec` shows all RSpec matchers — no need to consult the docs.
132
+ step types, `tui-td help rspec` shows all RSpec matchers, and `tui-td help minitest` shows all Minitest assertions — no need to consult the docs.
121
133
 
122
134
  ## Demo
123
135
 
@@ -161,6 +173,11 @@ driver.screenshot("screenshot.png") # PNG renderer
161
173
  TUITD::HtmlRenderer.new(driver.state_data).render("output.html") # HTML renderer
162
174
  html_string = TUITD::HtmlRenderer.new(driver.state_data).to_html # HTML string
163
175
 
176
+ # Video recording (requires ffmpeg)
177
+ driver.start_recording("session.mp4", framerate: 30, codec: "libx264")
178
+ driver.recording? # => true
179
+ driver.stop_recording # => "session.mp4"
180
+
164
181
  driver.close
165
182
  ```
166
183
 
@@ -293,6 +310,9 @@ tui-td test examples/echo_test.json
293
310
  | `assert_tab` | `"text"` | Assert a tab (`[Tab1]`) is visible |
294
311
  | `assert_statusbar` | — | Assert a status bar (bottom row with background) is visible |
295
312
  | `assert_progress_bar` | `"text"` (optional) | Assert a progress bar (`[####]`) is visible |
313
+ | `start_recording` | `"path", "framerate": 30` | Start video recording (requires ffmpeg) |
314
+ | `stop_recording` | — | Stop recording and finalize video |
315
+ | `assert_recording` | `true` / `false` | Assert recording is active / not active |
296
316
  | `close` | — | Close the TUI |
297
317
 
298
318
  Example with `html` step for before/after snapshots:
@@ -378,6 +398,58 @@ end
378
398
  | `have_statusbar` | Assert a status bar (bottom row with background) is visible |
379
399
  | `have_progress_bar("50%")` | Assert a progress bar (`[####]`) is visible |
380
400
  | `have_exit_status(N)` | Assert the driver process exit status equals N |
401
+ | `be_recording` | Assert that the driver is currently recording video |
402
+ | `have_recorded_video("path.mp4")` | Assert that a video file was created and has content |
403
+
404
+ ## Minitest Assertions
405
+
406
+ Include the assertions module for native Minitest support (auto-wait included):
407
+
408
+ ```ruby
409
+ require "tui_td/minitest/assertions"
410
+
411
+ class MyTUITest < Minitest::Test
412
+ include TUITD::Minitest::Assertions
413
+
414
+ def test_login
415
+ driver = TUITD::Driver.new("my_tui", rows: 24, cols: 80)
416
+ driver.start
417
+ assert_text(driver, "Welcome")
418
+ assert_button(driver, "OK")
419
+ refute_text(driver, "Error")
420
+ assert_snapshot(driver, "login", type: :text, region: 0..6)
421
+ ensure
422
+ driver&.close
423
+ end
424
+ end
425
+ ```
426
+
427
+ | Assertion | Usage |
428
+ |-----------|-------|
429
+ | `assert_text(driver, "...")` | Assert text is present |
430
+ | `refute_text(driver, "...")` | Assert text is NOT present |
431
+ | `assert_regex(driver, /pattern/)` | Assert regex matches |
432
+ | `assert_fg(driver, "cyan", row:, col:)` | Assert foreground color |
433
+ | `assert_bg(driver, "blue", row:, col:)` | Assert background color |
434
+ | `assert_style(driver, row:, col:, bold: true)` | Assert cell style |
435
+ | `assert_exit_status(driver, 0)` | Assert process exit status |
436
+ | `assert_button(driver, "OK")` | Assert button with given text |
437
+ | `assert_dialog(driver)` | Assert a dialog is visible |
438
+ | `assert_checkbox(driver, "Label", checked: true)` | Assert checkbox with state |
439
+ | `assert_role(driver, :button, text: "OK")` | Generic role assertion |
440
+ | `assert_input(driver)` | Assert an input field |
441
+ | `assert_label(driver, "Name")` | Assert a label |
442
+ | `assert_menu(driver)` | Assert a menu |
443
+ | `assert_tab(driver, "File")` | Assert a tab |
444
+ | `assert_statusbar(driver)` | Assert a status bar |
445
+ | `assert_progress_bar(driver)` | Assert a progress bar |
446
+ | `assert_snapshot(driver, "name", type:, region:, ignore_rows:)` | Named snapshot comparison |
447
+ | `assert_record_start(driver, "path", framerate:)` | Start video recording |
448
+ | `assert_record_stop(driver)` | Stop recording and finalize video |
449
+ | `assert_recording(driver)` | Assert recording is active |
450
+ | `refute_recording(driver)` | Assert recording is NOT active |
451
+
452
+ See `tui-td help minitest` for the full reference.
381
453
 
382
454
  ## Snapshot Testing
383
455
 
@@ -459,6 +531,9 @@ tui-td serve
459
531
  | `tui_annotate_element` | Manually register a UI element annotation. Picked up by tui_find_elements. |
460
532
  | `tui_save_snapshot` | Save the current terminal state as a named snapshot to disk. |
461
533
  | `tui_assert_snapshot` | Assert current state matches a saved named snapshot. Creates on first run. |
534
+ | `tui_record_start` | Start video recording with path, framerate, codec, and quality options. |
535
+ | `tui_record_stop` | Stop recording and finalize the video file. |
536
+ | `tui_record_status` | Check if recording is currently active. |
462
537
  | `tui_close` | Close the TUI and clean up. |
463
538
 
464
539
  ### MCP configuration
@@ -604,6 +679,93 @@ html = TUITD::HtmlRenderer.new(driver.state_data).to_html
604
679
  {"html": "/tmp/snapshot.html"}
605
680
  ```
606
681
 
682
+ ## Video Recording
683
+
684
+ Record TUI sessions as MP4 video using ffmpeg. Frames are captured via the same Screenshot pipeline and piped directly to ffmpeg for incremental encoding — no temporary frame files.
685
+
686
+ **Requires ffmpeg** (`brew install ffmpeg` on macOS, `apt install ffmpeg` on Debian).
687
+
688
+ ```bash
689
+ # Record a capture session
690
+ tui-td --record demo.mp4 capture "htop" --timeout 10
691
+
692
+ # High-framerate recording
693
+ tui-td --record session.mp4 --framerate 60 drive "vim file.txt"
694
+
695
+ # Custom codec and quality
696
+ tui-td --record output.mp4 --codec libx265 --framerate 30 capture "glow README.md"
697
+ ```
698
+
699
+ ### Ruby API
700
+
701
+ ```ruby
702
+ driver = TUITD::Driver.new("htop", rows: 40, cols: 120)
703
+ driver.start
704
+
705
+ # Start recording
706
+ driver.start_recording("session.mp4", framerate: 30, codec: "libx264", quality: "high")
707
+
708
+ # Check recording status
709
+ driver.recording? # => true
710
+
711
+ # ... interact with TUI ...
712
+
713
+ # Stop recording
714
+ driver.stop_recording # => "session.mp4"
715
+ driver.close # auto-stops recording if active
716
+ ```
717
+
718
+ ### Configuration
719
+
720
+ ```ruby
721
+ TUITD.configure do |c|
722
+ c.ffmpeg_path = "/usr/local/bin/ffmpeg" # default: auto-detect
723
+ c.record_default_fps = 30
724
+ c.record_default_codec = "libx264"
725
+ end
726
+ ```
727
+
728
+ ### RSpec
729
+
730
+ ```ruby
731
+ require "tui_td/matchers"
732
+
733
+ driver.start_recording("test.mp4", framerate: 30)
734
+ expect(driver).to be_recording
735
+ # ... interact ...
736
+ driver.stop_recording
737
+ expect(driver).not_to be_recording
738
+ expect(driver).to have_recorded_video("test.mp4")
739
+ ```
740
+
741
+ ### Minitest
742
+
743
+ ```ruby
744
+ require "tui_td/minitest/assertions"
745
+
746
+ assert_record_start(driver, "test.mp4", framerate: 30)
747
+ assert_recording(driver)
748
+ # ... interact ...
749
+ assert_record_stop(driver)
750
+ refute_recording(driver)
751
+ ```
752
+
753
+ ### JSON Test Steps
754
+
755
+ ```json
756
+ {"start_recording": "output.mp4", "framerate": 30, "codec": "libx264"}
757
+ {"assert_recording": true}
758
+ {"stop_recording": true}
759
+ ```
760
+
761
+ ### MCP Tools
762
+
763
+ | Tool | Description |
764
+ |------|-------------|
765
+ | `tui_record_start` | Start recording with path, framerate, codec, quality |
766
+ | `tui_record_stop` | Stop recording and finalize video file |
767
+ | `tui_record_status` | Check if recording is currently active |
768
+
607
769
  ## License
608
770
 
609
771
  MIT
data/lib/tui_td/cli.rb CHANGED
@@ -71,6 +71,15 @@ module TUITD
71
71
  opts.on("--html PATH", "Save HTML render (e.g., output.html)") do |p|
72
72
  global_opts[:html] = p
73
73
  end
74
+ opts.on("--record PATH", "Record session as video (MP4/WebM, requires ffmpeg)") do |p|
75
+ global_opts[:record] = p
76
+ end
77
+ opts.on("--framerate N", Integer, "Recording framerate (default: 30)") do |f|
78
+ global_opts[:record_framerate] = f
79
+ end
80
+ opts.on("--codec NAME", "Video codec: libx264, libx265, libvpx-vp9 (default: libx264)") do |c|
81
+ global_opts[:record_codec] = c
82
+ end
74
83
  opts.on("--json", "Output state as compact JSON") do |_|
75
84
  global_opts[:format] = :json
76
85
  end
@@ -136,6 +145,23 @@ module TUITD
136
145
 
137
146
  private
138
147
 
148
+ def _start_recording_if(driver, globals)
149
+ return unless globals[:record]
150
+
151
+ framerate = globals[:record_framerate] || 30
152
+ codec = globals[:record_codec] || "libx264"
153
+ path = driver.start_recording(globals[:record], framerate: framerate, codec: codec)
154
+ puts "Recording to: #{path}"
155
+ path
156
+ end
157
+
158
+ def _stop_recording(driver)
159
+ return unless driver.recording?
160
+
161
+ path = driver.stop_recording
162
+ puts "Recording saved: #{path}" if path
163
+ end
164
+
139
165
  def cmd_serve(globals)
140
166
  server = MCP::Server.new(
141
167
  rows: globals[:rows] || 40,
@@ -155,6 +181,8 @@ module TUITD
155
181
  puts "─" * (globals[:cols] || 80)
156
182
  driver.start
157
183
 
184
+ _start_recording_if(driver, globals)
185
+
158
186
  driver.wait_for_stable
159
187
 
160
188
  if %i[json pretty_json].include?(globals[:format])
@@ -172,6 +200,7 @@ module TUITD
172
200
  puts "HTML saved: #{path}"
173
201
  end
174
202
 
203
+ _stop_recording(driver)
175
204
  driver.close
176
205
  end
177
206
 
@@ -185,6 +214,8 @@ module TUITD
185
214
  puts "Type commands to send. Exit with Ctrl+C."
186
215
  driver.start
187
216
 
217
+ _start_recording_if(driver, globals)
218
+
188
219
  begin
189
220
  loop do
190
221
  driver.wait_for_stable
@@ -264,6 +295,7 @@ module TUITD
264
295
  rescue Interrupt
265
296
  puts "\nDone."
266
297
  ensure
298
+ _stop_recording(driver)
267
299
  driver.close
268
300
  end
269
301
  end
@@ -281,6 +313,9 @@ module TUITD
281
313
  # Proceed with whatever was rendered before the timeout.
282
314
  driver.refresh
283
315
  end
316
+
317
+ _start_recording_if(driver, globals)
318
+
284
319
  begin
285
320
  driver.wait_for_stable
286
321
  rescue TimeoutError
@@ -305,6 +340,7 @@ module TUITD
305
340
  puts "HTML saved: #{path}"
306
341
  end
307
342
 
343
+ _stop_recording(driver)
308
344
  driver.close
309
345
  end
310
346
 
@@ -462,6 +498,16 @@ module TUITD
462
498
  {"html": "<path>"}
463
499
  Save an HTML render. Path defaults to /tmp/tui_td_<ts>.html.
464
500
 
501
+ {"start_recording": "<path>", "framerate": 30, "codec": "libx264"}
502
+ Start recording the TUI session as a video (requires ffmpeg).
503
+ framerate defaults to 30, codec defaults to libx264.
504
+
505
+ {"stop_recording": true}
506
+ Stop video recording and finalize the video file.
507
+
508
+ {"assert_recording": true}
509
+ Assert that recording is active. Use false to assert NOT recording.
510
+
465
511
  {"wait_for_exit": true}
466
512
  Wait until the process exits naturally.
467
513
 
@@ -650,6 +696,22 @@ module TUITD
650
696
  have_exit_status(expected)
651
697
  Assert the process exit status matches expected.
652
698
  Usage: expect(driver).to have_exit_status(0)
699
+
700
+ Video Recording
701
+ ---------------
702
+
703
+ Start/stop recording via Driver methods, then verify with matchers:
704
+
705
+ driver.start_recording("test.mp4", framerate: 30, codec: "libx264")
706
+ expect(driver).to be_recording
707
+ # ... interact with TUI ...
708
+ driver.stop_recording
709
+ expect(driver).not_to be_recording
710
+ expect(driver).to have_recorded_video("test.mp4")
711
+
712
+ Options for start_recording: framerate (default 30),
713
+ codec (libx264, libx265, libvpx-vp9), quality (high/medium/low).
714
+ Recording stops automatically when the driver is closed.
653
715
  HELP
654
716
  exit 0
655
717
  end
@@ -752,6 +814,21 @@ module TUITD
752
814
 
753
815
  assert_snapshot(driver, "main", ignore_rows: [5])
754
816
  Skip volatile rows with ignore_rows:.
817
+
818
+ Video Recording
819
+ ---------------
820
+
821
+ assert_record_start(driver, "test.mp4", framerate: 30, codec: "libx264")
822
+ Start recording the TUI session as a video (requires ffmpeg).
823
+
824
+ assert_record_stop(driver)
825
+ Stop recording and finalize the video file.
826
+
827
+ assert_recording(driver)
828
+ Verify that recording is currently active.
829
+
830
+ refute_recording(driver)
831
+ Verify that recording is NOT active.
755
832
  HELP
756
833
  exit 0
757
834
  end
@@ -9,10 +9,13 @@ module TUITD
9
9
  # end
10
10
  #
11
11
  class Configuration
12
- attr_accessor :snapshot_dir
12
+ attr_accessor :snapshot_dir, :ffmpeg_path, :record_default_fps, :record_default_codec
13
13
 
14
14
  def initialize
15
15
  @snapshot_dir = nil
16
+ @ffmpeg_path = nil
17
+ @record_default_fps = 30
18
+ @record_default_codec = "libx264"
16
19
  end
17
20
 
18
21
  # Check if UPDATE_SNAPSHOTS env var is set to update mode.
data/lib/tui_td/driver.rb CHANGED
@@ -224,8 +224,36 @@ module TUITD
224
224
  TUITD::State.new(state_data)
225
225
  end
226
226
 
227
+ # Start video recording (requires ffmpeg).
228
+ # Options: framerate (default 30), codec (default "libx264"), quality ("high"/"medium"/"low").
229
+ def start_recording(path, framerate: 30, codec: "libx264", quality: "high")
230
+ raise Error, "Recording already in progress" if recording?
231
+
232
+ require_relative "video_recorder"
233
+ @recorder = VideoRecorder.new(path, driver: self, framerate: framerate,
234
+ codec: codec, quality: quality,)
235
+ @recorder.start
236
+ path
237
+ end
238
+
239
+ # Stop video recording and finalize the video file.
240
+ # Returns the output path, or nil if not recording.
241
+ def stop_recording
242
+ return nil unless @recorder
243
+
244
+ path = @recorder.stop
245
+ @recorder = nil
246
+ path
247
+ end
248
+
249
+ # Is video recording currently active?
250
+ def recording?
251
+ @recorder&.recording? || false
252
+ end
253
+
227
254
  # Close the driver and clean up
228
255
  def close
256
+ stop_recording
229
257
  _stop_reader_thread
230
258
 
231
259
  # Kill the process if still running
@@ -134,6 +134,37 @@ module TUITD
134
134
  end
135
135
  end
136
136
 
137
+ # Video recording matchers — work on Driver instances
138
+
139
+ RSpec::Matchers.define :be_recording do
140
+ match do |driver|
141
+ @actual = driver.recording?
142
+ @actual
143
+ end
144
+
145
+ description { "be recording video" }
146
+ failure_message do |_driver|
147
+ "expected driver to be recording, but it is not"
148
+ end
149
+ failure_message_when_negated do |_driver|
150
+ "expected driver NOT to be recording, but it is"
151
+ end
152
+ end
153
+
154
+ RSpec::Matchers.define :have_recorded_video do |path|
155
+ match do |_actual|
156
+ File.exist?(File.expand_path(path)) && File.size(File.expand_path(path)).positive?
157
+ end
158
+
159
+ description { "have recorded video at #{path}" }
160
+ failure_message do |_actual|
161
+ "expected video file to exist at #{path}"
162
+ end
163
+ failure_message_when_negated do |_actual|
164
+ "expected video file NOT to exist at #{path}"
165
+ end
166
+ end
167
+
137
168
  # Selector-based matchers — work with both State and Driver (auto-wait)
138
169
 
139
170
  RSpec::Matchers.define :have_button do |expected|
@@ -424,6 +424,52 @@ module TUITD
424
424
  required: ["name"],
425
425
  },
426
426
  },
427
+ {
428
+ name: "tui_record_start",
429
+ description: "Start recording the current TUI session as a video file (requires ffmpeg). Call before interacting with the TUI. Frames are captured via the screenshot pipeline and encoded incrementally.",
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ path: {
434
+ type: "string",
435
+ description: "Output file path for the video (e.g., /tmp/session.mp4). Required.",
436
+ },
437
+ framerate: {
438
+ type: "integer",
439
+ description: "Frames per second (default: 30).",
440
+ default: 30,
441
+ },
442
+ codec: {
443
+ type: "string",
444
+ description: "Video codec: libx264, libx265, libvpx-vp9 (default: libx264).",
445
+ default: "libx264",
446
+ },
447
+ quality: {
448
+ type: "string",
449
+ enum: %w[high medium low],
450
+ description: "Quality preset: high (best), medium, low (smaller files). Default: high.",
451
+ default: "high",
452
+ },
453
+ },
454
+ required: ["path"],
455
+ },
456
+ },
457
+ {
458
+ name: "tui_record_stop",
459
+ description: "Stop video recording and finalize the video file. Returns the output path.",
460
+ inputSchema: {
461
+ type: "object",
462
+ properties: {},
463
+ },
464
+ },
465
+ {
466
+ name: "tui_record_status",
467
+ description: "Check whether video recording is currently active.",
468
+ inputSchema: {
469
+ type: "object",
470
+ properties: {},
471
+ },
472
+ },
427
473
  {
428
474
  name: "tui_close",
429
475
  description: "Close the TUI application and clean up the PTY session. Call this when finished.",
@@ -461,6 +507,9 @@ module TUITD
461
507
  when "tui_annotate_element" then call_tui_annotate_element(args)
462
508
  when "tui_save_snapshot" then call_tui_save_snapshot(args)
463
509
  when "tui_assert_snapshot" then call_tui_assert_snapshot(args)
510
+ when "tui_record_start" then call_tui_record_start(args)
511
+ when "tui_record_stop" then call_tui_record_stop
512
+ when "tui_record_status" then call_tui_record_status
464
513
  when "tui_close" then call_tui_close
465
514
  else
466
515
  return error_response(id, -32_602, "Unknown tool: #{tool_name}")
@@ -783,6 +832,35 @@ module TUITD
783
832
  end
784
833
  end
785
834
 
835
+ def call_tui_record_start(args)
836
+ ensure_driver!
837
+ path = args["path"] or return "ERROR: 'path' argument is required"
838
+ framerate = args["framerate"] || 30
839
+ codec = args["codec"] || "libx264"
840
+ quality = args["quality"] || "high"
841
+
842
+ safe = safe_path(path, ext: "mp4")
843
+ @driver.start_recording(safe, framerate: framerate, codec: codec, quality: quality)
844
+ "OK: Recording started to #{safe} (#{framerate} fps, #{codec})"
845
+ end
846
+
847
+ def call_tui_record_stop
848
+ ensure_driver!
849
+ return "No recording in progress" unless @driver.recording?
850
+
851
+ path = @driver.stop_recording
852
+ "OK: Recording saved to #{path}"
853
+ end
854
+
855
+ def call_tui_record_status
856
+ ensure_driver!
857
+ if @driver.recording?
858
+ "Recording is active"
859
+ else
860
+ "Not recording"
861
+ end
862
+ end
863
+
786
864
  def call_tui_close
787
865
  @driver&.close
788
866
  @driver = nil
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
3
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists, Metrics/ModuleLength
4
4
 
5
5
  require "minitest"
6
6
 
@@ -234,6 +234,48 @@ module TUITD
234
234
  assert(result, msg)
235
235
  end
236
236
 
237
+ # --- Video Recording ---
238
+
239
+ # Start recording the TUI session as a video (requires ffmpeg).
240
+ # +actual+ must be a Driver.
241
+ def assert_record_start(actual, path, framerate: 30, codec: "libx264", quality: "high")
242
+ unless actual.respond_to?(:start_recording)
243
+ raise ArgumentError, "assert_record_start requires a Driver, got #{actual.class}"
244
+ end
245
+
246
+ result = actual.start_recording(path, framerate: framerate, codec: codec, quality: quality)
247
+ assert(result, "Recording failed to start")
248
+ end
249
+
250
+ # Stop video recording and finalize the video file.
251
+ # +actual+ must be a Driver.
252
+ def assert_record_stop(actual)
253
+ unless actual.respond_to?(:stop_recording)
254
+ raise ArgumentError, "assert_record_stop requires a Driver, got #{actual.class}"
255
+ end
256
+
257
+ path = actual.stop_recording
258
+ assert(path, "No recording in progress")
259
+ end
260
+
261
+ # Verify that recording is currently active.
262
+ def assert_recording(actual)
263
+ unless actual.respond_to?(:recording?)
264
+ raise ArgumentError, "assert_recording requires a Driver, got #{actual.class}"
265
+ end
266
+
267
+ assert(actual.recording?, "Expected recording to be active")
268
+ end
269
+
270
+ # Verify that recording is NOT active.
271
+ def refute_recording(actual)
272
+ unless actual.respond_to?(:recording?)
273
+ raise ArgumentError, "refute_recording requires a Driver, got #{actual.class}"
274
+ end
275
+
276
+ refute(actual.recording?, "Expected recording NOT to be active")
277
+ end
278
+
237
279
  # --- Snapshot ---
238
280
 
239
281
  def assert_snapshot(actual, name, type: :text, wait: false, region: nil, ignore_rows: nil)
@@ -260,4 +302,4 @@ module TUITD
260
302
  end
261
303
  end
262
304
  end
263
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists
305
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/ParameterLists, Metrics/ModuleLength
@@ -215,6 +215,19 @@ module TUITD
215
215
  end
216
216
 
217
217
  def render(output_path)
218
+ image = build_image
219
+ image.save(output_path)
220
+ output_path
221
+ end
222
+
223
+ # Return the rendered PNG as a binary string (for in-memory use, e.g. video recording).
224
+ def to_blob
225
+ build_image.to_blob
226
+ end
227
+
228
+ private
229
+
230
+ def build_image
218
231
  width = @cols * CELL_W
219
232
  height = @rows * CELL_H
220
233
  image = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::BLACK)
@@ -230,13 +243,9 @@ module TUITD
230
243
  end
231
244
 
232
245
  draw_cursor(image)
233
-
234
- image.save(output_path)
235
- output_path
246
+ image
236
247
  end
237
248
 
238
- private
239
-
240
249
  def render_cell(image, ri, ci, cell)
241
250
  char = cell[:char] || cell["char"] || " "
242
251
  fg = cell[:fg] || cell["fg"] || "default"
@@ -227,7 +227,37 @@ module TUITD
227
227
  Result.new(step: action, passed: result.passed?, message: msg)
228
228
  end
229
229
 
230
+ when "start_recording"
231
+ ensure_driver!(driver)
232
+ framerate = step[:framerate] || 30
233
+ codec = step[:codec] || "libx264"
234
+ path = safe_output_path(value.to_s, "mp4")
235
+ driver.start_recording(path, framerate: framerate, codec: codec)
236
+ Result.new(step: action, passed: true, message: "Recording started: #{path}")
237
+
238
+ when "stop_recording"
239
+ ensure_driver!(driver)
240
+ if driver.recording?
241
+ path = driver.stop_recording
242
+ Result.new(step: action, passed: true, message: "Recording saved: #{path}")
243
+ else
244
+ Result.new(step: action, passed: false, message: "No recording in progress")
245
+ end
246
+
247
+ when "assert_recording"
248
+ ensure_driver!(driver)
249
+ expected = value == true || value.nil? # true/nil → assert active; false → assert NOT active
250
+ is_recording = driver.recording?
251
+ if is_recording == expected
252
+ state = expected ? "is active" : "is NOT active"
253
+ Result.new(step: action, passed: true, message: "Recording #{state} (as expected)")
254
+ else
255
+ state = is_recording ? "IS active (expected not)" : "is NOT active (expected active)"
256
+ Result.new(step: action, passed: false, message: "Recording #{state}")
257
+ end
258
+
230
259
  when "close"
260
+ driver&.stop_recording if driver&.recording?
231
261
  driver&.close
232
262
  driver = nil
233
263
  Result.new(step: action, passed: true, message: "Closed")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUITD
4
- VERSION = "0.2.19"
4
+ VERSION = "0.2.21"
5
5
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/CyclomaticComplexity, Naming/PredicateMethod
4
+
5
+ require "English"
6
+
7
+ module TUITD
8
+ # Records TUI sessions as video using ffmpeg.
9
+ #
10
+ # Frames are captured via the existing Screenshot pipeline and piped
11
+ # directly to ffmpeg's stdin as PNG images — no temporary frame files.
12
+ #
13
+ # Usage:
14
+ # recorder = VideoRecorder.new("session.mp4", driver: driver, framerate: 30)
15
+ # recorder.start
16
+ # # ... interact with TUI ...
17
+ # recorder.stop # => "session.mp4"
18
+ #
19
+ class VideoRecorder
20
+ QUALITY_CRF = {
21
+ "high" => 18,
22
+ "medium" => 23,
23
+ "low" => 28,
24
+ }.freeze
25
+
26
+ DEFAULT_QUALITY = "high"
27
+ DEFAULT_FRAMERATE = 30
28
+ DEFAULT_CODEC = "libx264"
29
+
30
+ attr_reader :output_path, :framerate, :codec, :quality
31
+
32
+ def initialize(output_path, driver:, framerate: DEFAULT_FRAMERATE,
33
+ codec: DEFAULT_CODEC, quality: DEFAULT_QUALITY)
34
+ raise Error, "ffmpeg not found. Install ffmpeg to use video recording." unless self.class.available?
35
+
36
+ @output_path = File.expand_path(output_path)
37
+ @driver = driver
38
+ @framerate = framerate
39
+ @codec = codec
40
+ @quality = quality
41
+ @ffmpeg_io = nil
42
+ @capture_thread = nil
43
+ @running = false
44
+ @mutex = Mutex.new
45
+ @frame_interval = 1.0 / framerate
46
+ end
47
+
48
+ # Check whether ffmpeg is available on the system.
49
+ def self.available?
50
+ system("which ffmpeg > /dev/null 2>&1")
51
+ end
52
+
53
+ # Start recording. Spawns ffmpeg and begins frame capture.
54
+ def start
55
+ @mutex.synchronize do
56
+ raise Error, "Recording already in progress" if @running
57
+
58
+ @ffmpeg_io = IO.popen(ffmpeg_command, "w", err: File::NULL)
59
+ @running = true
60
+ end
61
+
62
+ @capture_thread = Thread.new { capture_loop }
63
+ true
64
+ end
65
+
66
+ # Stop recording. Waits for ffmpeg to finalize and returns the output path.
67
+ def stop
68
+ @mutex.synchronize do
69
+ return nil unless @running
70
+
71
+ @running = false
72
+ end
73
+
74
+ @capture_thread&.join(5)
75
+ begin
76
+ @capture_thread&.kill
77
+ rescue StandardError
78
+ nil
79
+ end
80
+ @capture_thread = nil
81
+
82
+ begin
83
+ @ffmpeg_io&.close_write
84
+ @ffmpeg_io&.close
85
+ rescue StandardError
86
+ nil
87
+ end
88
+ @ffmpeg_io = nil
89
+
90
+ @output_path
91
+ end
92
+
93
+ # Is recording currently active?
94
+ def recording?
95
+ @mutex.synchronize { @running }
96
+ end
97
+
98
+ private
99
+
100
+ def ffmpeg_command
101
+ crf = QUALITY_CRF.fetch(@quality, QUALITY_CRF[DEFAULT_QUALITY])
102
+ ffmpeg_bin = TUITD.configuration.ffmpeg_path || "ffmpeg"
103
+
104
+ [
105
+ ffmpeg_bin,
106
+ "-y", # overwrite output
107
+ "-f", "image2pipe", # input format: pipe of images
108
+ "-vcodec", "png", # input codec
109
+ "-r", @framerate.to_s, # input framerate
110
+ "-i", "-", # read from stdin
111
+ "-vcodec", @codec, # output codec
112
+ "-crf", crf.to_s, # quality (lower = better)
113
+ "-pix_fmt", "yuv420p", # wide compatibility
114
+ @output_path,
115
+ ]
116
+ end
117
+
118
+ def capture_loop
119
+ last_frame = nil
120
+
121
+ while recording?
122
+ loop_start = monotonic
123
+
124
+ begin
125
+ state = @driver.state_data
126
+ screenshot = Screenshot.new(state)
127
+ png_blob = screenshot.to_blob
128
+
129
+ # Only write frames that differ from the last one (delta compression)
130
+ if last_frame.nil? || png_blob != last_frame
131
+ @ffmpeg_io&.write(png_blob)
132
+ @ffmpeg_io&.flush
133
+ last_frame = png_blob
134
+ end
135
+ rescue IOError, Errno::EPIPE
136
+ break # ffmpeg pipe closed
137
+ rescue StandardError => e
138
+ warn "[tui-td VideoRecorder] Frame capture error: #{e.class}: #{e.message}"
139
+ end
140
+
141
+ elapsed = monotonic - loop_start
142
+ sleep_time = @frame_interval - elapsed
143
+ sleep(sleep_time) if sleep_time.positive?
144
+ end
145
+ end
146
+
147
+ def monotonic
148
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ end
150
+ end
151
+ end
152
+
153
+ # rubocop:enable Metrics/CyclomaticComplexity, Naming/PredicateMethod
data/lib/tui_td.rb CHANGED
@@ -16,10 +16,10 @@ require_relative "tui_td/state"
16
16
  require_relative "tui_td/configuration"
17
17
  require_relative "tui_td/snapshot"
18
18
  require_relative "tui_td/screenshot"
19
+ require_relative "tui_td/video_recorder"
19
20
  require_relative "tui_td/html_renderer"
20
21
  require_relative "tui_td/test_runner"
21
22
  require_relative "tui_td/selector"
22
- require_relative "tui_td/minitest/assertions"
23
23
  require_relative "tui_td/mcp/server"
24
24
  require_relative "tui_td/cli"
25
25
 
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.19
4
+ version: 0.2.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Haluk Durmus
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: 0.1.3
74
+ version: 0.1.4
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: 0.1.3
81
+ version: 0.1.4
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: bundler-audit
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0.9'
96
+ - !ruby/object:Gem::Dependency
97
+ name: minitest
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '5.15'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '5.15'
96
110
  - !ruby/object:Gem::Dependency
97
111
  name: pry
98
112
  requirement: !ruby/object:Gem::Requirement
@@ -182,6 +196,7 @@ files:
182
196
  - lib/tui_td/test_runner.rb
183
197
  - lib/tui_td/unifont_glyphs.rb
184
198
  - lib/tui_td/version.rb
199
+ - lib/tui_td/video_recorder.rb
185
200
  homepage: https://github.com/vurte/tui-td
186
201
  licenses:
187
202
  - MIT