selenium_screencast 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cfab0bcb1ad7671634b430664e40b6678a33976668c25a99b98b2811e1bd154f
4
+ data.tar.gz: c0e4767fe92439c6f7d20d21cd6f82f9f994c0f7d68f76ce4c0aaff86ea06b92
5
+ SHA512:
6
+ metadata.gz: 20c5220b49c3124ab6a6b3b43d3aececf774c16e32fe2700069a4716bfebec4e47fe99062668a2b5fa1bb7b4f106f39107c6fb00c6da906ce4b0ddcea0885d31
7
+ data.tar.gz: '073424481ffb0cf5f55d330f21ccbc24fa3d1c62fba206cd6986ac89841a6def28e5dfa06139faa475b20f50d5b0e0fe7d1d5a93df41ede872be92b9fda673b6'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-07-26
4
+
5
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "selenium_screencast" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["lala.akira@gmail.com"](mailto:"lala.akira@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 aki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # SeleniumScreencast
2
+
3
+ Record your Capybara/Selenium system specs as webm or mp4 video, using the
4
+ Chrome DevTools Protocol (CDP) screencast API. `SeleniumScreencast` captures
5
+ the JPEG frame stream from `Page.startScreencast`, then encodes it into a
6
+ webm or mp4 video with `ffmpeg` — no browser extensions or external
7
+ recording tools required.
8
+
9
+ ## Requirements
10
+
11
+ - `ffmpeg` available on `PATH` (or point `FFMPEG_BIN` / `ffmpeg_bin` at it).
12
+ For `mp4` output, `ffmpeg` must include an H.264 (`libx264`) encoder.
13
+ - A Chrome/Chromium-based driver used through `selenium-webdriver`, since
14
+ recording relies on the CDP `devtools` connection exposed by Selenium's
15
+ Chrome bridge (e.g. system specs driven by `:selenium_chrome_headless` or
16
+ similar)
17
+
18
+ ## Installation
19
+
20
+ Add the gem to your application's test group:
21
+
22
+ ```bash
23
+ bundle add selenium_screencast --group test
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### RSpec / Capybara
29
+
30
+ Require the RSpec adapter once, e.g. in `rails_helper.rb` or a spec support
31
+ file:
32
+
33
+ ```ruby
34
+ require "selenium_screencast/rspec"
35
+ ```
36
+
37
+ This automatically records every `type: :system` example when recording is
38
+ enabled, and by default saves a video after each example finishes (regardless
39
+ of pass/fail). See [Recording only failed specs](#recording-only-failed-specs)
40
+ to keep videos only for failures.
41
+
42
+ Enable recording by setting `RECORD_VIDEO` when running specs:
43
+
44
+ ```bash
45
+ RECORD_VIDEO=1 bundle exec rspec spec/system/checkout_spec.rb
46
+ ```
47
+
48
+ Videos are written as webm (or mp4, depending on `config.format`) files
49
+ under the configured `output_dir` (`Rails.root.join("tmp/videos")` in a Rails
50
+ app, or `tmp/videos` relative to the working directory otherwise). Each file
51
+ is named after the example's full description, sanitized to a safe filename
52
+ with a random suffix to avoid collisions, e.g.
53
+ `Checkout_completes_purchase_a1b2c3d4.webm` (or `....mp4` when `format` is
54
+ `:mp4`).
55
+
56
+ ### Configuration
57
+
58
+ Configure defaults in an initializer or spec support file:
59
+
60
+ ```ruby
61
+ SeleniumScreencast.configure do |config|
62
+ config.slow_factor = 4 # default: 8.0
63
+ config.output_fps = 30 # default: 30
64
+ config.ffmpeg_bin = "ffmpeg" # default: "ffmpeg"
65
+ config.quality = 80 # default: 80
66
+ config.every_nth_frame = 1 # default: 1
67
+ config.last_frame_duration = 0.5 # default: 0.5 (seconds, before slow_factor)
68
+ config.inflight_delay = 0.2 # default: 0.2 (seconds, wait before stopping)
69
+ config.output_dir = "tmp/videos" # default: Rails.root/tmp/videos, or tmp/videos
70
+ config.debug = false # default: false
71
+ config.format = :webm # default: :webm (:webm or :mp4)
72
+ config.failed_only = false # default: false
73
+ end
74
+ ```
75
+
76
+ | Option | Description | Default |
77
+ | ---------------------- | ---------------------------------------------------------------------------- | ------- |
78
+ | `slow_factor` | Playback slow-motion multiplier applied to inter-frame durations | `8.0` |
79
+ | `output_fps` | Fixed output frame rate (CFR) that ffmpeg re-encodes to | `30` |
80
+ | `ffmpeg_bin` | Path to the `ffmpeg` executable | `"ffmpeg"` |
81
+ | `quality` | JPEG quality requested from the CDP screencast; must be 0-100 | `80` |
82
+ | `every_nth_frame` | Only capture every Nth screencast frame from Chrome; must be >= 1 | `1` |
83
+ | `last_frame_duration` | Seconds the final frame is held for, before `slow_factor` is applied | `0.5` |
84
+ | `inflight_delay` | Seconds to wait after stopping the screencast, to let in-flight frames land. Skipped when the frames are discarded without encoding | `0.2` |
85
+ | `output_dir` | Directory videos are written to | `Rails.root/tmp/videos` (Rails) or `tmp/videos` |
86
+ | `debug` | Keep the intermediate JPEG frame files (and concat list) instead of deleting them after encoding | `false` |
87
+ | `format` | Output video format (`:webm` or `:mp4`); determines container and codec | `:webm` |
88
+ | `failed_only` | Only keep the video for examples that failed; passing examples are recorded but never encoded | `false` |
89
+
90
+ **Precedence: environment variable > `configure` value > default.** When one
91
+ of the environment variables below is set, it always wins over a value set
92
+ via `SeleniumScreencast.configure`.
93
+
94
+ ### Clearing previous videos
95
+
96
+ When recording is enabled (`RECORD_VIDEO` is set), `output_dir` is emptied
97
+ once at the start of each rspec run, so this run's videos aren't mixed with
98
+ stale files from previous runs. This happens automatically via the
99
+ `selenium_screencast/rspec` adapter's `before(:suite)` hook — no
100
+ configuration needed.
101
+
102
+ When `RECORD_VIDEO` is unset, the output dir is left untouched (the
103
+ "disabled -> nothing happens" contract).
104
+
105
+ The cleanup runs at most once per process. With `parallel_tests`,
106
+ `before(:suite)` fires once per RSpec worker process, so each worker empties
107
+ the shared output dir at startup (not exactly once across all processes).
108
+ Because the workers share one `output_dir` and start at different times, a
109
+ slower-starting worker can wipe videos a faster worker already recorded in
110
+ the same run. If you rely on `parallel_tests`, give each worker its own
111
+ `output_dir` (e.g. keyed by `TEST_ENV_NUMBER`) so a worker only clears its
112
+ own directory.
113
+
114
+ Only the directory contents are removed; the directory itself is kept.
115
+
116
+ ### Environment variables
117
+
118
+ | Variable | Effect | Default |
119
+ | --------------------- | --------------------------------------------------------------- | ------- |
120
+ | `RECORD_VIDEO` | Enables recording when present (any non-empty value) | disabled |
121
+ | `RECORD_VIDEO_TESTS` | Comma-separated spec file paths to record. When set (alongside `RECORD_VIDEO`), only matching specs are recorded. See [Recording only specific specs](#recording-only-specific-specs). | all specs |
122
+ | `RECORD_VIDEO_TESTS_FILE` | Path to a file listing spec paths (one per line) to record. Combined (union) with `RECORD_VIDEO_TESTS`. A missing file raises an error. | unset |
123
+ | `RECORD_VIDEO_FAILED_ONLY` | Only keep the video for examples that failed, when present (any non-empty value). See [Recording only failed specs](#recording-only-failed-specs). | disabled |
124
+ | `RECORD_SLOW_FACTOR` | Overrides the playback slow-motion multiplier | `8` |
125
+ | `RECORD_QUALITY` | Overrides the JPEG quality (0-100) requested from the screencast | `80` |
126
+ | `RECORD_EVERY_NTH_FRAME` | Overrides how many screencast frames to skip (capture every Nth frame; must be >= 1) | `1` |
127
+ | `FFMPEG_BIN` | Overrides the path to the `ffmpeg` executable | `ffmpeg` |
128
+ | `RECORD_DEBUG` | Keeps intermediate frame files on disk when present | disabled |
129
+ | `RECORD_VIDEO_FORMAT` | Overrides the output format (`webm` / `mp4`) | `webm` |
130
+
131
+ ### Recording only specific specs
132
+
133
+ By default, when `RECORD_VIDEO` is enabled, **every** `type: :system` example is
134
+ recorded. When you run the full suite but only want video for a few specs — for
135
+ example, to review a newly added spec — narrow recording to specific spec files
136
+ with `RECORD_VIDEO_TESTS` (comma-separated) and/or `RECORD_VIDEO_TESTS_FILE`
137
+ (one path per line). All the specs still **run**; only the listed ones are
138
+ **recorded**.
139
+
140
+ ```bash
141
+ # Inline list — the whole suite runs; only these two specs are recorded
142
+ RECORD_VIDEO=1 RECORD_VIDEO_TESTS="spec/system/checkout_spec.rb,spec/system/login_spec.rb" \
143
+ bundle exec rspec
144
+
145
+ # List from a file (e.g. generated by CI from the changed specs)
146
+ RECORD_VIDEO=1 RECORD_VIDEO_TESTS_FILE=tmp/record_targets.txt bundle exec rspec
147
+ ```
148
+
149
+ When both `RECORD_VIDEO_TESTS` and `RECORD_VIDEO_TESTS_FILE` are set, the union
150
+ of the two lists is used.
151
+
152
+ Behavior summary:
153
+
154
+ | `RECORD_VIDEO` | Target list (`RECORD_VIDEO_TESTS` / `RECORD_VIDEO_TESTS_FILE`) | Result |
155
+ | --- | --- | --- |
156
+ | unset | (ignored) | Recording disabled entirely; the target list is never read. |
157
+ | set | both unset | Every system spec is recorded (backward-compatible default). |
158
+ | set | set, with matching paths | Only the matching spec files are recorded. |
159
+ | set | set, but effectively empty (blank values only) | Nothing is recorded — an empty selection does **not** fall back to recording everything. |
160
+
161
+ Paths are compared after normalizing against `Rails.root` (or the working
162
+ directory when not running under Rails), so relative paths (`spec/system/...`,
163
+ `./spec/system/...`) and absolute paths all match the same spec.
164
+
165
+ ### Recording only failed specs
166
+
167
+ When running the full suite in CI, you typically don't want a video for every
168
+ passing example — only for the ones that failed, so they can be attached as
169
+ build artifacts. Set `RECORD_VIDEO_FAILED_ONLY` (or `config.failed_only`) to
170
+ do this:
171
+
172
+ ```bash
173
+ RECORD_VIDEO=1 RECORD_VIDEO_FAILED_ONLY=1 bundle exec rspec
174
+ ```
175
+
176
+ **Honest overhead note:** whether an example passed or failed is only known
177
+ after it finishes, so CDP frame capture still runs for **every** example —
178
+ this can't be avoided. What's actually skipped is `ffmpeg` encoding, which is
179
+ the dominant cost of recording. For examples that pass, `ffmpeg` is never
180
+ invoked, so neither the video file nor its intermediate frame directory
181
+ (`*_frames`) is ever created — encoding isn't skipped by deleting output
182
+ afterward, it's skipped from the start. The `inflight_delay` wait is skipped
183
+ too, since it only exists to let a trailing frame make it into the video.
184
+
185
+ Whether a video is kept depends on the example's outcome:
186
+
187
+ | Example outcome | Video kept? |
188
+ | --- | --- |
189
+ | passed | No |
190
+ | failed | Yes |
191
+ | failed via `aggregate_failures` | Yes |
192
+ | failed in a user's own `after` hook | Yes |
193
+ | pending, failed as expected | No (a known failure doesn't need a video) |
194
+ | pending but unexpectedly passed | Yes (this is a real failure) |
195
+ | skipped | No |
196
+
197
+ `RECORD_VIDEO_FAILED_ONLY` combines with `RECORD_VIDEO_TESTS` /
198
+ `RECORD_VIDEO_TESTS_FILE` as an **AND**: an example is only encoded if it's
199
+ both in the target list (or no list is set) **and** it failed.
200
+
201
+ Interaction with `RECORD_DEBUG`: for a passing example, nothing is encoded,
202
+ so there are no frames left to keep — `RECORD_DEBUG` has no effect on
203
+ passing examples. `RECORD_DEBUG` remains useful for inspecting the source
204
+ frames behind a video that _was_ produced (i.e. a failing example).
205
+
206
+ ### Reducing recording overhead
207
+
208
+ Since CDP frame capture can't be skipped for any example (see above), the
209
+ remaining lever is making that capture itself cheaper. Two knobs help:
210
+
211
+ - Lowering `RECORD_QUALITY` (or `config.quality`) shrinks each JPEG frame,
212
+ reducing CDP transfer and disk I/O.
213
+ - Raising `RECORD_EVERY_NTH_FRAME` (or `config.every_nth_frame`) reduces the
214
+ number of frames captured at all.
215
+
216
+ **Note:** raising `every_nth_frame` does not shorten the video or make it
217
+ play faster. Frame display durations are computed from the deltas between
218
+ consecutive CDP frame timestamps, so skipping frames just widens those gaps
219
+ and each surviving frame is held on screen longer — total playback time is
220
+ preserved. What you lose is temporal resolution (motion looks choppier
221
+ between frames), not timing fidelity.
222
+
223
+ ```bash
224
+ RECORD_VIDEO=1 RECORD_QUALITY=40 RECORD_EVERY_NTH_FRAME=2 bundle exec rspec
225
+ ```
226
+
227
+ ### Core API (without Capybara)
228
+
229
+ `SeleniumScreencast::Recorder` only needs an object that responds to
230
+ `#devtools` (as provided by `selenium-webdriver`'s Chrome bridge), so it can
231
+ be used directly without Capybara or RSpec:
232
+
233
+ ```ruby
234
+ require "selenium_screencast"
235
+ require "selenium-webdriver"
236
+
237
+ driver = Selenium::WebDriver.for(:chrome)
238
+ recorder = SeleniumScreencast::Recorder.new(driver, output_path: "out.webm")
239
+
240
+ recorder.start
241
+ # ... drive the browser ...
242
+ recorder.stop
243
+ ```
244
+
245
+ ## How it works
246
+
247
+ 1. Starts a CDP screencast session (`Page.startScreencast`) and writes each
248
+ incoming JPEG frame to a sequential file, acknowledging every frame so
249
+ Chrome keeps streaming.
250
+ 2. On stop, builds an ffmpeg concat list where each frame's display duration
251
+ is derived from the delta between its Chrome timestamp and the next
252
+ frame's (variable frame rate), scaled by `slow_factor`.
253
+ 3. Runs `ffmpeg` once against that concat list, re-encoding the variable
254
+ frame rate stream to a fixed `output_fps` (CFR) file, using VP9/webm or
255
+ H.264/mp4 depending on `config.format`. If the configured `ffmpeg`
256
+ executable cannot be found, a clear `SeleniumScreencast::Error` is raised
257
+ before encoding instead of failing silently.
258
+
259
+ ## Development
260
+
261
+ After checking out the repo, run `bin/setup` to install dependencies. Then,
262
+ run `rake spec` to run the tests. You can also run `bin/console` for an
263
+ interactive prompt that will allow you to experiment.
264
+
265
+ To install this gem onto your local machine, run `bundle exec rake install`.
266
+ To release a new version, update the version number in `version.rb`, and
267
+ then run `bundle exec rake release`, which will create a git tag for the
268
+ version, push git commits and the created tag, and push the `.gem` file to
269
+ [rubygems.org](https://rubygems.org).
270
+
271
+ ## Contributing
272
+
273
+ Bug reports and pull requests are welcome on GitHub at
274
+ https://github.com/aki77/selenium_screencast. This project is intended to be
275
+ a safe, welcoming space for collaboration, and contributors are expected to
276
+ adhere to the [code of conduct](https://github.com/aki77/selenium_screencast/blob/main/CODE_OF_CONDUCT.md).
277
+
278
+ ## License
279
+
280
+ The gem is available as open source under the terms of the
281
+ [MIT License](https://opensource.org/licenses/MIT).
282
+
283
+ ## Code of Conduct
284
+
285
+ Everyone interacting in the SeleniumScreencast project's codebases, issue
286
+ trackers, chat rooms and mailing lists is expected to follow the
287
+ [code of conduct](https://github.com/aki77/selenium_screencast/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SeleniumScreencast
6
+ # Holds the configuration for the recording engine.
7
+ #
8
+ # Value precedence is "env var > value set via configure > default".
9
+ # Env vars always win, since they're often used to temporarily change
10
+ # behavior in CI, etc. An env var of nil / empty string is treated as
11
+ # "unset" (this does not rely on ActiveSupport's String#blank?).
12
+ class Configuration
13
+ DEFAULT_SLOW_FACTOR = 8.0
14
+ DEFAULT_OUTPUT_FPS = 30
15
+ DEFAULT_FFMPEG_BIN = "ffmpeg"
16
+ DEFAULT_QUALITY = 80
17
+ DEFAULT_EVERY_NTH_FRAME = 1
18
+ DEFAULT_LAST_FRAME_DURATION = 0.5
19
+ DEFAULT_INFLIGHT_DELAY = 0.2
20
+ DEFAULT_FORMAT = :webm
21
+
22
+ attr_writer :slow_factor, :output_fps, :ffmpeg_bin, :quality,
23
+ :every_nth_frame, :last_frame_duration, :inflight_delay,
24
+ :output_dir, :debug, :format, :failed_only
25
+
26
+ def slow_factor
27
+ env_or("RECORD_SLOW_FACTOR", value_or_default(@slow_factor, DEFAULT_SLOW_FACTOR), &:to_f)
28
+ end
29
+
30
+ def output_fps
31
+ value_or_default(@output_fps, DEFAULT_OUTPUT_FPS)
32
+ end
33
+
34
+ def ffmpeg_bin
35
+ env_or("FFMPEG_BIN", value_or_default(@ffmpeg_bin, DEFAULT_FFMPEG_BIN)) { |v| v }
36
+ end
37
+
38
+ # The output format (:webm / :mp4). Resolved with env > configure >
39
+ # default, then normalized to absorb case, surrounding whitespace, and
40
+ # symbol/string variance. An unsupported value is a config error, so it
41
+ # raises a clear Error.
42
+ def format
43
+ raw = env_or("RECORD_VIDEO_FORMAT", value_or_default(@format, DEFAULT_FORMAT)) { |v| v }
44
+ normalize_format(raw)
45
+ end
46
+
47
+ # JPEG quality (0-100). Resolved with env > configure > default, then
48
+ # validated to be within the range CDP accepts.
49
+ def quality
50
+ raw = env_or("RECORD_QUALITY", value_or_default(@quality, DEFAULT_QUALITY), &:to_i)
51
+ normalize_int_in_range(raw, "quality", 0..100, "between 0 and 100")
52
+ end
53
+
54
+ # Capture every Nth frame. Resolved with env > configure > default, then
55
+ # validated to be a positive value.
56
+ def every_nth_frame
57
+ raw = env_or("RECORD_EVERY_NTH_FRAME",
58
+ value_or_default(@every_nth_frame, DEFAULT_EVERY_NTH_FRAME), &:to_i)
59
+ normalize_int_in_range(raw, "every_nth_frame", 1.., ">= 1")
60
+ end
61
+
62
+ def last_frame_duration
63
+ value_or_default(@last_frame_duration, DEFAULT_LAST_FRAME_DURATION)
64
+ end
65
+
66
+ def inflight_delay
67
+ value_or_default(@inflight_delay, DEFAULT_INFLIGHT_DELAY)
68
+ end
69
+
70
+ # The output directory. A user-specified value takes priority; otherwise
71
+ # Rails.root/tmp/videos in a Rails environment, or the relative tmp/videos
72
+ # outside Rails.
73
+ def output_dir
74
+ return Pathname(@output_dir.to_s) unless @output_dir.nil?
75
+
76
+ defined?(Rails) ? Rails.root.join("tmp/videos") : Pathname("tmp/videos")
77
+ end
78
+
79
+ # Recording is enabled when RECORD_VIDEO is set.
80
+ def enabled?
81
+ env_present?("RECORD_VIDEO")
82
+ end
83
+
84
+ # Whether the given spec file should be recorded.
85
+ # Always false if the arm (enabled?) is disabled (the target list env vars
86
+ # are not read at all in that case). If neither target list env var is
87
+ # set, records everything (true), as before. If set, normalizes and checks
88
+ # for a match. An effectively empty list is always false (does not fall
89
+ # back to recording everything).
90
+ def recording_target?(spec_file)
91
+ return false unless enabled?
92
+ return true unless target_list_configured?
93
+ return false if spec_file.nil? || spec_file.to_s.empty?
94
+
95
+ recording_target_paths.include?(normalize_test_path(spec_file))
96
+ end
97
+
98
+ # Frame images are kept (for debugging) when RECORD_DEBUG is set.
99
+ def debug?
100
+ env_present?("RECORD_DEBUG") || @debug == true
101
+ end
102
+
103
+ # Only failed examples keep their video when RECORD_VIDEO_FAILED_ONLY is
104
+ # set. Recording itself still runs for every example (frames must be
105
+ # captured before the outcome is known); this flag only decides whether the
106
+ # captured frames get encoded into a video at the end. Orthogonal to
107
+ # RECORD_VIDEO_TESTS: when both are set, only the listed specs that also
108
+ # failed produce a video.
109
+ def failed_only?
110
+ env_present?("RECORD_VIDEO_FAILED_ONLY") || @failed_only == true
111
+ end
112
+
113
+ private
114
+
115
+ # Returns the default if the @ivar is nil (checked with nil? since false/0
116
+ # are valid values to keep as-is).
117
+ def value_or_default(value, default)
118
+ value.nil? ? default : value
119
+ end
120
+
121
+ # Normalizes a format value. Absorbs differences in case, surrounding
122
+ # whitespace, and symbol/string, and raises a clear Error for an
123
+ # unsupported value as a config error. The supported formats are read from
124
+ # the neutral VIDEO_FORMATS as the single source (shared with Encoder).
125
+ def normalize_format(value)
126
+ normalized = value.to_s.strip.downcase.to_sym
127
+ supported = SeleniumScreencast::VIDEO_FORMATS.keys
128
+ unless supported.include?(normalized)
129
+ raise SeleniumScreencast::Error,
130
+ "Unsupported video format: #{value.inspect} " \
131
+ "(supported: #{supported.join(", ")})"
132
+ end
133
+ normalized
134
+ end
135
+
136
+ # Shared validator for integer options constrained to a range. Raises a
137
+ # clear Error (config error) when the value falls outside range, including
138
+ # the received value via inspect for easier debugging. bound_desc spells
139
+ # out the range in prose, since a Range alone can't say it readably.
140
+ def normalize_int_in_range(value, name, range, bound_desc)
141
+ unless range.cover?(value)
142
+ raise SeleniumScreencast::Error,
143
+ "Invalid #{name}: #{value.inspect} (must be #{bound_desc})"
144
+ end
145
+ value
146
+ end
147
+
148
+ # If the env var key is set, returns its value transformed by the block.
149
+ # If unset (nil/empty string), returns default (this consolidates the
150
+ # "env var > default" precedence).
151
+ def env_or(key, default)
152
+ env_present?(key) ? yield(ENV.fetch(key)) : default
153
+ end
154
+
155
+ def env_present?(key)
156
+ v = ENV.fetch(key, nil)
157
+ !v.nil? && !v.empty?
158
+ end
159
+
160
+ # Whether either RECORD_VIDEO_TESTS or RECORD_VIDEO_TESTS_FILE is set
161
+ # (even to a blank value). This is what distinguishes "env unset =
162
+ # record everything" from "set but empty = zero targets".
163
+ def target_list_configured?
164
+ env_present?("RECORD_VIDEO_TESTS") || env_present?("RECORD_VIDEO_TESTS_FILE")
165
+ end
166
+
167
+ # The normalized path set of target specs.
168
+ # The union of RECORD_VIDEO_TESTS (comma-separated) and
169
+ # RECORD_VIDEO_TESTS_FILE (newline-separated). Blanks are removed and the
170
+ # rest is normalized into a Set. An effectively empty result is an empty
171
+ # Set (= zero targets). Since recording_target? is called on every
172
+ # before(:each) of a system spec, the I/O for RECORD_VIDEO_TESTS_FILE's
173
+ # File.readlines, etc. is memoized on the singleton instance instead of
174
+ # being repeated once per example (env vars are assumed immutable for the
175
+ # life of the process).
176
+ def recording_target_paths
177
+ @recording_target_paths ||=
178
+ (target_paths_from_env + target_paths_from_file)
179
+ .filter_map { |p| (s = p&.strip) && !s.empty? ? normalize_test_path(s) : nil }
180
+ .to_set
181
+ end
182
+
183
+ # Splits RECORD_VIDEO_TESTS on commas. [] if unset.
184
+ def target_paths_from_env
185
+ env_or("RECORD_VIDEO_TESTS", []) { |v| v.split(",") }
186
+ end
187
+
188
+ # Reads the RECORD_VIDEO_TESTS_FILE file, newline-separated. [] if unset.
189
+ # Since env_or only evaluates the block when the arm is active (the env
190
+ # var is set), the existence check does not run either when the arm is
191
+ # inactive. Raises an Error if set but the file does not exist.
192
+ def target_paths_from_file
193
+ env_or("RECORD_VIDEO_TESTS_FILE", []) do |path|
194
+ raise SeleniumScreencast::Error, "RECORD_VIDEO_TESTS_FILE not found: #{path}" unless File.file?(path)
195
+
196
+ File.readlines(path, chomp: true)
197
+ end
198
+ end
199
+
200
+ # Normalizes a spec path to one relative to base (= Rails.root or pwd).
201
+ # The same normalization is applied to both the target list side and the
202
+ # current example side so they can be matched.
203
+ def normalize_test_path(path)
204
+ expanded = Pathname(path.to_s).expand_path(base_path)
205
+ expanded.relative_path_from(base_path).to_s
206
+ rescue ArgumentError
207
+ # Where relativizing is impossible (outside base, e.g. a different
208
+ # drive), settle for the absolute path string. If expand_path itself
209
+ # fails and expanded is unassigned (nil), fall back to the original path
210
+ # string (this prevents nil.to_s's empty string from polluting the
211
+ # target set and causing a false match).
212
+ (expanded || path).to_s
213
+ end
214
+
215
+ # The base directory for normalization. Same Rails-branching concern as
216
+ # output_dir.
217
+ def base_path
218
+ defined?(Rails) ? Rails.root : Pathname.pwd
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module SeleniumScreencast
6
+ # Encodes a frame sequence to webm (VP9) via the ffmpeg concat demuxer.
7
+ # Composed of pure computation (dropping static frames, computing durations,
8
+ # building concat.txt) plus a single ffmpeg call, so the computation part
9
+ # can be unit tested.
10
+ class Encoder
11
+ def self.file_extension(format)
12
+ format_spec(format).fetch(:ext)
13
+ end
14
+
15
+ # Returns the VIDEO_FORMATS entry for a format symbol. An unsupported value
16
+ # is converted into the unified-contract SeleniumScreencast::Error instead
17
+ # of a plain KeyError (matching callers that only rescue
18
+ # SeleniumScreencast::Error).
19
+ def self.format_spec(format)
20
+ SeleniumScreencast::VIDEO_FORMATS.fetch(format) do
21
+ raise SeleniumScreencast::Error,
22
+ "Unsupported video format: #{format.inspect} " \
23
+ "(supported: #{SeleniumScreencast::VIDEO_FORMATS.keys.join(", ")})"
24
+ end
25
+ end
26
+
27
+ def initialize(frame_store, output_path:, config: SeleniumScreencast.config)
28
+ @frame_store = frame_store
29
+ @output_path = output_path
30
+ @config = config
31
+ end
32
+
33
+ def encode
34
+ frames = drop_leading_static_frames(@frame_store.frames)
35
+ return if frames.empty?
36
+
37
+ # Resolve @config.format (raises Error on an invalid value) and check
38
+ # that ffmpeg is available before doing wasted work like writing
39
+ # concat.txt.
40
+ format_spec = self.class.format_spec(@config.format)
41
+ ensure_ffmpeg_available!
42
+ concat_path = @frame_store.write_concat_list(build_concat_list(frames))
43
+ run(ffmpeg_argv(concat_path, format_spec))
44
+ end
45
+
46
+ # Drops the leading static frames where "nothing has changed yet".
47
+ # Right after a page loads, the same screen tends to repeat, so frames up
48
+ # to the first change are discarded so the video starts from actual
49
+ # interaction. If every frame is identical, only the last one is kept.
50
+ def drop_leading_static_frames(frames)
51
+ first_changed_index =
52
+ frames.each_cons(2).find_index { |prev, curr| !same_frame_content?(prev, curr) }
53
+ return frames.last(1) if first_changed_index.nil?
54
+
55
+ frames.drop(first_changed_index + 1)
56
+ end
57
+
58
+ # Builds the list string for the ffmpeg concat demuxer.
59
+ # The last file is listed twice because the concat demuxer interprets each
60
+ # duration as "the display time until the next file", so giving the last
61
+ # frame a display time requires listing the same file once more.
62
+ def build_concat_list(frames)
63
+ durations = frame_durations(frames)
64
+ lines =
65
+ frames.each_with_index.flat_map do |frame, i|
66
+ ["file '#{frame[:file]}'", "duration #{durations[i].round(3)}"]
67
+ end
68
+ lines << "file '#{frames.last[:file]}'"
69
+ "#{lines.join("\n")}\n"
70
+ end
71
+
72
+ private
73
+
74
+ # Computes the display duration of each frame. CDP frames are variable
75
+ # frame rate (VFR), so the diff of metadata.timestamp is used as real time
76
+ # and stretched by slow_factor (to show the interaction more slowly).
77
+ # This is repacked to CFR via -r at encode time.
78
+ def frame_durations(frames)
79
+ frames.each_with_index.map do |frame, i|
80
+ next_frame = frames[i + 1]
81
+ next @config.last_frame_duration * @config.slow_factor unless next_frame
82
+ next 0.0 unless frame[:time] && next_frame[:time]
83
+
84
+ (next_frame[:time] - frame[:time]) * @config.slow_factor
85
+ end
86
+ end
87
+
88
+ # Compares two frames for identical content. Differing :size is a fast
89
+ # rejection that needs no disk I/O, since compressed JPEGs that differ
90
+ # almost always differ in byte size. It is only a rejection, never a
91
+ # proof of equality: two different JPEGs can happen to compress to the
92
+ # same byte count, so equal (or missing) :size still falls back to a
93
+ # real byte comparison.
94
+ def same_frame_content?(frame_a, frame_b)
95
+ return false if frame_a[:size] && frame_b[:size] && frame_a[:size] != frame_b[:size]
96
+
97
+ File.binread(frame_a[:file]) == File.binread(frame_b[:file])
98
+ end
99
+
100
+ # Repacks the VFR frame sequence into constant frame rate (CFR) via -r, and
101
+ # outputs with the codec from format_spec (the webm/VP9 argv is identical
102
+ # to before, for backward compatibility).
103
+ def ffmpeg_argv(concat_path, format_spec)
104
+ [
105
+ @config.ffmpeg_bin, "-y", "-f", "concat", "-safe", "0", "-i", concat_path.to_s,
106
+ "-r", @config.output_fps.to_s, "-c:v", format_spec[:codec], *format_spec[:extra], @output_path.to_s
107
+ ]
108
+ end
109
+
110
+ # Runs ffmpeg. Kept as a separate method so it can be stubbed in tests.
111
+ def run(argv)
112
+ warn "SeleniumScreencast: running ffmpeg: #{argv.join(" ")}" if @config.debug?
113
+ out, status = Open3.capture2e(*argv)
114
+ unless status.success?
115
+ warn "SeleniumScreencast: ffmpeg encoding failed: #{status}"
116
+ # ffmpeg writes the actual cause (missing encoder, bad dimensions, etc.)
117
+ # to stderr, which capture2e merges into out. Surface it under
118
+ # RECORD_DEBUG so failures like exit 187 can be diagnosed.
119
+ warn "SeleniumScreencast: ffmpeg output:\n#{out}" if @config.debug?
120
+ end
121
+ status
122
+ end
123
+
124
+ # Raises an Error with a clear cause and remedy if ffmpeg_bin is not
125
+ # executable. Calling this before running the encode prevents
126
+ # Open3.capture2e from crashing with Errno::ENOENT and silently producing
127
+ # no video.
128
+ def ensure_ffmpeg_available!
129
+ return if ffmpeg_executable?
130
+
131
+ raise SeleniumScreencast::Error,
132
+ "ffmpeg executable not found: #{@config.ffmpeg_bin.inspect}. " \
133
+ "Install ffmpeg and make sure it is on PATH, or set the ffmpeg path via " \
134
+ "FFMPEG_BIN env / config.ffmpeg_bin."
135
+ end
136
+
137
+ # A `which`-equivalent existence check (assumes macOS/Linux). If the value
138
+ # contains a directory separator (absolute/relative path), checks that
139
+ # path directly; otherwise searches for it as a command name on PATH.
140
+ def ffmpeg_executable?
141
+ bin = @config.ffmpeg_bin.to_s
142
+ return executable_file?(bin) if bin.include?(File::SEPARATOR)
143
+
144
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
145
+ executable_file?(File.join(dir, bin))
146
+ end
147
+ end
148
+
149
+ def executable_file?(path)
150
+ File.file?(path) && File.executable?(path)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module SeleniumScreencast
7
+ # Saves received JPEG frames to disk with sequential numbering, and holds
8
+ # the frame metadata. Deals only with the filesystem and has no dependency
9
+ # on selenium, so it can be tested standalone.
10
+ class FrameStore
11
+ # Frame info in the order received. Each element is { file:, time:, size: }.
12
+ # The key names match the original implementation (passed on to Encoder).
13
+ # size: is the byte size of the written JPEG, recorded so Encoder can use
14
+ # it as a fast-path rejection when comparing frames for staticness,
15
+ # without having to read the frame back from disk.
16
+ attr_reader :frames
17
+
18
+ # Builds a FrameStore by deriving the frame directory from the output file
19
+ # path. Frame images are placed in "<basename>_frames" in the same
20
+ # directory as the output file.
21
+ def self.for_output(output_path)
22
+ path = Pathname(output_path.to_s)
23
+ frames_dir = path.dirname.join("#{path.basename(".*")}_frames")
24
+ new(frames_dir)
25
+ end
26
+
27
+ def initialize(dir)
28
+ @dir = Pathname(dir.to_s)
29
+ FileUtils.mkdir_p(@dir)
30
+ @frames = []
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Writes the JPEG byte string sequentially as 0001.jpg, and so on, and
35
+ # appends the metadata to frames. The index matches the original
36
+ # implementation: @frames.size + 1 (starting at 0001).
37
+ # CDP runs the callback on a new thread for every screencastFrame event, so
38
+ # numbering, writing, and appending are all serialized under a Mutex to
39
+ # prevent frame collisions or loss.
40
+ def write(jpeg_bytes, timestamp:)
41
+ @mutex.synchronize do
42
+ index = @frames.size + 1
43
+ file = @dir.join(format("%04d.jpg", index))
44
+ File.binwrite(file, jpeg_bytes)
45
+ @frames << { file: file.to_s, time: timestamp, size: jpeg_bytes.bytesize }
46
+ end
47
+ end
48
+
49
+ # Writes the concat demuxer list to concat.txt in the frame directory, and
50
+ # returns its path.
51
+ def write_concat_list(content)
52
+ path = concat_path
53
+ File.write(path, content)
54
+ path
55
+ end
56
+
57
+ # Removes the entire frame directory unless keep is true.
58
+ def cleanup(keep:)
59
+ FileUtils.rm_rf(@dir) unless keep
60
+ end
61
+
62
+ private
63
+
64
+ def concat_path
65
+ @dir.join("concat.txt")
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SeleniumScreencast
6
+ # The recording lifecycle built on CDP (Chrome DevTools Protocol)'s
7
+ # Page.startScreencast. Receives a browser that responds to #devtools via
8
+ # injection, and knows nothing about Capybara or RSpec.
9
+ class Recorder
10
+ def initialize(browser, output_path:, config: SeleniumScreencast.config)
11
+ @browser = browser
12
+ @output_path = Pathname(output_path.to_s)
13
+ @config = config
14
+ @state = nil
15
+ end
16
+
17
+ def start
18
+ raise "SeleniumScreencast::Recorder is already recording" if recording?
19
+
20
+ devtools = @browser.devtools(target_type: "page")
21
+
22
+ frame_store = FrameStore.for_output(@output_path)
23
+
24
+ callback = build_frame_callback(devtools, frame_store)
25
+ # on returns the array of registered callbacks. Removing our own
26
+ # callback from this array on stop keeps it from lingering into the
27
+ # next recording.
28
+ callbacks = devtools.page.on(:screencast_frame, &callback)
29
+
30
+ begin
31
+ devtools.page.start_screencast(
32
+ format: "jpeg", quality: @config.quality, every_nth_frame: @config.every_nth_frame
33
+ )
34
+ rescue StandardError
35
+ # If starting fails, remove ourselves from the shared callback array
36
+ # immediately before re-raising, so we don't linger in it.
37
+ callbacks.delete(callback)
38
+ raise
39
+ end
40
+
41
+ @state = { devtools:, callback:, callbacks:, frame_store: }
42
+ end
43
+
44
+ # Stops the recording. With encode: false, the captured frames are
45
+ # discarded without invoking ffmpeg, so nothing is written to output_path.
46
+ # This lets a caller that only wants video under some condition (e.g. the
47
+ # RSpec adapter's "failed only" mode) skip the encode cost entirely rather
48
+ # than encoding and deleting afterwards. Recorder is told whether to
49
+ # encode, never why: it stays unaware of test outcomes.
50
+ def stop(encode: true)
51
+ state = @state
52
+ return if state.nil?
53
+
54
+ stop_screencast(state, wait_for_inflight: encode)
55
+
56
+ encode_frames(state) if encode
57
+ ensure
58
+ # Even if encode raises SeleniumScreencast::Error (invalid format value,
59
+ # ffmpeg missing), always clean up in ensure so the intermediate JPEG
60
+ # frames don't linger uncleaned.
61
+ # debug? only applies when we actually encoded: keeping frames is for
62
+ # inspecting what a produced video was built from, so with encode: false
63
+ # they are always removed. Otherwise a full suite in "failed only" mode
64
+ # would leave a *_frames dir behind for every passing example.
65
+ state&.fetch(:frame_store)&.cleanup(keep: @config.debug? && encode)
66
+ @state = nil
67
+ # NOTE: never close devtools. It's a shared instance memoized per target
68
+ # on the browser side; closing it would break other recordings and
69
+ # selenium's own devtools usage too. Removing ourselves from the
70
+ # callback array is sufficient cleanup.
71
+ end
72
+
73
+ def recording?
74
+ !@state.nil?
75
+ end
76
+
77
+ private
78
+
79
+ # Tells CDP to stop delivering frames and unregisters our callback. This
80
+ # always runs, even when not encoding: leaving the screencast running would
81
+ # spill frames into the next recording.
82
+ #
83
+ # wait_for_inflight adds a short grace period so a frame still in flight can
84
+ # land before the frames are handed to the encoder. It is only worth paying
85
+ # when we are going to encode; when the frames are about to be discarded the
86
+ # sleep would just be dead time on every example that skips encoding.
87
+ def stop_screencast(state, wait_for_inflight:)
88
+ state[:devtools].page.stop_screencast
89
+ sleep @config.inflight_delay if wait_for_inflight
90
+ rescue StandardError => e
91
+ warn "SeleniumScreencast: exception in stop_screencast: #{e.class}: #{e.message}"
92
+ ensure
93
+ state[:callbacks].delete(state[:callback])
94
+ end
95
+
96
+ def encode_frames(state)
97
+ Encoder.new(
98
+ state[:frame_store],
99
+ output_path: @output_path,
100
+ config: @config
101
+ ).encode
102
+ end
103
+
104
+ def build_frame_callback(devtools, frame_store)
105
+ lambda do |params|
106
+ begin
107
+ # Decode Base64 with the standard unpack (equivalent to
108
+ # Base64.decode64) to avoid depending on the base64 gem, which
109
+ # dropped out of the default gems as of Ruby 3.4.
110
+ frame_store.write(
111
+ params["data"].unpack1("m"),
112
+ timestamp: params.dig("metadata", "timestamp")
113
+ )
114
+ rescue StandardError => e
115
+ warn "SeleniumScreencast: exception in screencastFrame callback: #{e.class}: #{e.message}"
116
+ end
117
+ # Skipping the ack would make CDP stop delivering frames altogether,
118
+ # so it must always be sent regardless of whether write succeeded.
119
+ devtools.page.screencast_frame_ack(session_id: params["sessionId"])
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "fileutils"
5
+ require "selenium_screencast"
6
+
7
+ begin
8
+ require "rspec/core"
9
+ rescue LoadError
10
+ raise LoadError,
11
+ "selenium_screencast/rspec requires rspec-core. Add `gem \"rspec\"` to your Gemfile " \
12
+ "before requiring \"selenium_screencast/rspec\"."
13
+ end
14
+
15
+ module SeleniumScreencast
16
+ # A thin RSpec adapter for Capybara system specs (type: :system).
17
+ # The only dependency on Capybara is `page.driver.browser`; this module
18
+ # itself does not require Capybara.
19
+ module RSpecHelper
20
+ # Starts recording. Does nothing if the config is disabled.
21
+ # A failure to start is warned instead of raised, so it does not affect the
22
+ # test result. However, SeleniumScreencast::Error is a contractual exception
23
+ # signaling a configuration problem (e.g. a wrong RECORD_VIDEO_TESTS_FILE
24
+ # path), so it is not swallowed and is re-raised. Any other StandardError
25
+ # (Recorder start failure, an unexpected I/O error while judging the
26
+ # recording target, etc.) is warned and skipped as before, without
27
+ # affecting the test result.
28
+ def start_screencast_recording
29
+ config = SeleniumScreencast.config
30
+ return unless config.recording_target?(current_spec_file)
31
+
32
+ dir = config.output_dir
33
+ FileUtils.mkdir_p(dir)
34
+ path = dir.join(screencast_filename(config))
35
+ browser = page.driver.browser
36
+ @screencast_recorder = SeleniumScreencast::Recorder.new(browser, output_path: path)
37
+ @screencast_recorder.start
38
+ rescue SeleniumScreencast::Error
39
+ raise
40
+ rescue StandardError => e
41
+ warn "SeleniumScreencast: failed to start recording: #{e.class}: #{e.message}"
42
+ @screencast_recorder = nil
43
+ end
44
+
45
+ # Stops recording. A failure to stop is warned instead of raised.
46
+ # However, SeleniumScreencast::Error is a contractual exception signaling a
47
+ # configuration problem (e.g. ffmpeg missing or an invalid format value),
48
+ # so, just like start, it is not swallowed and is re-raised. stop also runs
49
+ # encoding (Encoder#encode) along this path, so without this rescue the
50
+ # intent of "raise a clear Error when ffmpeg is missing" would be swallowed
51
+ # by warn and stop working.
52
+ #
53
+ # By default the video is kept regardless of pass/fail. When
54
+ # RECORD_VIDEO_FAILED_ONLY is enabled, a passing example stops with
55
+ # encode: false so ffmpeg is never invoked for it: recording (CDP frame
56
+ # capture) cannot be avoided since the outcome is only known afterwards,
57
+ # but the expensive part is skipped, and no file is written to output_dir.
58
+ def stop_screencast_recording
59
+ return if @screencast_recorder.nil?
60
+
61
+ @screencast_recorder.stop(encode: keep_video?)
62
+ rescue SeleniumScreencast::Error
63
+ raise
64
+ rescue StandardError => e
65
+ warn "SeleniumScreencast: failed to stop recording: #{e.class}: #{e.message}"
66
+ ensure
67
+ @screencast_recorder = nil
68
+ end
69
+
70
+ private
71
+
72
+ # Generates a filename that also supports non-ASCII describe/it text (same
73
+ # behavior as the original copy-tuner implementation). Collapses runs of
74
+ # non-\p{Word} characters into _, squeezes repeated _, and strips a
75
+ # leading _.
76
+ def screencast_filename(config)
77
+ raw = "#{RSpec.current_example.metadata[:full_description]}_#{SecureRandom.hex(4)}"
78
+ safe = raw.gsub(/[^\p{Word}]+/u, "_").squeeze("_").delete_prefix("_")
79
+ ext = SeleniumScreencast::Encoder.file_extension(config.format)
80
+ "#{safe}.#{ext}"
81
+ end
82
+
83
+ # The spec file path of the current example. nil if it cannot be obtained.
84
+ def current_spec_file
85
+ RSpec.current_example&.metadata&.fetch(:file_path, nil)
86
+ end
87
+
88
+ # Whether this example's video should actually be written out.
89
+ # Always true unless the "failed only" mode is on.
90
+ def keep_video?
91
+ return true unless SeleniumScreencast.config.failed_only?
92
+
93
+ example_failed?
94
+ end
95
+
96
+ # Whether the current example failed, judged from RSpec.current_example in
97
+ # an after(:each) hook.
98
+ #
99
+ # execution_result.status is deliberately NOT used: RSpec assigns it in
100
+ # Example#finish, which runs *after* the after(:each) hooks, so it is still
101
+ # nil at this point. Example#exception, on the other hand, is already set by
102
+ # then, and it also covers a failure raised from a user's own after hook
103
+ # (config-level after hooks run last) and aggregate_failures (which stores a
104
+ # MultipleExceptionError).
105
+ #
106
+ # Pending/skip fall out correctly for free: for an example pending as
107
+ # expected, RSpec routes the error into execution_result.pending_exception
108
+ # and leaves #exception nil, so no video is kept for a known failure. An
109
+ # example that unexpectedly passes while pending gets a
110
+ # PendingExampleFixedError in #exception, so its video is kept.
111
+ #
112
+ # If current_example is somehow unavailable, treat it as failed and keep the
113
+ # video: an extra video is harmless, a missing video for a real failure is
114
+ # not.
115
+ def example_failed?
116
+ example = RSpec.current_example
117
+ return true if example.nil?
118
+
119
+ !example.exception.nil?
120
+ end
121
+ end
122
+ end
123
+
124
+ RSpec.configure do |config|
125
+ config.include SeleniumScreencast::RSpecHelper, type: :system
126
+
127
+ # NOTE: register per-example hooks lazily, through before(:suite).
128
+ # This file can be required before the system-test-style driven_by call,
129
+ # so registering before(:each) directly would run it before driven_by and
130
+ # leave current_driver as rack_test. Registering inside before(:suite)
131
+ # defers it until after driven_by, so recording can start on the selenium
132
+ # driver.
133
+ config.before(:suite) do
134
+ SeleniumScreencast.clean_output_dir!
135
+ RSpec.configure do |c|
136
+ c.before(:each, type: :system) { start_screencast_recording }
137
+ c.after(:each, type: :system) { stop_screencast_recording }
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SeleniumScreencast
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "selenium_screencast/version"
6
+ require_relative "selenium_screencast/configuration"
7
+ require_relative "selenium_screencast/frame_store"
8
+ require_relative "selenium_screencast/encoder"
9
+ require_relative "selenium_screencast/recorder"
10
+
11
+ # CDP screencast recording engine, independent of Capybara.
12
+ # Provides access to config and creation/reset of Configuration.
13
+ module SeleniumScreencast
14
+ class Error < StandardError; end
15
+
16
+ # ffmpeg settings per output format. Both Configuration (which formats are
17
+ # allowed) and Encoder (which codec builds the argv per format) treat this
18
+ # as the single source of truth. Letting either side hold its own allowlist
19
+ # would let them drift apart, passing validation but raising KeyError when
20
+ # building argv, so it lives here in a neutral location instead.
21
+ # ext : output file extension (the container is inferred by ffmpeg from it)
22
+ # codec : video codec passed to -c:v
23
+ # extra : codec-specific extra options (mp4/H.264 pins yuv420p for playback
24
+ # compatibility, and scales to even dimensions since libx264 with
25
+ # yuv420p requires the width and height to be divisible by 2 —
26
+ # an odd-sized capture otherwise aborts encoding, e.g. exit 187)
27
+ # format_spec returns this entry as-is and ffmpeg_argv reads from it, so nested
28
+ # Hash / Array values are frozen too, preventing an accidental mutation from
29
+ # corrupting global state (it raises FrozenError instead, so it gets noticed).
30
+ VIDEO_FORMATS = {
31
+ webm: { ext: "webm", codec: "libvpx-vp9", extra: [].freeze }.freeze,
32
+ mp4: {
33
+ ext: "mp4", codec: "libx264",
34
+ extra: ["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"].freeze
35
+ }.freeze
36
+ }.freeze
37
+
38
+ class << self
39
+ def configure
40
+ yield config
41
+ config
42
+ end
43
+
44
+ def config
45
+ @config ||= Configuration.new
46
+ end
47
+
48
+ def reset_config!
49
+ @config = Configuration.new
50
+ @output_dir_cleaned = false
51
+ end
52
+
53
+ # Empties output_dir at the start of a run so this run's videos aren't
54
+ # mixed with stale files from a previous run. No-op unless recording is
55
+ # enabled (RECORD_VIDEO), preserving the "disabled -> nothing happens"
56
+ # contract. Wipes at most once per process, so registering it in a
57
+ # before(:suite) hook is idempotent. Does nothing if the directory does
58
+ # not exist yet.
59
+ def clean_output_dir!
60
+ return unless config.enabled?
61
+ return if @output_dir_cleaned
62
+
63
+ @output_dir_cleaned = true
64
+ dir = config.output_dir
65
+ return unless File.directory?(dir)
66
+
67
+ # Enumerate entries with Dir.children instead of interpolating dir into a
68
+ # Dir.glob pattern: a dir path containing glob metacharacters ([, {, *,
69
+ # ...) would otherwise be misparsed and silently clean nothing.
70
+ Dir.children(dir).each { |name| FileUtils.rm_rf(File.join(dir, name)) }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,4 @@
1
+ module SeleniumScreencast
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: selenium_screencast
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - aki
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: selenium-webdriver
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: selenium-devtools
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Captures JPEG frames from Chrome via the CDP Page.startScreencast API
41
+ and encodes them into a webm video with ffmpeg. Wires into RSpec/Capybara system
42
+ specs with a single require, and its behavior (enable/disable, slow-motion factor,
43
+ ffmpeg binary, debug output) can be controlled via environment variables.
44
+ email:
45
+ - lala.akira@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - CODE_OF_CONDUCT.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - lib/selenium_screencast.rb
56
+ - lib/selenium_screencast/configuration.rb
57
+ - lib/selenium_screencast/encoder.rb
58
+ - lib/selenium_screencast/frame_store.rb
59
+ - lib/selenium_screencast/recorder.rb
60
+ - lib/selenium_screencast/rspec.rb
61
+ - lib/selenium_screencast/version.rb
62
+ - sig/selenium_screencast.rbs
63
+ homepage: https://github.com/aki77/selenium_screencast
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ allowed_push_host: https://rubygems.org
68
+ homepage_uri: https://github.com/aki77/selenium_screencast
69
+ source_code_uri: https://github.com/aki77/selenium_screencast/tree/v0.1.0
70
+ changelog_uri: https://github.com/aki77/selenium_screencast/blob/main/CHANGELOG.md
71
+ rubygems_mfa_required: 'true'
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.2.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.15
87
+ specification_version: 4
88
+ summary: Record Capybara/Selenium system specs to webm video via Chrome DevTools screencast.
89
+ test_files: []