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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +287 -0
- data/Rakefile +12 -0
- data/lib/selenium_screencast/configuration.rb +221 -0
- data/lib/selenium_screencast/encoder.rb +153 -0
- data/lib/selenium_screencast/frame_store.rb +68 -0
- data/lib/selenium_screencast/recorder.rb +123 -0
- data/lib/selenium_screencast/rspec.rb +140 -0
- data/lib/selenium_screencast/version.rb +5 -0
- data/lib/selenium_screencast.rb +73 -0
- data/sig/selenium_screencast.rbs +4 -0
- metadata +89 -0
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
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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,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,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
|
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: []
|