SeleniumScreencast

Record your Capybara/Selenium system specs as webm or mp4 video, using the Chrome DevTools Protocol (CDP) screencast API. SeleniumScreencast captures the JPEG frame stream from Page.startScreencast, then encodes it into a webm or mp4 video with ffmpeg — no browser extensions or external recording tools required.

Requirements

  • ffmpeg available on PATH (or point FFMPEG_BIN / ffmpeg_bin at it). For mp4 output, ffmpeg must include an H.264 (libx264) encoder.
  • A Chrome/Chromium-based driver used through selenium-webdriver, since recording relies on the CDP devtools connection exposed by Selenium's Chrome bridge (e.g. system specs driven by :selenium_chrome_headless or similar)

Installation

Add the gem to your application's test group:

bundle add selenium_screencast --group test

Usage

RSpec / Capybara

Require the RSpec adapter once, e.g. in rails_helper.rb or a spec support file:

require "selenium_screencast/rspec"

This automatically records every type: :system example when recording is enabled, and by default saves a video after each example finishes (regardless of pass/fail). See Recording only failed specs to keep videos only for failures.

Enable recording by setting RECORD_VIDEO when running specs:

RECORD_VIDEO=1 bundle exec rspec spec/system/checkout_spec.rb

Videos are written as webm (or mp4, depending on config.format) files under the configured output_dir (Rails.root.join("tmp/videos") in a Rails app, or tmp/videos relative to the working directory otherwise). Each file is named after the example's full description, sanitized to a safe filename with a random suffix to avoid collisions, e.g. Checkout_completes_purchase_a1b2c3d4.webm (or ....mp4 when format is :mp4).

Configuration

Configure defaults in an initializer or spec support file:

SeleniumScreencast.configure do |config|
  config.slow_factor = 4          # default: 8.0
  config.output_fps = 30          # default: 30
  config.ffmpeg_bin = "ffmpeg"    # default: "ffmpeg"
  config.quality = 80             # default: 80
  config.every_nth_frame = 1      # default: 1
  config.last_frame_duration = 0.5 # default: 0.5 (seconds, before slow_factor)
  config.inflight_delay = 0.2     # default: 0.2 (seconds, wait before stopping)
  config.output_dir = "tmp/videos" # default: Rails.root/tmp/videos, or tmp/videos
  config.debug = false            # default: false
  config.format = :webm           # default: :webm (:webm or :mp4)
  config.failed_only = false      # default: false
end
Option Description Default
slow_factor Playback slow-motion multiplier applied to inter-frame durations 8.0
output_fps Fixed output frame rate (CFR) that ffmpeg re-encodes to 30
ffmpeg_bin Path to the ffmpeg executable "ffmpeg"
quality JPEG quality requested from the CDP screencast; must be 0-100 80
every_nth_frame Only capture every Nth screencast frame from Chrome; must be >= 1 1
last_frame_duration Seconds the final frame is held for, before slow_factor is applied 0.5
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
output_dir Directory videos are written to Rails.root/tmp/videos (Rails) or tmp/videos
debug Keep the intermediate JPEG frame files (and concat list) instead of deleting them after encoding false
format Output video format (:webm or :mp4); determines container and codec :webm
failed_only Only keep the video for examples that failed; passing examples are recorded but never encoded false

Precedence: environment variable > configure value > default. When one of the environment variables below is set, it always wins over a value set via SeleniumScreencast.configure.

Clearing previous videos

When recording is enabled (RECORD_VIDEO is set), output_dir is emptied once at the start of each rspec run, so this run's videos aren't mixed with stale files from previous runs. This happens automatically via the selenium_screencast/rspec adapter's before(:suite) hook — no configuration needed.

When RECORD_VIDEO is unset, the output dir is left untouched (the "disabled -> nothing happens" contract).

The cleanup runs at most once per process. With parallel_tests, before(:suite) fires once per RSpec worker process, so each worker empties the shared output dir at startup (not exactly once across all processes). Because the workers share one output_dir and start at different times, a slower-starting worker can wipe videos a faster worker already recorded in the same run. If you rely on parallel_tests, give each worker its own output_dir (e.g. keyed by TEST_ENV_NUMBER) so a worker only clears its own directory.

Only the directory contents are removed; the directory itself is kept.

Environment variables

Variable Effect Default
RECORD_VIDEO Enables recording when present (any non-empty value) disabled
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. all specs
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
RECORD_VIDEO_FAILED_ONLY Only keep the video for examples that failed, when present (any non-empty value). See Recording only failed specs. disabled
RECORD_SLOW_FACTOR Overrides the playback slow-motion multiplier 8
RECORD_QUALITY Overrides the JPEG quality (0-100) requested from the screencast 80
RECORD_EVERY_NTH_FRAME Overrides how many screencast frames to skip (capture every Nth frame; must be >= 1) 1
FFMPEG_BIN Overrides the path to the ffmpeg executable ffmpeg
RECORD_DEBUG Keeps intermediate frame files on disk when present disabled
RECORD_VIDEO_FORMAT Overrides the output format (webm / mp4) webm

Recording only specific specs

By default, when RECORD_VIDEO is enabled, every type: :system example is recorded. When you run the full suite but only want video for a few specs — for example, to review a newly added spec — narrow recording to specific spec files with RECORD_VIDEO_TESTS (comma-separated) and/or RECORD_VIDEO_TESTS_FILE (one path per line). All the specs still run; only the listed ones are recorded.

# Inline list — the whole suite runs; only these two specs are recorded
RECORD_VIDEO=1 RECORD_VIDEO_TESTS="spec/system/checkout_spec.rb,spec/system/login_spec.rb" \
  bundle exec rspec

# List from a file (e.g. generated by CI from the changed specs)
RECORD_VIDEO=1 RECORD_VIDEO_TESTS_FILE=tmp/record_targets.txt bundle exec rspec

When both RECORD_VIDEO_TESTS and RECORD_VIDEO_TESTS_FILE are set, the union of the two lists is used.

Behavior summary:

RECORD_VIDEO Target list (RECORD_VIDEO_TESTS / RECORD_VIDEO_TESTS_FILE) Result
unset (ignored) Recording disabled entirely; the target list is never read.
set both unset Every system spec is recorded (backward-compatible default).
set set, with matching paths Only the matching spec files are recorded.
set set, but effectively empty (blank values only) Nothing is recorded — an empty selection does not fall back to recording everything.

Paths are compared after normalizing against Rails.root (or the working directory when not running under Rails), so relative paths (spec/system/..., ./spec/system/...) and absolute paths all match the same spec.

Recording only failed specs

When running the full suite in CI, you typically don't want a video for every passing example — only for the ones that failed, so they can be attached as build artifacts. Set RECORD_VIDEO_FAILED_ONLY (or config.failed_only) to do this:

RECORD_VIDEO=1 RECORD_VIDEO_FAILED_ONLY=1 bundle exec rspec

Honest overhead note: whether an example passed or failed is only known after it finishes, so CDP frame capture still runs for every example — this can't be avoided. What's actually skipped is ffmpeg encoding, which is the dominant cost of recording. For examples that pass, ffmpeg is never invoked, so neither the video file nor its intermediate frame directory (*_frames) is ever created — encoding isn't skipped by deleting output afterward, it's skipped from the start. The inflight_delay wait is skipped too, since it only exists to let a trailing frame make it into the video.

Whether a video is kept depends on the example's outcome:

Example outcome Video kept?
passed No
failed Yes
failed via aggregate_failures Yes
failed in a user's own after hook Yes
pending, failed as expected No (a known failure doesn't need a video)
pending but unexpectedly passed Yes (this is a real failure)
skipped No

RECORD_VIDEO_FAILED_ONLY combines with RECORD_VIDEO_TESTS / RECORD_VIDEO_TESTS_FILE as an AND: an example is only encoded if it's both in the target list (or no list is set) and it failed.

Interaction with RECORD_DEBUG: for a passing example, nothing is encoded, so there are no frames left to keep — RECORD_DEBUG has no effect on passing examples. RECORD_DEBUG remains useful for inspecting the source frames behind a video that was produced (i.e. a failing example).

Reducing recording overhead

Since CDP frame capture can't be skipped for any example (see above), the remaining lever is making that capture itself cheaper. Two knobs help:

  • Lowering RECORD_QUALITY (or config.quality) shrinks each JPEG frame, reducing CDP transfer and disk I/O.
  • Raising RECORD_EVERY_NTH_FRAME (or config.every_nth_frame) reduces the number of frames captured at all.

Note: raising every_nth_frame does not shorten the video or make it play faster. Frame display durations are computed from the deltas between consecutive CDP frame timestamps, so skipping frames just widens those gaps and each surviving frame is held on screen longer — total playback time is preserved. What you lose is temporal resolution (motion looks choppier between frames), not timing fidelity.

RECORD_VIDEO=1 RECORD_QUALITY=40 RECORD_EVERY_NTH_FRAME=2 bundle exec rspec

Core API (without Capybara)

SeleniumScreencast::Recorder only needs an object that responds to #devtools (as provided by selenium-webdriver's Chrome bridge), so it can be used directly without Capybara or RSpec:

require "selenium_screencast"
require "selenium-webdriver"

driver = Selenium::WebDriver.for(:chrome)
recorder = SeleniumScreencast::Recorder.new(driver, output_path: "out.webm")

recorder.start
# ... drive the browser ...
recorder.stop

How it works

  1. Starts a CDP screencast session (Page.startScreencast) and writes each incoming JPEG frame to a sequential file, acknowledging every frame so Chrome keeps streaming.
  2. On stop, builds an ffmpeg concat list where each frame's display duration is derived from the delta between its Chrome timestamp and the next frame's (variable frame rate), scaled by slow_factor.
  3. Runs ffmpeg once against that concat list, re-encoding the variable frame rate stream to a fixed output_fps (CFR) file, using VP9/webm or H.264/mp4 depending on config.format. If the configured ffmpeg executable cannot be found, a clear SeleniumScreencast::Error is raised before encoding instead of failing silently.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/aki77/selenium_screencast. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the SeleniumScreencast project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.