test-recorder 0.2.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: 9ce916b59fcd166bdcafb222263423739c9c8fe58e53a50f5895c95485145522
4
- data.tar.gz: 71fd0cb14988364e06d7977c38815a6656522c2acb79180ec06e5889b5eab155
3
+ metadata.gz: f2df7b7454c1776d950afaadc9fcefdab037f2638a0a0f05ba7251ef08131428
4
+ data.tar.gz: 97e45933561090c772aef6a26849075626c7d8e9766d0e25c60a642841a38f36
5
5
  SHA512:
6
- metadata.gz: 3caaba9397db23c5c2bdb215812345af54f04a21e25c56ae6c83209c45f42a2bfd1891a587e7c58682ee85bd07b806d077adb7f7e780e2cd1c202fa2a38e5b64
7
- data.tar.gz: 50f45dfc872ff6cc60a997f842bff3ba95d225cd88621879d2a07c0119c577376528ac3cedf306322c9c7efe1e18a1be63d232109c4d3ac29f3689d22aed27b9
6
+ metadata.gz: 2ced41859d336565547e55de99b4574952f175bc3acb7ce6a6231c07ce32bab27483689f6a9c3967950d4da80145ea2002a0abe2ba3b6905c56fa9728902e004
7
+ data.tar.gz: 1657c90f69bcf393bb7e4905fde1a9e6afc74b7c5e864521a72bee0f4d3f97db9b1ceba28e466d321e7122ce2545548431ccd593a576e53f2b7c3c45d34214da
@@ -3,32 +3,45 @@ name: CI
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - master
6
+ - main
7
7
 
8
8
  pull_request:
9
9
 
10
+ env:
11
+ FFMPEG_BUILD: ffmpeg-n8.1-latest-linux64-gpl-8.1
12
+
10
13
  jobs:
11
14
  build:
12
15
  runs-on: ubuntu-latest
16
+ timeout-minutes: 10
13
17
 
14
- concurrency:
15
- group: ${{ github.ref }}
16
- cancel-in-progress: true
18
+ strategy:
19
+ matrix:
20
+ ruby: [ '4.0', '3.4', '3.3' ]
17
21
 
18
22
  steps:
19
- - uses: actions/checkout@v3
20
- - uses: ruby/setup-ruby@v1
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
24
+ - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
21
25
  with:
22
- ruby-version: '3.2'
23
- - uses: actions/setup-node@v2
26
+ ruby-version: ${{ matrix.ruby }}
27
+ bundler-cache: true
28
+ - name: Cache ffmpeg
29
+ id: cache-ffmpeg
30
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
24
31
  with:
25
- node-version: '18'
32
+ path: ~/.local/bin/ffmpeg
33
+ key: ffmpeg-${{ runner.os }}-${{ env.FFMPEG_BUILD }}
26
34
  - name: Install ffmpeg
35
+ if: steps.cache-ffmpeg.outputs.cache-hit != 'true'
27
36
  run: |
28
- sudo apt update && sudo apt install ffmpeg
29
- - name: Install dependencies
37
+ mkdir -p "$HOME/.local/bin"
38
+ curl -fsSL -o "$RUNNER_TEMP/ffmpeg.tar.xz" \
39
+ "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/${FFMPEG_BUILD}.tar.xz"
40
+ tar -xJf "$RUNNER_TEMP/ffmpeg.tar.xz" -C "$RUNNER_TEMP" "${FFMPEG_BUILD}/bin/ffmpeg"
41
+ mv "$RUNNER_TEMP/${FFMPEG_BUILD}/bin/ffmpeg" "$HOME/.local/bin/ffmpeg"
42
+ - name: Set up ffmpeg
30
43
  run: |
31
- gem install bundler --no-document
32
- bundle install
44
+ chmod +x "$HOME/.local/bin/ffmpeg"
45
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
33
46
  - name: Run test
34
47
  run: bundle exec rake
@@ -0,0 +1,33 @@
1
+ name: Push Gem
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ push:
13
+ if: github.repository == 'y-yagi/test-recorder'
14
+ runs-on: ubuntu-latest
15
+
16
+ permissions:
17
+ contents: write
18
+ id-token: write
19
+
20
+ steps:
21
+ - name: Harden Runner
22
+ uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
23
+ with:
24
+ egress-policy: audit
25
+
26
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27
+ - name: Set up Ruby
28
+ uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
29
+ with:
30
+ bundler-cache: true
31
+ ruby-version: ruby
32
+
33
+ - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
data/.gitignore CHANGED
@@ -6,3 +6,4 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+ Gemfile.lock
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## Unreleased
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
+
7
+ ## 0.3.0 - 2026-08-23
8
+
9
+ * Reduce recording overhead
10
+ * Make recording JPEG quality, max dimension and screencast frame interval configurable
11
+ * Explicitly encode videos with VP8 to avoid relying on ffmpeg's default codec
12
+ * Sanitize characters that can't use for video file names
13
+ * Stop recording a video for a skipped Rails system test
14
+
1
15
  ## 0.2.0 - 2023-08-23
2
16
 
3
17
  * Add support for aggregate_failures #21 (@willnet)
data/Gemfile CHANGED
@@ -3,6 +3,6 @@ source "https://rubygems.org"
3
3
  # Specify your gem's dependencies in test-recorder.gemspec
4
4
  gemspec
5
5
 
6
- gem "rake", "~> 12.0"
6
+ gem "rake"
7
7
  gem "minitest", "~> 5.0"
8
8
  gem "debug"
data/README.md CHANGED
@@ -94,4 +94,21 @@ TestRecorder.disable!
94
94
  it "test to something", test_recorder: true do
95
95
  # ...
96
96
  end
97
+ ```
98
+
99
+ ### Configuration
100
+
101
+ You can change the recording quality and the max width/height (in pixels).
102
+
103
+ ```ruby
104
+ TestRecorder.jpeg_quality = 80 # default: 60
105
+ TestRecorder.max_dimension = 1280 # default: 1000
106
+ ```
107
+
108
+ You can also record only every Nth frame. This lowers the recording overhead
109
+ at the cost of a choppier video. The playback duration stays the same.
110
+
111
+ ```ruby
112
+ TestRecorder.every_nth_frame = 3 # default: 1
113
+ ```
97
114
 
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"]
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,19 +1,78 @@
1
- require "open3"
1
+ 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
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
+ class << self
19
+ def record(devtools)
20
+ records[devtools] ||= begin
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
31
+ end
32
+
33
+ private
34
+
35
+ def write_frame(record, event)
36
+ record.io&.write(Base64.decode64(event["data"]), frame_time(record, event))
37
+ rescue IOError
38
+ # A frame can still arrive after the recording was stopped and the file was closed.
39
+ rescue => e
40
+ warn "[TestRecorder] Failed to write a screencast frame: #{e.class}: #{e.message}"
41
+ end
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
+
68
+ def records
69
+ @records ||= {}.compare_by_identity
70
+ end
71
+ end
72
+
7
73
  def initialize(enabled:)
8
74
  @enabled = enabled
9
75
  @started = nil
10
- @page = nil
11
- setup
12
- end
13
-
14
- def setup
15
- @video_dir = ::Rails.root.join("tmp", "videos")
16
- FileUtils.mkdir_p(@video_dir)
17
76
  end
18
77
 
19
78
  def start(page:, enabled: nil)
@@ -21,40 +80,61 @@ module TestRecorder
21
80
  @started = enabled
22
81
  return unless @started
23
82
 
24
- @tmp_video = Tempfile.open(["testrecorder", ".webm"])
25
- cmd = "ffmpeg -loglevel quiet -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -i - -y -an -r 25 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 #{@tmp_video.path}"
26
- @stdin, @wait_thrs = *Open3.pipeline_w(cmd)
27
- @stdin.set_encoding("ASCII-8BIT")
83
+ @tmp_video = Tempfile.new(["testrecorder", ".mkv"])
84
+ @tmp_video.binmode
28
85
 
29
- @page = page
30
- @page.driver.browser.devtools.page.enable
86
+ @record = self.class.record(page.driver.browser.devtools)
87
+ @frame_writer = FrameWriter.new(@tmp_video)
88
+ @record.io = @frame_writer
31
89
 
32
- @page.driver.browser.devtools.page.on(:screencast_frame) do |event|
33
- decoded_data = Base64.decode64(event["data"])
34
- @stdin.print(decoded_data) rescue nil
35
- @page.driver.browser.devtools.page.screencast_frame_ack(session_id: event["sessionId"])
36
- end
37
-
38
- @page.driver.browser.devtools.page.start_screencast(format: "jpeg", quality: 90)
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)
39
91
  end
40
92
 
41
93
  def stop_and_discard
42
- if @started
43
- @page.driver.browser.devtools.page.stop_screencast
44
- @stdin.close
45
- end
94
+ return unless @started
95
+
96
+ @record.io = nil
97
+ @record.page.stop_screencast
98
+ @tmp_video.close!
46
99
  end
47
100
 
48
101
  def stop_and_save(filename)
49
102
  return "" unless @started
50
103
 
51
- @page.driver.browser.devtools.page.stop_screencast
52
- @stdin.close
53
- @wait_thrs.each(&:join)
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
+ video_dir = ::Rails.root.join("tmp", "videos")
119
+ FileUtils.mkdir_p(video_dir)
120
+ video_path = video_dir.join(filename).to_s
54
121
 
55
- video_path = File.join(@video_dir, filename)
56
- FileUtils.copy(@tmp_video.path, video_path)
57
- @tmp_video.close(true)
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)
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
58
138
 
59
139
  video_path
60
140
  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
@@ -10,11 +10,11 @@ module TestRecorder
10
10
  end
11
11
 
12
12
  def before_teardown
13
- if failures.empty?
14
- @cdp_recorder.stop_and_discard
15
- else
16
- video_path = @cdp_recorder.stop_and_save("failures_#{self.name}.webm")
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")
17
15
  puts "[Video]: #{video_path}" if File.exist?(video_path)
16
+ else
17
+ @cdp_recorder.stop_and_discard
18
18
  end
19
19
  ensure
20
20
  super
@@ -1,3 +1,4 @@
1
+ require "test_recorder"
1
2
  require "test_recorder/cdp_recorder"
2
3
  require "test_recorder/rails/setup_and_teardown"
3
4
 
@@ -1,10 +1,9 @@
1
+ require "test_recorder"
1
2
  require "test_recorder/cdp_recorder"
2
3
  require "test_recorder/rspec/example_wrapper"
3
4
 
4
5
  module TestRecorder
5
6
  module RSpec
6
- CHARS_TO_TRANSLATE = ['/', '.', ':', ',', "'", '"', " "].freeze
7
-
8
7
  class << self
9
8
  attr_accessor :cdp_recorder
10
9
 
@@ -20,7 +19,7 @@ module TestRecorder
20
19
  end
21
20
 
22
21
  def method_name(example)
23
- example.description.underscore.tr(CHARS_TO_TRANSLATE.join, "_")[0...200] + "_#{rand(1000)}"
22
+ TestRecorder.sanitize_filename(example.description.underscore)[0...200] + "_#{rand(1000)}"
24
23
  end
25
24
 
26
25
  def passed?(example)
@@ -1,3 +1,3 @@
1
1
  module TestRecorder
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/test_recorder.rb CHANGED
@@ -1,7 +1,15 @@
1
1
  require "test_recorder/version"
2
2
 
3
3
  module TestRecorder
4
+ DEFAULT_JPEG_QUALITY = 60
5
+ DEFAULT_MAX_DIMENSION = 1000
6
+ DEFAULT_EVERY_NTH_FRAME = 1
7
+
8
+ CHARS_TO_TRANSLATE = ['/', '.', ':', ',', "'", '"', " "].freeze
9
+
4
10
  class << self
11
+ attr_writer :jpeg_quality, :max_dimension, :every_nth_frame
12
+
5
13
  def enable!
6
14
  @enable = true
7
15
  end
@@ -13,5 +21,21 @@ module TestRecorder
13
21
  def enabled?
14
22
  defined?(@enable) ? @enable : true
15
23
  end
24
+
25
+ def jpeg_quality
26
+ defined?(@jpeg_quality) ? @jpeg_quality : DEFAULT_JPEG_QUALITY
27
+ end
28
+
29
+ def max_dimension
30
+ defined?(@max_dimension) ? @max_dimension : DEFAULT_MAX_DIMENSION
31
+ end
32
+
33
+ def every_nth_frame
34
+ defined?(@every_nth_frame) ? @every_nth_frame : DEFAULT_EVERY_NTH_FRAME
35
+ end
36
+
37
+ def sanitize_filename(name)
38
+ name.tr(CHARS_TO_TRANSLATE.join, "_")
39
+ end
16
40
  end
17
41
  end
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.summary = %q{Automatically record videos when tests failed.}
10
10
  spec.homepage = "http://github.com/y-yagi/test-recorder"
11
11
  spec.license = "MIT"
12
- spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
13
13
 
14
14
  spec.metadata["homepage_uri"] = spec.homepage
15
15
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: test-recorder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuji Yaginuma
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-08-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -66,7 +65,6 @@ dependencies:
66
65
  - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: '0'
69
- description:
70
68
  email:
71
69
  - yuuji.yaginuma@gmail.com
72
70
  executables: []
@@ -74,10 +72,10 @@ extensions: []
74
72
  extra_rdoc_files: []
75
73
  files:
76
74
  - ".github/workflows/ci.yml"
75
+ - ".github/workflows/push_gem.yml"
77
76
  - ".gitignore"
78
77
  - CHANGELOG.md
79
78
  - Gemfile
80
- - Gemfile.lock
81
79
  - LICENSE.txt
82
80
  - README.md
83
81
  - Rakefile
@@ -86,6 +84,8 @@ files:
86
84
  - lib/test-recorder.rb
87
85
  - lib/test_recorder.rb
88
86
  - lib/test_recorder/cdp_recorder.rb
87
+ - lib/test_recorder/frame_writer.rb
88
+ - lib/test_recorder/matroska_writer.rb
89
89
  - lib/test_recorder/rails.rb
90
90
  - lib/test_recorder/rails/setup_and_teardown.rb
91
91
  - lib/test_recorder/rspec.rb
@@ -97,7 +97,6 @@ licenses:
97
97
  - MIT
98
98
  metadata:
99
99
  homepage_uri: http://github.com/y-yagi/test-recorder
100
- post_install_message:
101
100
  rdoc_options: []
102
101
  require_paths:
103
102
  - lib
@@ -105,15 +104,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
105
104
  requirements:
106
105
  - - ">="
107
106
  - !ruby/object:Gem::Version
108
- version: 2.3.0
107
+ version: 2.7.0
109
108
  required_rubygems_version: !ruby/object:Gem::Requirement
110
109
  requirements:
111
110
  - - ">="
112
111
  - !ruby/object:Gem::Version
113
112
  version: '0'
114
113
  requirements: []
115
- rubygems_version: 3.4.10
116
- signing_key:
114
+ rubygems_version: 4.0.16
117
115
  specification_version: 4
118
116
  summary: Automatically record videos when tests failed.
119
117
  test_files: []
data/Gemfile.lock DELETED
@@ -1,55 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- test-recorder (0.2.0)
5
- activesupport
6
- selenium-devtools
7
- selenium-webdriver (>= 4.0)
8
-
9
- GEM
10
- remote: https://rubygems.org/
11
- specs:
12
- activesupport (7.0.7)
13
- concurrent-ruby (~> 1.0, >= 1.0.2)
14
- i18n (>= 1.6, < 2)
15
- minitest (>= 5.1)
16
- tzinfo (~> 2.0)
17
- activesupport-testing-metadata (0.1.0)
18
- activesupport (>= 4.0)
19
- concurrent-ruby (1.2.2)
20
- debug (1.8.0)
21
- irb (>= 1.5.0)
22
- reline (>= 0.3.1)
23
- i18n (1.14.1)
24
- concurrent-ruby (~> 1.0)
25
- io-console (0.6.0)
26
- irb (1.7.4)
27
- reline (>= 0.3.6)
28
- minitest (5.19.0)
29
- rake (12.3.3)
30
- reline (0.3.8)
31
- io-console (~> 0.5)
32
- rexml (3.2.6)
33
- rubyzip (2.3.2)
34
- selenium-devtools (0.115.0)
35
- selenium-webdriver (~> 4.2)
36
- selenium-webdriver (4.11.0)
37
- rexml (~> 3.2, >= 3.2.5)
38
- rubyzip (>= 1.2.2, < 3.0)
39
- websocket (~> 1.0)
40
- tzinfo (2.0.6)
41
- concurrent-ruby (~> 1.0)
42
- websocket (1.2.9)
43
-
44
- PLATFORMS
45
- ruby
46
-
47
- DEPENDENCIES
48
- activesupport-testing-metadata
49
- debug
50
- minitest (~> 5.0)
51
- rake (~> 12.0)
52
- test-recorder!
53
-
54
- BUNDLED WITH
55
- 2.4.3