test-recorder 0.3.0 → 0.4.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: f2df7b7454c1776d950afaadc9fcefdab037f2638a0a0f05ba7251ef08131428
4
+ data.tar.gz: 97e45933561090c772aef6a26849075626c7d8e9766d0e25c60a642841a38f36
5
5
  SHA512:
6
- metadata.gz: 5b4ca4f5087cc0d32cb335f79fdface896ae918493958ff753f36207f93e44b80a0d1af11f9486c8a6cdcad9d2dd2974599cabfac1afaf158e85d51842d5f71c
7
- data.tar.gz: 52a41a7cc4a35ee019892cea4c67f467bb0e7a9e7f391bba7bfca52a596bda057b2fb92f72c9dc20ef85e700b52985dd049a2bfed6baac874cb3bad8479eb19d
6
+ metadata.gz: 2ced41859d336565547e55de99b4574952f175bc3acb7ce6a6231c07ce32bab27483689f6a9c3967950d4da80145ea2002a0abe2ba3b6905c56fa9728902e004
7
+ data.tar.gz: 1657c90f69bcf393bb7e4905fde1a9e6afc74b7c5e864521a72bee0f4d3f97db9b1ceba28e466d321e7122ce2545548431ccd593a576e53f2b7c3c45d34214da
@@ -20,14 +20,14 @@ jobs:
20
20
  ruby: [ '4.0', '3.4', '3.3' ]
21
21
 
22
22
  steps:
23
- - uses: actions/checkout@v4
24
- - uses: ruby/setup-ruby@v1
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
+ - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
25
25
  with:
26
26
  ruby-version: ${{ matrix.ruby }}
27
27
  bundler-cache: true
28
28
  - name: Cache ffmpeg
29
29
  id: cache-ffmpeg
30
- uses: actions/cache@v4
30
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
31
31
  with:
32
32
  path: ~/.local/bin/ffmpeg
33
33
  key: ffmpeg-${{ runner.os }}-${{ env.FFMPEG_BUILD }}
@@ -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,9 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 0.4.0 - 2026-08-30
4
+
5
+ * Record each frame with its own timestamp so that videos play back at the same speed as the test
6
+
3
7
  ## 0.3.0 - 2026-08-23
4
8
 
5
9
  * Reduce 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
@@ -2,11 +2,18 @@ require "base64"
2
2
  require "fileutils"
3
3
  require "tempfile"
4
4
 
5
+ require "test_recorder/frame_writer"
6
+
5
7
  module TestRecorder
6
8
  class CdpRecorder
7
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
8
10
 
9
- Record = Struct.new(:page, :io, :frame_count)
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)
10
17
 
11
18
  class << self
12
19
  def record(devtools)
@@ -14,9 +21,9 @@ module TestRecorder
14
21
  page = devtools.page
15
22
  page.enable
16
23
 
17
- record = Record.new(page, nil, 0)
24
+ record = Record.new(page)
18
25
  page.on(:screencast_frame) do |event|
19
- write_frame(record, event["data"])
26
+ write_frame(record, event)
20
27
  record.page.screencast_frame_ack(session_id: event["sessionId"])
21
28
  end
22
29
  record
@@ -25,17 +32,39 @@ module TestRecorder
25
32
 
26
33
  private
27
34
 
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
35
+ def write_frame(record, event)
36
+ record.io&.write(Base64.decode64(event["data"]), frame_time(record, event))
33
37
  rescue IOError
34
38
  # A frame can still arrive after the recording was stopped and the file was closed.
35
39
  rescue => e
36
40
  warn "[TestRecorder] Failed to write a screencast frame: #{e.class}: #{e.message}"
37
41
  end
38
42
 
43
+ # `metadata.timestamp` is the wall clock time of the frame swap, in seconds, on the browser's
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
62
+ end
63
+
64
+ def monotonic_time
65
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ end
67
+
39
68
  def records
40
69
  @records ||= {}.compare_by_identity
41
70
  end
@@ -51,16 +80,14 @@ module TestRecorder
51
80
  @started = enabled
52
81
  return unless @started
53
82
 
54
- @tmp_video = Tempfile.new(["testrecorder", ".mjpeg"])
83
+ @tmp_video = Tempfile.new(["testrecorder", ".mkv"])
55
84
  @tmp_video.binmode
56
85
 
57
86
  @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
87
+ @frame_writer = FrameWriter.new(@tmp_video)
88
+ @record.io = @frame_writer
62
89
 
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)
90
+ @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)
64
91
  end
65
92
 
66
93
  def stop_and_discard
@@ -76,12 +103,14 @@ module TestRecorder
76
103
 
77
104
  @record.io = nil
78
105
  @record.page.stop_screencast
106
+ @frame_writer.finish
79
107
  @tmp_video.flush
80
108
 
81
109
  # Chrome sends a frame as soon as the screencast starts, so a test that fails
82
110
  # before it draws anything still leaves behind the one frame of the blank page
83
111
  # it started on.
84
- if @record.frame_count < 2
112
+ if @frame_writer.frame_count < 2
113
+ warn "[TestRecorder] The screencast captured no page updates, so no video was saved."
85
114
  @tmp_video.close!
86
115
  return ""
87
116
  end
@@ -90,11 +119,12 @@ module TestRecorder
90
119
  FileUtils.mkdir_p(video_dir)
91
120
  video_path = video_dir.join(filename).to_s
92
121
 
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)
122
+ # Each frame carries its own timestamp in the Matroska stream, so ffmpeg knows how long to
123
+ # hold it and duplicates frames to reach the constant output frame rate on its own. Without a
124
+ # known end, though, ffmpeg pads however long it likes past the last real timestamp, so -t
125
+ # caps the output at the video's real duration explicitly.
126
+ duration = @frame_writer.duration + FFMPEG_DURATION_SLACK_S
127
+ result = system("ffmpeg", "-loglevel", "error", "-f", "matroska", "-i", @tmp_video.path, "-t", duration.to_s, *FFMPEG_ENCODE_OPTIONS, video_path)
98
128
 
99
129
  @tmp_video.close!
100
130
 
@@ -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
@@ -1,3 +1,3 @@
1
1
  module TestRecorder
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuji Yaginuma
@@ -84,6 +84,8 @@ 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
@@ -109,7 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
111
  - !ruby/object:Gem::Version
110
112
  version: '0'
111
113
  requirements: []
112
- rubygems_version: 3.6.7
114
+ rubygems_version: 4.0.16
113
115
  specification_version: 4
114
116
  summary: Automatically record videos when tests failed.
115
117
  test_files: []