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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f76a3cec00d302949375e3ad8d9c4c566f8c52cac703db8384b36505d2be47bb
4
- data.tar.gz: 109b533201bcde42f210224cf1a01f5b0b795cb1dc5c737d83a80a0b232d67b3
3
+ metadata.gz: 10ed1ca4c5868a640ac26f54969374fabb4d298f1e25a63fbf898b147410c97a
4
+ data.tar.gz: 36c717726cf643f138140f04d1c6068fc59f04a93d9d3740bc219f4c6fabc6eb
5
5
  SHA512:
6
- metadata.gz: 5b4ca4f5087cc0d32cb335f79fdface896ae918493958ff753f36207f93e44b80a0d1af11f9486c8a6cdcad9d2dd2974599cabfac1afaf158e85d51842d5f71c
7
- data.tar.gz: 52a41a7cc4a35ee019892cea4c67f467bb0e7a9e7f391bba7bfca52a596bda057b2fb92f72c9dc20ef85e700b52985dd049a2bfed6baac874cb3bad8479eb19d
6
+ metadata.gz: 0e227207116c5b6bef3acd692d755499117665b1e774cefdaf8e9f2722dc62865997f7bc2246e64ec54b6b8e2f18992cadd8b49b510ff11702a6d37b0a5b1fe4
7
+ data.tar.gz: 7895ee63692f785d0633e0b35ea2d4d104a318d5bc6f4a27501c4294764367c77c9902d912cd8635976c9f862e5950819fb37f46a7beea09f336fee910eb6989
@@ -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@v4
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@v4
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@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
22
+ uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
23
23
  with:
24
24
  egress-policy: audit
25
25
 
26
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
26
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27
27
  - name: Set up Ruby
28
- uses: ruby/setup-ruby@2e007403fc1ec238429ecaa57af6f22f019cc135 # v1.234.0
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@9e85cb11501bebc2ae661c1500176316d3987059 # v1.1.0
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
- This gem depends on FFmpeg. Please install that package.
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 recording quality and the max width/height (in pixels).
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 "tempfile"
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 record(devtools)
13
- records[devtools] ||= begin
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
- def write_frame(record, data)
29
- return if record.io.nil?
30
-
31
- record.io.write(Base64.decode64(data))
32
- record.frame_count += 1
33
- rescue IOError
34
- # A frame can still arrive after the recording was stopped and the file was closed.
35
- rescue => e
36
- warn "[TestRecorder] Failed to write a screencast frame: #{e.class}: #{e.message}"
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 records
40
- @records ||= {}.compare_by_identity
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
- @tmp_video = Tempfile.new(["testrecorder", ".mjpeg"])
55
- @tmp_video.binmode
56
-
57
- @record = self.class.record(page.driver.browser.devtools)
58
- @record.io = @tmp_video
59
- @record.frame_count = 0
60
-
61
- @every_nth_frame = TestRecorder.every_nth_frame
62
-
63
- @record.page.start_screencast(format: "jpeg", quality: TestRecorder.jpeg_quality, max_width: TestRecorder.max_dimension, max_height: TestRecorder.max_dimension, every_nth_frame: @every_nth_frame)
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
- @record.io = nil
70
- @record.page.stop_screencast
71
- @tmp_video.close!
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
- def stop_and_save(filename)
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(filename).to_s
92
-
93
- # Chrome captures at about 25 fps, but `every_nth_frame` makes it deliver only
94
- # one out of every N frames. So the captured file holds 25 / N frames per second.
95
- # Tell ffmpeg that input rate, otherwise it assumes 25 fps and the video plays
96
- # N times faster than the actual test.
97
- result = system("ffmpeg", "-loglevel", "error", "-f", "image2pipe", "-c:v", "mjpeg", "-framerate", "25/#{@every_nth_frame}", "-i", @tmp_video.path, *FFMPEG_ENCODE_OPTIONS, video_path)
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)}.webm")
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
@@ -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)}.webm").to_s
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
@@ -1,3 +1,3 @@
1
1
  module TestRecorder
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  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
@@ -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.3.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: 3.6.7
117
+ rubygems_version: 4.0.16
113
118
  specification_version: 4
114
119
  summary: Automatically record videos when tests failed.
115
120
  test_files: []