test-recorder 0.4.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 +15 -0
- data/CHANGELOG.md +4 -0
- data/README.md +18 -3
- data/lib/test_recorder/cdp_recorder.rb +40 -103
- 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 +4 -1
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,6 +18,13 @@ 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
30
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -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
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
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
|
+
|
|
3
7
|
## 0.4.0 - 2026-08-30
|
|
4
8
|
|
|
5
9
|
* Record each frame with its own timestamp so that videos play back at the same speed as the test
|
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
|
|
@@ -1,72 +1,31 @@
|
|
|
1
|
-
require "base64"
|
|
2
1
|
require "fileutils"
|
|
3
|
-
require "
|
|
2
|
+
require "selenium-webdriver"
|
|
4
3
|
|
|
5
|
-
require "test_recorder/
|
|
4
|
+
require "test_recorder/screen_recording"
|
|
5
|
+
require "test_recorder/screencast"
|
|
6
6
|
|
|
7
7
|
module TestRecorder
|
|
8
8
|
class CdpRecorder
|
|
9
|
-
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
|
|
10
|
-
|
|
11
|
-
# ffmpeg's -t treats the given duration as an exclusive cutoff, dropping the very frame that
|
|
12
|
-
# marks the video's real length if it lands exactly on it. This nudges -t just past that frame
|
|
13
|
-
# so it is kept, without adding a duration long enough to be noticeable.
|
|
14
|
-
FFMPEG_DURATION_SLACK_S = 0.01
|
|
15
|
-
|
|
16
|
-
Record = Struct.new(:page, :io, :last_metadata_time, :last_metadata_received_at)
|
|
17
|
-
|
|
18
9
|
class << self
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
page = devtools.page
|
|
22
|
-
page.enable
|
|
23
|
-
|
|
24
|
-
record = Record.new(page)
|
|
25
|
-
page.on(:screencast_frame) do |event|
|
|
26
|
-
write_frame(record, event)
|
|
27
|
-
record.page.screencast_frame_ack(session_id: event["sessionId"])
|
|
28
|
-
end
|
|
29
|
-
record
|
|
30
|
-
end
|
|
10
|
+
def screen_recording_supported?(browser)
|
|
11
|
+
support.fetch(browser) { support[browser] = probe_screen_recording(browser) }
|
|
31
12
|
end
|
|
32
13
|
|
|
33
14
|
private
|
|
34
15
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# clock. The protocol marks it optional, so when it is missing, estimate it from the last
|
|
45
|
-
# frame that did carry one plus how much monotonic time has passed since. Falling back to
|
|
46
|
-
# this process's own wall clock instead would silently distort pacing whenever the browser
|
|
47
|
-
# runs on a different host than the driver, since the two wall clocks are not guaranteed to
|
|
48
|
-
# agree.
|
|
49
|
-
def frame_time(record, event)
|
|
50
|
-
metadata = event["metadata"]
|
|
51
|
-
timestamp = metadata && metadata["timestamp"]
|
|
52
|
-
|
|
53
|
-
if timestamp
|
|
54
|
-
record.last_metadata_time = timestamp
|
|
55
|
-
record.last_metadata_received_at = monotonic_time
|
|
56
|
-
timestamp
|
|
57
|
-
elsif record.last_metadata_time
|
|
58
|
-
record.last_metadata_time + (monotonic_time - record.last_metadata_received_at)
|
|
59
|
-
else
|
|
60
|
-
Time.now.to_f
|
|
61
|
-
end
|
|
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
|
|
62
25
|
end
|
|
63
26
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def records
|
|
69
|
-
@records ||= {}.compare_by_identity
|
|
27
|
+
def support
|
|
28
|
+
@support ||= {}.compare_by_identity
|
|
70
29
|
end
|
|
71
30
|
end
|
|
72
31
|
|
|
@@ -80,63 +39,41 @@ module TestRecorder
|
|
|
80
39
|
@started = enabled
|
|
81
40
|
return unless @started
|
|
82
41
|
|
|
83
|
-
|
|
84
|
-
@
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
91
52
|
end
|
|
92
53
|
|
|
93
54
|
def stop_and_discard
|
|
94
55
|
return unless @started
|
|
95
56
|
|
|
96
|
-
@
|
|
97
|
-
|
|
98
|
-
|
|
57
|
+
@recording.stop_and_discard
|
|
58
|
+
rescue => e
|
|
59
|
+
warn "[TestRecorder] Failed to stop the recording: #{e.class}: #{e.message}"
|
|
99
60
|
end
|
|
100
61
|
|
|
101
|
-
|
|
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)
|
|
102
65
|
return "" unless @started
|
|
103
66
|
|
|
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 ""
|
|
116
|
-
end
|
|
117
|
-
|
|
118
67
|
video_dir = ::Rails.root.join("tmp", "videos")
|
|
119
68
|
FileUtils.mkdir_p(video_dir)
|
|
120
|
-
video_path = video_dir.join(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
@tmp_video.close!
|
|
130
|
-
|
|
131
|
-
if result.nil?
|
|
132
|
-
warn "[TestRecorder] Failed to execute ffmpeg. Please make sure that FFmpeg is installed."
|
|
133
|
-
return ""
|
|
134
|
-
elsif !result
|
|
135
|
-
warn "[TestRecorder] ffmpeg failed to encode #{video_path}."
|
|
136
|
-
return ""
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
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
|
+
""
|
|
140
77
|
end
|
|
141
78
|
end
|
|
142
79
|
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
|
|
@@ -90,6 +90,8 @@ files:
|
|
|
90
90
|
- lib/test_recorder/rails/setup_and_teardown.rb
|
|
91
91
|
- lib/test_recorder/rspec.rb
|
|
92
92
|
- lib/test_recorder/rspec/example_wrapper.rb
|
|
93
|
+
- lib/test_recorder/screen_recording.rb
|
|
94
|
+
- lib/test_recorder/screencast.rb
|
|
93
95
|
- lib/test_recorder/version.rb
|
|
94
96
|
- test-recorder.gemspec
|
|
95
97
|
homepage: http://github.com/y-yagi/test-recorder
|
|
@@ -97,6 +99,7 @@ licenses:
|
|
|
97
99
|
- MIT
|
|
98
100
|
metadata:
|
|
99
101
|
homepage_uri: http://github.com/y-yagi/test-recorder
|
|
102
|
+
rubygems_mfa_required: 'true'
|
|
100
103
|
rdoc_options: []
|
|
101
104
|
require_paths:
|
|
102
105
|
- lib
|