test-recorder 0.3.0 → 0.5.0
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/.github/workflows/ci.yml +18 -3
- data/.github/workflows/push_gem.yml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +18 -3
- data/Rakefile +1 -1
- data/lib/test_recorder/cdp_recorder.rb +41 -74
- data/lib/test_recorder/frame_writer.rb +83 -0
- data/lib/test_recorder/matroska_writer.rb +177 -0
- data/lib/test_recorder/rails/setup_and_teardown.rb +1 -1
- data/lib/test_recorder/rspec.rb +1 -1
- data/lib/test_recorder/screen_recording.rb +93 -0
- data/lib/test_recorder/screencast.rb +138 -0
- data/lib/test_recorder/version.rb +1 -1
- data/lib/test_recorder.rb +6 -1
- data/test-recorder.gemspec +1 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10ed1ca4c5868a640ac26f54969374fabb4d298f1e25a63fbf898b147410c97a
|
|
4
|
+
data.tar.gz: 36c717726cf643f138140f04d1c6068fc59f04a93d9d3740bc219f4c6fabc6eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e227207116c5b6bef3acd692d755499117665b1e774cefdaf8e9f2722dc62865997f7bc2246e64ec54b6b8e2f18992cadd8b49b510ff11702a6d37b0a5b1fe4
|
|
7
|
+
data.tar.gz: 7895ee63692f785d0633e0b35ea2d4d104a318d5bc6f4a27501c4294764367c77c9902d912cd8635976c9f862e5950819fb37f46a7beea09f336fee910eb6989
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -18,16 +18,23 @@ jobs:
|
|
|
18
18
|
strategy:
|
|
19
19
|
matrix:
|
|
20
20
|
ruby: [ '4.0', '3.4', '3.3' ]
|
|
21
|
+
chrome: [ 'stable' ]
|
|
22
|
+
include:
|
|
23
|
+
# The last version without Page.startScreenRecording, to cover the screencast fallback.
|
|
24
|
+
- ruby: '4.0'
|
|
25
|
+
chrome: '152'
|
|
26
|
+
|
|
27
|
+
name: build (ruby ${{ matrix.ruby }}, chrome ${{ matrix.chrome }})
|
|
21
28
|
|
|
22
29
|
steps:
|
|
23
|
-
- uses: actions/checkout@
|
|
24
|
-
- uses: ruby/setup-ruby@v1
|
|
30
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
31
|
+
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
|
25
32
|
with:
|
|
26
33
|
ruby-version: ${{ matrix.ruby }}
|
|
27
34
|
bundler-cache: true
|
|
28
35
|
- name: Cache ffmpeg
|
|
29
36
|
id: cache-ffmpeg
|
|
30
|
-
uses: actions/cache@
|
|
37
|
+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
|
31
38
|
with:
|
|
32
39
|
path: ~/.local/bin/ffmpeg
|
|
33
40
|
key: ffmpeg-${{ runner.os }}-${{ env.FFMPEG_BUILD }}
|
|
@@ -43,5 +50,13 @@ jobs:
|
|
|
43
50
|
run: |
|
|
44
51
|
chmod +x "$HOME/.local/bin/ffmpeg"
|
|
45
52
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
53
|
+
- name: Use Chrome ${{ matrix.chrome }}
|
|
54
|
+
if: matrix.chrome != 'stable'
|
|
55
|
+
run: |
|
|
56
|
+
install() { npx -y @puppeteer/browsers install "$1@${{ matrix.chrome }}" --path "$RUNNER_TEMP/browsers" | tail -n 1 | cut -d ' ' -f 2; }
|
|
57
|
+
echo "CHROME_BIN=$(install chrome)" >> "$GITHUB_ENV"
|
|
58
|
+
echo "SE_CHROMEDRIVER=$(install chromedriver)" >> "$GITHUB_ENV"
|
|
59
|
+
# Chrome for Testing is not installed under /opt, so AppArmor blocks its sandbox.
|
|
60
|
+
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
|
46
61
|
- name: Run test
|
|
47
62
|
run: bundle exec rake
|
|
@@ -19,15 +19,15 @@ jobs:
|
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
21
|
- name: Harden Runner
|
|
22
|
-
uses: step-security/harden-runner@
|
|
22
|
+
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
|
23
23
|
with:
|
|
24
24
|
egress-policy: audit
|
|
25
25
|
|
|
26
|
-
- uses: actions/checkout@
|
|
26
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
27
27
|
- name: Set up Ruby
|
|
28
|
-
uses: ruby/setup-ruby@
|
|
28
|
+
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
|
|
29
29
|
with:
|
|
30
30
|
bundler-cache: true
|
|
31
31
|
ruby-version: ruby
|
|
32
32
|
|
|
33
|
-
- uses: rubygems/release-gem@
|
|
33
|
+
- uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 0.5.0 - 2026-10-06
|
|
4
|
+
|
|
5
|
+
* Record with `Page.startScreenRecording` on Chrome 153+. Chrome encodes the video itself as an `.mp4` file, so FFmpeg is no longer needed. Older Chrome versions fall back to the screencast (`.webm`, requires FFmpeg). `TestRecorder.frame_rate` (default: 25) sets the frame rate, and `jpeg_quality` and `every_nth_frame` now only apply to the screencast fallback
|
|
6
|
+
|
|
7
|
+
## 0.4.0 - 2026-08-30
|
|
8
|
+
|
|
9
|
+
* Record each frame with its own timestamp so that videos play back at the same speed as the test
|
|
10
|
+
|
|
3
11
|
## 0.3.0 - 2026-08-23
|
|
4
12
|
|
|
5
13
|
* Reduce recording overhead
|
data/README.md
CHANGED
|
@@ -9,7 +9,9 @@ This gem was inspired by [Record video feature of Playwright](https://playwright
|
|
|
9
9
|
|
|
10
10
|
## Requirements
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Chrome 153 or later records the video by itself (using `Page.startScreenRecording`), so nothing else is needed.
|
|
13
|
+
|
|
14
|
+
With older Chrome versions, this gem falls back to the screencast, and depends on FFmpeg for that. Please install that package.
|
|
13
15
|
|
|
14
16
|
On Debian/Ubuntu:
|
|
15
17
|
|
|
@@ -17,6 +19,13 @@ On Debian/Ubuntu:
|
|
|
17
19
|
sudo apt-get install ffmpeg
|
|
18
20
|
```
|
|
19
21
|
|
|
22
|
+
## Output format
|
|
23
|
+
|
|
24
|
+
The videos are generated in `tmp/videos` directory.
|
|
25
|
+
|
|
26
|
+
* Chrome 153 or later: `.mp4` (AV1)
|
|
27
|
+
* Older Chrome versions: `.webm` (VP8)
|
|
28
|
+
|
|
20
29
|
## Supported libraries
|
|
21
30
|
|
|
22
31
|
Rails system tests and RSpec(System Spec and Feature Spec).
|
|
@@ -98,11 +107,17 @@ end
|
|
|
98
107
|
|
|
99
108
|
### Configuration
|
|
100
109
|
|
|
101
|
-
You can change the
|
|
110
|
+
You can change the max width/height (in pixels) and the frame rate. These apply to both recording methods.
|
|
102
111
|
|
|
103
112
|
```ruby
|
|
104
|
-
TestRecorder.jpeg_quality = 80 # default: 60
|
|
105
113
|
TestRecorder.max_dimension = 1280 # default: 1000
|
|
114
|
+
TestRecorder.frame_rate = 30 # default: 25
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The following settings only apply to the screencast fallback used with Chrome versions older than 153. They are ignored when Chrome records the screen by itself.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
TestRecorder.jpeg_quality = 80 # default: 60
|
|
106
121
|
```
|
|
107
122
|
|
|
108
123
|
You can also record only every Nth frame. This lowers the recording overhead
|
data/Rakefile
CHANGED
|
@@ -6,7 +6,7 @@ Rake::TestTask.new(:test) do |t|
|
|
|
6
6
|
t.libs << "lib"
|
|
7
7
|
t.verbose = true
|
|
8
8
|
t.warning = true
|
|
9
|
-
t.test_files = ["test/integration_test.rb", "test/test_recorder_config_test.rb"]
|
|
9
|
+
t.test_files = ["test/integration_test.rb", "test/frame_writer_test.rb", "test/test_recorder_config_test.rb"]
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
task :default => :test
|
|
@@ -1,43 +1,31 @@
|
|
|
1
|
-
require "base64"
|
|
2
1
|
require "fileutils"
|
|
3
|
-
require "
|
|
2
|
+
require "selenium-webdriver"
|
|
3
|
+
|
|
4
|
+
require "test_recorder/screen_recording"
|
|
5
|
+
require "test_recorder/screencast"
|
|
4
6
|
|
|
5
7
|
module TestRecorder
|
|
6
8
|
class CdpRecorder
|
|
7
|
-
FFMPEG_ENCODE_OPTIONS = %w[-y -an -r 25 -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1].freeze
|
|
8
|
-
|
|
9
|
-
Record = Struct.new(:page, :io, :frame_count)
|
|
10
|
-
|
|
11
9
|
class << self
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
page = devtools.page
|
|
15
|
-
page.enable
|
|
16
|
-
|
|
17
|
-
record = Record.new(page, nil, 0)
|
|
18
|
-
page.on(:screencast_frame) do |event|
|
|
19
|
-
write_frame(record, event["data"])
|
|
20
|
-
record.page.screencast_frame_ack(session_id: event["sessionId"])
|
|
21
|
-
end
|
|
22
|
-
record
|
|
23
|
-
end
|
|
10
|
+
def screen_recording_supported?(browser)
|
|
11
|
+
support.fetch(browser) { support[browser] = probe_screen_recording(browser) }
|
|
24
12
|
end
|
|
25
13
|
|
|
26
14
|
private
|
|
27
15
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
rescue
|
|
34
|
-
|
|
35
|
-
rescue
|
|
36
|
-
|
|
16
|
+
# Stopping when nothing is being recorded tells whether the command exists without starting
|
|
17
|
+
# anything: Chrome 153+ answers "No active screen recording", older versions do not know it.
|
|
18
|
+
def probe_screen_recording(browser)
|
|
19
|
+
browser.execute_cdp("Page.stopScreenRecording")
|
|
20
|
+
true
|
|
21
|
+
rescue Selenium::WebDriver::Error::UnknownCommandError
|
|
22
|
+
false
|
|
23
|
+
rescue Selenium::WebDriver::Error::WebDriverError
|
|
24
|
+
true
|
|
37
25
|
end
|
|
38
26
|
|
|
39
|
-
def
|
|
40
|
-
@
|
|
27
|
+
def support
|
|
28
|
+
@support ||= {}.compare_by_identity
|
|
41
29
|
end
|
|
42
30
|
end
|
|
43
31
|
|
|
@@ -51,62 +39,41 @@ module TestRecorder
|
|
|
51
39
|
@started = enabled
|
|
52
40
|
return unless @started
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
@
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@
|
|
42
|
+
browser = page.driver.browser
|
|
43
|
+
@recording = if self.class.screen_recording_supported?(browser)
|
|
44
|
+
ScreenRecording.new(browser)
|
|
45
|
+
else
|
|
46
|
+
Screencast.new(browser)
|
|
47
|
+
end
|
|
48
|
+
@recording.start
|
|
49
|
+
rescue => e
|
|
50
|
+
warn "[TestRecorder] Failed to start the recording: #{e.class}: #{e.message}"
|
|
51
|
+
@started = false
|
|
64
52
|
end
|
|
65
53
|
|
|
66
54
|
def stop_and_discard
|
|
67
55
|
return unless @started
|
|
68
56
|
|
|
69
|
-
@
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
@recording.stop_and_discard
|
|
58
|
+
rescue => e
|
|
59
|
+
warn "[TestRecorder] Failed to stop the recording: #{e.class}: #{e.message}"
|
|
72
60
|
end
|
|
73
61
|
|
|
74
|
-
|
|
62
|
+
# `name` is the file name without an extension, since the format depends on how it was recorded.
|
|
63
|
+
# Returns the path of the video, or "" when nothing was saved.
|
|
64
|
+
def stop_and_save(name)
|
|
75
65
|
return "" unless @started
|
|
76
66
|
|
|
77
|
-
@record.io = nil
|
|
78
|
-
@record.page.stop_screencast
|
|
79
|
-
@tmp_video.flush
|
|
80
|
-
|
|
81
|
-
# Chrome sends a frame as soon as the screencast starts, so a test that fails
|
|
82
|
-
# before it draws anything still leaves behind the one frame of the blank page
|
|
83
|
-
# it started on.
|
|
84
|
-
if @record.frame_count < 2
|
|
85
|
-
@tmp_video.close!
|
|
86
|
-
return ""
|
|
87
|
-
end
|
|
88
|
-
|
|
89
67
|
video_dir = ::Rails.root.join("tmp", "videos")
|
|
90
68
|
FileUtils.mkdir_p(video_dir)
|
|
91
|
-
video_path = video_dir.join(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@tmp_video.close!
|
|
100
|
-
|
|
101
|
-
if result.nil?
|
|
102
|
-
warn "[TestRecorder] Failed to execute ffmpeg. Please make sure that FFmpeg is installed."
|
|
103
|
-
return ""
|
|
104
|
-
elsif !result
|
|
105
|
-
warn "[TestRecorder] ffmpeg failed to encode #{video_path}."
|
|
106
|
-
return ""
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
video_path
|
|
69
|
+
video_path = video_dir.join("#{name}#{@recording.extension}").to_s
|
|
70
|
+
|
|
71
|
+
@recording.stop_and_save(video_path) ? video_path : ""
|
|
72
|
+
rescue => e
|
|
73
|
+
# The browser can be gone by now (a crash, a closed window). The test's own failure is what
|
|
74
|
+
# matters, so this must not replace it with an error from here.
|
|
75
|
+
warn "[TestRecorder] Failed to save the recording: #{e.class}: #{e.message}"
|
|
76
|
+
""
|
|
110
77
|
end
|
|
111
78
|
end
|
|
112
79
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "test_recorder/matroska_writer"
|
|
2
|
+
|
|
3
|
+
module TestRecorder
|
|
4
|
+
class FrameWriter
|
|
5
|
+
# How long the last frame stays visible at the end of the video. A test usually fails some
|
|
6
|
+
# time after the last repaint, and that last screen is the one worth looking at.
|
|
7
|
+
MIN_LAST_FRAME_DURATION = 1.0
|
|
8
|
+
MAX_LAST_FRAME_DURATION = 5.0
|
|
9
|
+
|
|
10
|
+
def initialize(io)
|
|
11
|
+
@container = MatroskaWriter.new(io)
|
|
12
|
+
@first_frame_time = nil
|
|
13
|
+
@last_frame = nil
|
|
14
|
+
@last_write_at = nil
|
|
15
|
+
@last_timestamp_ms = 0
|
|
16
|
+
@duration = nil
|
|
17
|
+
@frame_count = 0
|
|
18
|
+
@finished = false
|
|
19
|
+
# CDP dispatches every screencast frame event on its own thread, and `finish` can run on the
|
|
20
|
+
# main thread while a frame is still in flight, so all access to the state above and to the
|
|
21
|
+
# container is serialized through this.
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def empty?
|
|
26
|
+
@last_frame.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# How many seconds the finished video should run for. ffmpeg does not reliably infer this from
|
|
30
|
+
# the container alone, so `stop_and_save` passes it to ffmpeg explicitly. Only meaningful after
|
|
31
|
+
# `finish` has run.
|
|
32
|
+
attr_reader :duration
|
|
33
|
+
|
|
34
|
+
# Chrome sends a frame as soon as the screencast starts, so a test that fails before it draws
|
|
35
|
+
# anything still leaves behind the one frame of the blank page it started on. Callers use this
|
|
36
|
+
# to tell that apart from a real recording.
|
|
37
|
+
attr_reader :frame_count
|
|
38
|
+
|
|
39
|
+
def write(frame, time)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @finished
|
|
42
|
+
|
|
43
|
+
@first_frame_time ||= time
|
|
44
|
+
write_at(frame, time)
|
|
45
|
+
@last_frame = frame
|
|
46
|
+
@last_write_at = monotonic_time
|
|
47
|
+
@frame_count += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Repeats the last frame at the end of the recording so that it stays on screen, and so that
|
|
52
|
+
# the video covers the time between the last repaint and the end of the test. That interval is
|
|
53
|
+
# measured with the monotonic clock, so a wall clock adjustment cannot stretch or shrink it.
|
|
54
|
+
def finish
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return if @finished
|
|
57
|
+
|
|
58
|
+
@finished = true
|
|
59
|
+
return if empty?
|
|
60
|
+
|
|
61
|
+
elapsed = (monotonic_time - @last_write_at).clamp(MIN_LAST_FRAME_DURATION, MAX_LAST_FRAME_DURATION)
|
|
62
|
+
write_ms(@last_frame, @last_timestamp_ms + (elapsed * 1000).round)
|
|
63
|
+
@duration = @last_timestamp_ms / 1000.0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def monotonic_time
|
|
70
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write_at(frame, time)
|
|
74
|
+
write_ms(frame, ((time - @first_frame_time) * 1000).round)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_ms(frame, timestamp_ms)
|
|
78
|
+
timestamp_ms = [timestamp_ms, @last_timestamp_ms].max
|
|
79
|
+
@container.write_frame(frame, timestamp_ms)
|
|
80
|
+
@last_timestamp_ms = timestamp_ms
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
module TestRecorder
|
|
2
|
+
# Writes a Matroska stream holding a single MJPEG video track, where every frame carries the
|
|
3
|
+
# time it was captured. Chrome emits a screencast frame only when the page repaints, so frames
|
|
4
|
+
# are not evenly spaced; keeping the real times lets ffmpeg reproduce the original pacing
|
|
5
|
+
# instead of assuming a fixed frame rate.
|
|
6
|
+
#
|
|
7
|
+
# Written against the specifications:
|
|
8
|
+
# EBML https://datatracker.ietf.org/doc/html/rfc8794
|
|
9
|
+
# Matroska https://datatracker.ietf.org/doc/html/rfc9559
|
|
10
|
+
# Codecs https://datatracker.ietf.org/doc/html/draft-ietf-cellar-codec
|
|
11
|
+
#
|
|
12
|
+
# RFC 9559 updates RFC 8794, so read the two together: it makes 0x80 a legal EBML ID, which
|
|
13
|
+
# leaves 0xFF as the only reserved one. Codec IDs such as V_MJPEG are defined by neither, but by
|
|
14
|
+
# the codec document, which is still a draft.
|
|
15
|
+
#
|
|
16
|
+
# Only the elements those specifications require for this kind of stream are emitted. The stream
|
|
17
|
+
# is written as it is captured, so the sizes of the Segment and of each Cluster are not known
|
|
18
|
+
# when they start and are left unknown, which Matroska allows for those two elements alone.
|
|
19
|
+
class MatroskaWriter
|
|
20
|
+
# Element IDs, as defined by Matroska. The leading byte of an ID already encodes how many
|
|
21
|
+
# bytes the ID occupies, so an ID is written as the minimal big endian form of these numbers.
|
|
22
|
+
module Id
|
|
23
|
+
EBML = 0x1A45DFA3
|
|
24
|
+
EBML_VERSION = 0x4286
|
|
25
|
+
EBML_READ_VERSION = 0x42F7
|
|
26
|
+
EBML_MAX_ID_LENGTH = 0x42F2
|
|
27
|
+
EBML_MAX_SIZE_LENGTH = 0x42F3
|
|
28
|
+
DOC_TYPE = 0x4282
|
|
29
|
+
DOC_TYPE_VERSION = 0x4287
|
|
30
|
+
DOC_TYPE_READ_VERSION = 0x4285
|
|
31
|
+
SEGMENT = 0x18538067
|
|
32
|
+
INFO = 0x1549A966
|
|
33
|
+
TIMESTAMP_SCALE = 0x2AD7B1
|
|
34
|
+
MUXING_APP = 0x4D80
|
|
35
|
+
WRITING_APP = 0x5741
|
|
36
|
+
TRACKS = 0x1654AE6B
|
|
37
|
+
TRACK_ENTRY = 0xAE
|
|
38
|
+
TRACK_NUMBER = 0xD7
|
|
39
|
+
TRACK_UID = 0x73C5
|
|
40
|
+
TRACK_TYPE = 0x83
|
|
41
|
+
FLAG_LACING = 0x9C
|
|
42
|
+
CODEC_ID = 0x86
|
|
43
|
+
VIDEO = 0xE0
|
|
44
|
+
PIXEL_WIDTH = 0xB0
|
|
45
|
+
PIXEL_HEIGHT = 0xBA
|
|
46
|
+
CLUSTER = 0x1F43B675
|
|
47
|
+
TIMESTAMP = 0xE7
|
|
48
|
+
SIMPLE_BLOCK = 0xA3
|
|
49
|
+
end
|
|
50
|
+
private_constant :Id
|
|
51
|
+
|
|
52
|
+
# A size whose data bits are all ones means "unknown". One byte is enough to say that.
|
|
53
|
+
UNKNOWN_SIZE = [0xFF].pack("C")
|
|
54
|
+
|
|
55
|
+
# TimestampScale is given in nanoseconds per tick, so this makes every timestamp in the stream
|
|
56
|
+
# a number of milliseconds.
|
|
57
|
+
TIMESTAMP_SCALE_NS = 1_000_000
|
|
58
|
+
|
|
59
|
+
# A block states its time as a 16 bit signed offset from the timestamp of its cluster, so a
|
|
60
|
+
# cluster can only cover about 32 seconds. Matroska also recommends keeping clusters short,
|
|
61
|
+
# a few seconds at most.
|
|
62
|
+
MAX_CLUSTER_DURATION_MS = 5_000
|
|
63
|
+
|
|
64
|
+
TRACK_NUMBER = 1
|
|
65
|
+
TRACK_TYPE_VIDEO = 1
|
|
66
|
+
KEYFRAME = 0x80
|
|
67
|
+
|
|
68
|
+
def initialize(io)
|
|
69
|
+
@io = io
|
|
70
|
+
@cluster_timestamp_ms = nil
|
|
71
|
+
|
|
72
|
+
write_header
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Appends one JPEG, shown at the given number of milliseconds from the start of the stream.
|
|
76
|
+
# Timestamps must not go backwards.
|
|
77
|
+
def write_frame(frame, timestamp_ms)
|
|
78
|
+
open_cluster(timestamp_ms) if start_new_cluster?(timestamp_ms)
|
|
79
|
+
|
|
80
|
+
# The block header is written separately from the frame so that the frame, which is by far
|
|
81
|
+
# the largest part, never has to be copied into another string.
|
|
82
|
+
@io.write(simple_block_header(frame.bytesize, timestamp_ms - @cluster_timestamp_ms))
|
|
83
|
+
@io.write(frame)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def write_header
|
|
89
|
+
@io.write(element(Id::EBML, [
|
|
90
|
+
element(Id::EBML_VERSION, uint(1)),
|
|
91
|
+
element(Id::EBML_READ_VERSION, uint(1)),
|
|
92
|
+
element(Id::EBML_MAX_ID_LENGTH, uint(4)),
|
|
93
|
+
element(Id::EBML_MAX_SIZE_LENGTH, uint(8)),
|
|
94
|
+
element(Id::DOC_TYPE, "matroska"),
|
|
95
|
+
element(Id::DOC_TYPE_VERSION, uint(4)),
|
|
96
|
+
element(Id::DOC_TYPE_READ_VERSION, uint(2))
|
|
97
|
+
].join))
|
|
98
|
+
|
|
99
|
+
# Everything below lives inside the segment, which stays open until the file ends.
|
|
100
|
+
@io.write(id(Id::SEGMENT) + UNKNOWN_SIZE)
|
|
101
|
+
|
|
102
|
+
@io.write(element(Id::INFO, [
|
|
103
|
+
element(Id::TIMESTAMP_SCALE, uint(TIMESTAMP_SCALE_NS)),
|
|
104
|
+
element(Id::MUXING_APP, "test-recorder"),
|
|
105
|
+
element(Id::WRITING_APP, "test-recorder")
|
|
106
|
+
].join))
|
|
107
|
+
|
|
108
|
+
@io.write(element(Id::TRACKS, element(Id::TRACK_ENTRY, [
|
|
109
|
+
element(Id::TRACK_NUMBER, uint(TRACK_NUMBER)),
|
|
110
|
+
element(Id::TRACK_UID, uint(TRACK_NUMBER)),
|
|
111
|
+
element(Id::TRACK_TYPE, uint(TRACK_TYPE_VIDEO)),
|
|
112
|
+
# Lacing packs several frames into one block. Each frame here is a whole picture with its
|
|
113
|
+
# own time, so it is turned off.
|
|
114
|
+
element(Id::FLAG_LACING, uint(0)),
|
|
115
|
+
element(Id::CODEC_ID, "V_MJPEG"),
|
|
116
|
+
# PixelWidth and PixelHeight are mandatory, but the frame size is not known before the
|
|
117
|
+
# first frame arrives, and the browser may change it mid recording. Every JPEG states its
|
|
118
|
+
# own size, so declare the smallest allowed picture and let the decoder use the real one.
|
|
119
|
+
# Declaring a plausible size instead is actively harmful: ffmpeg then compares it with the
|
|
120
|
+
# size it decodes and can read a shorter picture as one field of an interlaced frame.
|
|
121
|
+
element(Id::VIDEO, element(Id::PIXEL_WIDTH, uint(1)) + element(Id::PIXEL_HEIGHT, uint(1)))
|
|
122
|
+
].join)))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def start_new_cluster?(timestamp_ms)
|
|
126
|
+
@cluster_timestamp_ms.nil? || timestamp_ms - @cluster_timestamp_ms > MAX_CLUSTER_DURATION_MS
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def open_cluster(timestamp_ms)
|
|
130
|
+
@io.write(id(Id::CLUSTER) + UNKNOWN_SIZE)
|
|
131
|
+
@io.write(element(Id::TIMESTAMP, uint(timestamp_ms)))
|
|
132
|
+
@cluster_timestamp_ms = timestamp_ms
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def simple_block_header(frame_length, relative_ms)
|
|
136
|
+
track = vint(TRACK_NUMBER)
|
|
137
|
+
# The payload is the track number, the 16 bit offset, the flags and then the frame itself.
|
|
138
|
+
size = track.bytesize + 2 + 1 + frame_length
|
|
139
|
+
|
|
140
|
+
id(Id::SIMPLE_BLOCK) + vint(size) + track + [relative_ms].pack("s>") + [KEYFRAME].pack("C")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# An element is its ID, then its length as a variable size integer, then its content.
|
|
144
|
+
def element(element_id, content)
|
|
145
|
+
id(element_id) + vint(content.bytesize) + content
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A variable size integer starts with a marker bit that says how many bytes it spans, and
|
|
149
|
+
# carries the value in the bits after it. A value that would fill every remaining bit is
|
|
150
|
+
# reserved to mean "unknown", so it needs one more byte.
|
|
151
|
+
def vint(value)
|
|
152
|
+
width = 1
|
|
153
|
+
width += 1 until value < (1 << (7 * width)) - 1
|
|
154
|
+
|
|
155
|
+
bytes = big_endian_bytes(value, width)
|
|
156
|
+
bytes[0] |= 1 << (8 - width)
|
|
157
|
+
bytes.pack("C*")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Numbers are stored big endian, in as few bytes as they fit into.
|
|
161
|
+
def uint(value)
|
|
162
|
+
big_endian_bytes(value).pack("C*")
|
|
163
|
+
end
|
|
164
|
+
alias id uint
|
|
165
|
+
|
|
166
|
+
def big_endian_bytes(value, width = nil)
|
|
167
|
+
bytes = []
|
|
168
|
+
loop do
|
|
169
|
+
bytes.unshift(value & 0xFF)
|
|
170
|
+
value >>= 8
|
|
171
|
+
break if value.zero?
|
|
172
|
+
end
|
|
173
|
+
bytes.unshift(0) while width && bytes.size < width
|
|
174
|
+
bytes
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -11,7 +11,7 @@ module TestRecorder
|
|
|
11
11
|
|
|
12
12
|
def before_teardown
|
|
13
13
|
if failures.any? { |failure| !failure.is_a?(Minitest::Skip) }
|
|
14
|
-
video_path = @cdp_recorder.stop_and_save("failures_#{TestRecorder.sanitize_filename(self.name)}
|
|
14
|
+
video_path = @cdp_recorder.stop_and_save("failures_#{TestRecorder.sanitize_filename(self.name)}")
|
|
15
15
|
puts "[Video]: #{video_path}" if File.exist?(video_path)
|
|
16
16
|
else
|
|
17
17
|
@cdp_recorder.stop_and_discard
|
data/lib/test_recorder/rspec.rb
CHANGED
|
@@ -11,7 +11,7 @@ module TestRecorder
|
|
|
11
11
|
if passed?(example)
|
|
12
12
|
cdp_recorder.stop_and_discard
|
|
13
13
|
else
|
|
14
|
-
video_path = cdp_recorder.stop_and_save("failures_#{method_name(example)}
|
|
14
|
+
video_path = cdp_recorder.stop_and_save("failures_#{method_name(example)}").to_s
|
|
15
15
|
if File.exist?(video_path)
|
|
16
16
|
example.metadata[:extra_failure_lines] = [example.metadata[:extra_failure_lines], "[Video]: #{video_path}"].flatten
|
|
17
17
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
|
|
3
|
+
module TestRecorder
|
|
4
|
+
# Records with `Page.startScreenRecording` (Chrome 153+). Chrome captures the page at a fixed
|
|
5
|
+
# frame rate and encodes an MP4 itself, so nothing needs to be encoded on this side. The commands
|
|
6
|
+
# are sent with `execute_cdp`, which does not need the selenium-devtools gem.
|
|
7
|
+
class ScreenRecording
|
|
8
|
+
EXTENSION = ".mp4".freeze
|
|
9
|
+
|
|
10
|
+
# The size of one `IO.read`.
|
|
11
|
+
READ_SIZE = 1 << 20
|
|
12
|
+
|
|
13
|
+
# What a new Chrome session shows before the test visits anything ("data:," is what
|
|
14
|
+
# chromedriver opens with).
|
|
15
|
+
BLANK_URLS = ["data:,", "about:blank"].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(browser)
|
|
18
|
+
@browser = browser
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def extension
|
|
22
|
+
EXTENSION
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
@stream = @browser.execute_cdp("Page.startScreenRecording", maxWidth: TestRecorder.max_dimension, maxHeight: TestRecorder.max_dimension, frameRate: TestRecorder.frame_rate)["stream"]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop_and_discard
|
|
30
|
+
stop
|
|
31
|
+
ensure
|
|
32
|
+
close_stream
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns true when the video was written to `video_path`.
|
|
36
|
+
def stop_and_save(video_path)
|
|
37
|
+
# Chrome captures at a fixed frame rate whether or not anything is drawn, so a test that
|
|
38
|
+
# fails before it visits a page would still leave behind a video of the blank page it
|
|
39
|
+
# started on.
|
|
40
|
+
if blank_page?
|
|
41
|
+
stop
|
|
42
|
+
warn "[TestRecorder] The test failed before any page was loaded, so no video was saved."
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
stop
|
|
47
|
+
read_stream_into(video_path)
|
|
48
|
+
|
|
49
|
+
if File.zero?(video_path)
|
|
50
|
+
warn "[TestRecorder] The screen recording was empty, so no video was saved."
|
|
51
|
+
File.delete(video_path)
|
|
52
|
+
return false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
true
|
|
56
|
+
rescue
|
|
57
|
+
File.delete(video_path) if File.exist?(video_path)
|
|
58
|
+
raise
|
|
59
|
+
ensure
|
|
60
|
+
close_stream
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def blank_page?
|
|
66
|
+
BLANK_URLS.include?(@browser.current_url)
|
|
67
|
+
rescue
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The stream stays readable after this, so it has to be closed even if nobody reads it.
|
|
72
|
+
def stop
|
|
73
|
+
@browser.execute_cdp("Page.stopScreenRecording")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_stream_into(video_path)
|
|
77
|
+
File.open(video_path, "wb") do |file|
|
|
78
|
+
loop do
|
|
79
|
+
chunk = @browser.execute_cdp("IO.read", handle: @stream, size: READ_SIZE)
|
|
80
|
+
file.write(chunk["base64Encoded"] ? Base64.decode64(chunk["data"]) : chunk["data"])
|
|
81
|
+
break if chunk["eof"]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Runs on the way out of a failed stop or read too, so its own error must not hide that one.
|
|
87
|
+
def close_stream
|
|
88
|
+
@browser.execute_cdp("IO.close", handle: @stream)
|
|
89
|
+
rescue => e
|
|
90
|
+
warn "[TestRecorder] Failed to close the screen recording stream: #{e.class}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
|
|
4
|
+
require "test_recorder/frame_writer"
|
|
5
|
+
|
|
6
|
+
module TestRecorder
|
|
7
|
+
# Records with `Page.startScreencast`, which sends a JPEG for every repaint, and has ffmpeg
|
|
8
|
+
# encode them into a video afterwards. This is the fallback for Chrome versions without
|
|
9
|
+
# `Page.startScreenRecording`.
|
|
10
|
+
class Screencast
|
|
11
|
+
EXTENSION = ".webm".freeze
|
|
12
|
+
|
|
13
|
+
FFMPEG_ENCODE_OPTIONS = %w[-y -an -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1].freeze
|
|
14
|
+
|
|
15
|
+
# ffmpeg's -t treats the given duration as an exclusive cutoff, dropping the very frame that
|
|
16
|
+
# marks the video's real length if it lands exactly on it. This nudges -t just past that frame
|
|
17
|
+
# so it is kept, without adding a duration long enough to be noticeable.
|
|
18
|
+
FFMPEG_DURATION_SLACK_S = 0.01
|
|
19
|
+
|
|
20
|
+
Record = Struct.new(:page, :io, :last_metadata_time, :last_metadata_received_at)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def record(devtools)
|
|
24
|
+
records[devtools] ||= begin
|
|
25
|
+
page = devtools.page
|
|
26
|
+
page.enable
|
|
27
|
+
|
|
28
|
+
record = Record.new(page)
|
|
29
|
+
page.on(:screencast_frame) do |event|
|
|
30
|
+
write_frame(record, event)
|
|
31
|
+
record.page.screencast_frame_ack(session_id: event["sessionId"])
|
|
32
|
+
end
|
|
33
|
+
record
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def write_frame(record, event)
|
|
40
|
+
record.io&.write(Base64.decode64(event["data"]), frame_time(record, event))
|
|
41
|
+
rescue IOError
|
|
42
|
+
# A frame can still arrive after the recording was stopped and the file was closed.
|
|
43
|
+
rescue => e
|
|
44
|
+
warn "[TestRecorder] Failed to write a screencast frame: #{e.class}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `metadata.timestamp` is the wall clock time of the frame swap, in seconds, on the browser's
|
|
48
|
+
# clock. The protocol marks it optional, so when it is missing, estimate it from the last
|
|
49
|
+
# frame that did carry one plus how much monotonic time has passed since. Falling back to
|
|
50
|
+
# this process's own wall clock instead would silently distort pacing whenever the browser
|
|
51
|
+
# runs on a different host than the driver, since the two wall clocks are not guaranteed to
|
|
52
|
+
# agree.
|
|
53
|
+
def frame_time(record, event)
|
|
54
|
+
metadata = event["metadata"]
|
|
55
|
+
timestamp = metadata && metadata["timestamp"]
|
|
56
|
+
|
|
57
|
+
if timestamp
|
|
58
|
+
record.last_metadata_time = timestamp
|
|
59
|
+
record.last_metadata_received_at = monotonic_time
|
|
60
|
+
timestamp
|
|
61
|
+
elsif record.last_metadata_time
|
|
62
|
+
record.last_metadata_time + (monotonic_time - record.last_metadata_received_at)
|
|
63
|
+
else
|
|
64
|
+
Time.now.to_f
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def monotonic_time
|
|
69
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def records
|
|
73
|
+
@records ||= {}.compare_by_identity
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize(browser)
|
|
78
|
+
@browser = browser
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extension
|
|
82
|
+
EXTENSION
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def start
|
|
86
|
+
@tmp_video = Tempfile.new(["testrecorder", ".mkv"])
|
|
87
|
+
@tmp_video.binmode
|
|
88
|
+
|
|
89
|
+
@record = self.class.record(@browser.devtools)
|
|
90
|
+
@frame_writer = FrameWriter.new(@tmp_video)
|
|
91
|
+
@record.io = @frame_writer
|
|
92
|
+
|
|
93
|
+
@record.page.start_screencast(format: "jpeg", quality: TestRecorder.jpeg_quality, max_width: TestRecorder.max_dimension, max_height: TestRecorder.max_dimension, every_nth_frame: TestRecorder.every_nth_frame)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stop_and_discard
|
|
97
|
+
@record.io = nil
|
|
98
|
+
@record.page.stop_screencast
|
|
99
|
+
@tmp_video.close!
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns true when the video was written to `video_path`.
|
|
103
|
+
def stop_and_save(video_path)
|
|
104
|
+
@record.io = nil
|
|
105
|
+
@record.page.stop_screencast
|
|
106
|
+
@frame_writer.finish
|
|
107
|
+
@tmp_video.flush
|
|
108
|
+
|
|
109
|
+
# Chrome sends a frame as soon as the screencast starts, so a test that fails
|
|
110
|
+
# before it draws anything still leaves behind the one frame of the blank page
|
|
111
|
+
# it started on.
|
|
112
|
+
if @frame_writer.frame_count < 2
|
|
113
|
+
warn "[TestRecorder] The screencast captured no page updates, so no video was saved."
|
|
114
|
+
@tmp_video.close!
|
|
115
|
+
return false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Each frame carries its own timestamp in the Matroska stream, so ffmpeg knows how long to
|
|
119
|
+
# hold it and duplicates frames to reach the constant output frame rate on its own. Without a
|
|
120
|
+
# known end, though, ffmpeg pads however long it likes past the last real timestamp, so -t
|
|
121
|
+
# caps the output at the video's real duration explicitly.
|
|
122
|
+
duration = @frame_writer.duration + FFMPEG_DURATION_SLACK_S
|
|
123
|
+
result = system("ffmpeg", "-loglevel", "error", "-f", "matroska", "-i", @tmp_video.path, "-t", duration.to_s, "-r", TestRecorder.frame_rate.to_s, *FFMPEG_ENCODE_OPTIONS, video_path)
|
|
124
|
+
|
|
125
|
+
@tmp_video.close!
|
|
126
|
+
|
|
127
|
+
if result.nil?
|
|
128
|
+
warn "[TestRecorder] Failed to execute ffmpeg. Please make sure that FFmpeg is installed."
|
|
129
|
+
return false
|
|
130
|
+
elsif !result
|
|
131
|
+
warn "[TestRecorder] ffmpeg failed to encode #{video_path}."
|
|
132
|
+
return false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/test_recorder.rb
CHANGED
|
@@ -4,11 +4,12 @@ module TestRecorder
|
|
|
4
4
|
DEFAULT_JPEG_QUALITY = 60
|
|
5
5
|
DEFAULT_MAX_DIMENSION = 1000
|
|
6
6
|
DEFAULT_EVERY_NTH_FRAME = 1
|
|
7
|
+
DEFAULT_FRAME_RATE = 25
|
|
7
8
|
|
|
8
9
|
CHARS_TO_TRANSLATE = ['/', '.', ':', ',', "'", '"', " "].freeze
|
|
9
10
|
|
|
10
11
|
class << self
|
|
11
|
-
attr_writer :jpeg_quality, :max_dimension, :every_nth_frame
|
|
12
|
+
attr_writer :jpeg_quality, :max_dimension, :every_nth_frame, :frame_rate
|
|
12
13
|
|
|
13
14
|
def enable!
|
|
14
15
|
@enable = true
|
|
@@ -34,6 +35,10 @@ module TestRecorder
|
|
|
34
35
|
defined?(@every_nth_frame) ? @every_nth_frame : DEFAULT_EVERY_NTH_FRAME
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
def frame_rate
|
|
39
|
+
defined?(@frame_rate) ? @frame_rate : DEFAULT_FRAME_RATE
|
|
40
|
+
end
|
|
41
|
+
|
|
37
42
|
def sanitize_filename(name)
|
|
38
43
|
name.tr(CHARS_TO_TRANSLATE.join, "_")
|
|
39
44
|
end
|
data/test-recorder.gemspec
CHANGED
|
@@ -12,6 +12,7 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
|
13
13
|
|
|
14
14
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
15
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
15
16
|
|
|
16
17
|
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
|
17
18
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: test-recorder
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuji Yaginuma
|
|
@@ -84,10 +84,14 @@ files:
|
|
|
84
84
|
- lib/test-recorder.rb
|
|
85
85
|
- lib/test_recorder.rb
|
|
86
86
|
- lib/test_recorder/cdp_recorder.rb
|
|
87
|
+
- lib/test_recorder/frame_writer.rb
|
|
88
|
+
- lib/test_recorder/matroska_writer.rb
|
|
87
89
|
- lib/test_recorder/rails.rb
|
|
88
90
|
- lib/test_recorder/rails/setup_and_teardown.rb
|
|
89
91
|
- lib/test_recorder/rspec.rb
|
|
90
92
|
- lib/test_recorder/rspec/example_wrapper.rb
|
|
93
|
+
- lib/test_recorder/screen_recording.rb
|
|
94
|
+
- lib/test_recorder/screencast.rb
|
|
91
95
|
- lib/test_recorder/version.rb
|
|
92
96
|
- test-recorder.gemspec
|
|
93
97
|
homepage: http://github.com/y-yagi/test-recorder
|
|
@@ -95,6 +99,7 @@ licenses:
|
|
|
95
99
|
- MIT
|
|
96
100
|
metadata:
|
|
97
101
|
homepage_uri: http://github.com/y-yagi/test-recorder
|
|
102
|
+
rubygems_mfa_required: 'true'
|
|
98
103
|
rdoc_options: []
|
|
99
104
|
require_paths:
|
|
100
105
|
- lib
|
|
@@ -109,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
109
114
|
- !ruby/object:Gem::Version
|
|
110
115
|
version: '0'
|
|
111
116
|
requirements: []
|
|
112
|
-
rubygems_version:
|
|
117
|
+
rubygems_version: 4.0.16
|
|
113
118
|
specification_version: 4
|
|
114
119
|
summary: Automatically record videos when tests failed.
|
|
115
120
|
test_files: []
|