ruby_raider 4.0.0 → 4.0.1

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.
data/README.md CHANGED
@@ -31,57 +31,186 @@ Ruby Raider is a CLI gem for scaffolding and generating UI test automation frame
31
31
 
32
32
  ## Supported Frameworks
33
33
 
34
+ Ruby Raider works on Mac OS, Linux and Windows.
35
+
34
36
  ### Web Testing
35
37
 
36
- | Test Framework | Selenium |
37
- |----------------|----------|
38
- | RSpec | ✅ |
38
+ Web projects use **Selenium and RSpec**. They get:
39
+
40
+ - Locators people see on the page (`label:`, `text:`, `by_role(...)` and more), with strict matching
41
+ - Automatic waiting for elements and actions: no implicit wait, no `sleep`
42
+ - Retrying RSpec matchers
43
+ - Login reuse, failure video, failure trace, network mocking and screenshot comparison
44
+ - Optional accessibility tests (axe) and a choice of GitHub Actions or Azure Pipelines
39
45
 
40
- ruby_raider 4.0 generates Selenium projects with RSpec only. Projects generated by 3.x (Cucumber, Watir) keep working on the 3.x line through the `ruby_raider` pin in their Gemfile (`~> 3.15` or an older `~> 3.x` requirement); 4.0 does not read or convert them.
46
+ See [What's Included](#whats-included) for each one.
41
47
 
42
- ### Mobile Testing (Appium)
48
+ ### Mobile Testing
49
+
50
+ Mobile projects use **Appium and RSpec**:
43
51
 
44
52
  | Test Framework | iOS | Android | Cross-Platform |
45
53
  |----------------|-----|---------|----------------|
46
54
  | RSpec | ✅ | ✅ | ✅ |
47
55
 
48
- ### Optional Add-on (Web Only)
56
+ They get:
57
+
58
+ - Appium locators: `accessibility_id:`, `id:`, `xpath:` and `class_name:`
59
+ - Automatic waiting: `find_element` waits for a match, and `click`, `send_keys` and `clear` wait until the element is displayed and enabled
60
+ - Retrying RSpec matchers
61
+ - Gestures: `swipe`, `scroll` and `scroll_to`, on iOS and Android
62
+ - Failure video from Appium's screen recording
63
+ - Raider reporting and a GitHub Actions workflow that starts a simulator or emulator and Appium
64
+
65
+ On native app screens, `find_element` returns the first match (strict matching is web only) and `submit` is not checked.
66
+
67
+ To run the examples, download the example [app](https://github.com/RaiderHQ/raider_test_app) and start the server:
68
+
69
+ ```bash
70
+ raider u start_appium
71
+ ```
72
+
73
+ ### Optional Add-ons
49
74
 
50
75
  | Add-on | Flag | Description |
51
76
  |--------|------|-------------|
52
- | Accessibility | `--accessibility` | Adds axe gem + example accessibility tests |
53
- | CI provider | `-p ci:azure` | Generates `azure-pipelines.yml` instead of the GitHub Actions workflow (default `ci:github`) |
54
-
55
- This works on all platforms (Mac OS, Linux and Windows).
77
+ | Accessibility (web) | `--accessibility` | Adds the axe gem and example accessibility tests |
78
+ | CI provider (web) | `-p ci:azure` | Generates `azure-pipelines.yml` instead of the GitHub Actions workflow (default `ci:github`) |
56
79
 
57
80
  ## What's Included
58
81
 
59
- Every generated project comes with:
60
-
61
- - **Page Object Model** — Base page and component classes with example pages
62
- - **Test examples** — Working login tests against the [Raider Test Store](https://raider-test-site.onrender.com/)
63
- - **Raider reporting** — Generated projects include [raider_reporter](https://github.com/RaiderHQ/raider_reporter), which writes results to `raider-results/` and uploads runs to your Raider Hideout server when `RAIDER_TOKEN` and `RAIDER_ENDPOINT` are set; generated CI runs the suite through `raider-reporter exec` with `RAIDER_QUARANTINE`, so quarantined tests do not fail the build; an opt-in `raider-gate` job (`RAIDER_GATE=1`) fails CI when the Raider quality gate fails
64
- - **Locators, waiting and assertions**: The runtime (`ruby_raider/runtime`), loaded by every generated project, adds waiting to Selenium and Appium themselves. Locators go beyond `id` and `css`: `label:`, `placeholder:`, `alt:`, `title:`, `text:`, `test_id:` and `by_role('button', name: 'Save')` find elements the way a person sees them, and `find_element` fails when a locator matches more than one. `find_element` and `find_elements` wait for a match, so there is no implicit wait and no `sleep`. Before an action the runtime waits until the element is ready: `click` waits until it is visible, enabled, not inert, stable and not covered; `send_keys` and `clear` until it is visible, enabled, editable and not inert; `submit` until it is visible, enabled and not inert (on native apps, displayed and enabled); `force: true` skips the checks for one call. `timeout`, `strict` and `actionability` in `config.yml` (or `RAIDER_TIMEOUT`, `RAIDER_STRICT`, `RAIDER_ACTIONABILITY`) set the rules. RSpec matchers (`have_text`, `have_value`, `have_attribute`, `have_css`, `have_xpath`, `be_visible`, `be_hidden`, `be_enabled`, `be_disabled`, `be_checked`, `have_count`, `have_title`, `have_url`, `have_element`) retry until the page matches, and a block expectation such as `expect { driver.find_element(id: 'status').text }.to eq('done')` retries with any value matcher
65
- - **Example tests or a skeleton (`examples:false`)** — start from the working login examples, or pass `examples:false` for an empty skeleton with the helpers, config, CI and README (see [Getting Started](#getting-started))
66
- - **Login reuse** — generated web projects (Selenium) log in once and restore the saved session for examples tagged `:logged_in`; `RAIDER_LOGIN_STATE=off|refresh` and `login_state: max_age:` control it, and the state stays in git-ignored `.raider/auth/`
67
- - **Failure video replay** — Every test is recorded and the video is kept only for failing tests, attached to the report as `video` (WebM from page screenshots for Selenium, Appium's own MP4 for mobile, which needs `ffmpeg` on the Appium server host for iOS and for Android recordings over 180 s). Turn it off per run with `VIDEO=off`, or per project with `raider u video off`; ffmpeg is downloaded once, SHA-256 checked and cached in `~/.cache/raider`. The RaiderHQ `ffmpeg-7.1` build that the download uses is not published yet, so for now set `RAIDER_FFMPEG_PATH` to an ffmpeg with libvpx to get web videos
68
- - **Failure trace replay** (Selenium) — Failing tests keep a step-by-step trace: each action with its locator and wait time, a screenshot and the page HTML after every step that changes the page, plus network requests and console messages on Chrome, Edge and Firefox. It is saved as `raider-results/attachments/<n>-trace.zip`, attached to the report as `trace` and opened in Raider Desktop or on Hideout. `TRACE=on` keeps passing tests too, `TRACE=off` captures nothing, and `raider u trace on|off` sets it for the project. Typed values in password fields and `RAIDER_SECRET_*` values are hidden; other typed values, page text and URLs are kept, so treat a trace like the video. Request headers, cookies and bodies are never captured
69
- - **Network mocking** (Selenium) — Answer a request with your own response or block it, from the test: `mock_request('**/api/users', json: { users: [] })`, `mock_request(url, status: 500)` and `block_request('**/*.png')`, with `calls` to check what the page asked for. It uses WebDriver BiDi on Chrome, Edge and Firefox (Safari and `BIDI=off` raise `NetworkHelper::Unavailable`), handles CORS for cross-origin mocks, clears mocks after each test, and marks mocked requests in the trace
70
- - **Screenshot comparison** (Selenium) — `match_screenshot('home')` compares the page, or an element with `element:`, with a baseline in `screenshots/baselines/<browser>/`. The first run saves the baseline; later runs fail on a difference and attach the expected, actual and diff pictures to the report and the trace. `SCREENSHOTS=update` rewrites baselines, a missing baseline fails on CI, and tolerances (`threshold`, `max_diff_pixels`, `max_diff_ratio`) go in `config/config.yml` under `screenshots:`. Generate baselines where CI runs: a Retina laptop makes 2x pictures
71
- - **GitHub Actions CI** — Ready-to-use test pipeline that caches ffmpeg for failure videos
72
- - **Rake tasks** — Default, smoke, and regression tasks with tag-based filtering
73
- - **Retry support** — RSpec retry with configurable retry count via `RETRY_COUNT` env var
74
- - **Re-run failed tests** — RSpec keeps `spec/examples.txt` for `--only-failures`
75
- - **Parallel execution** — parallel_tests and parallel_split_test gems included
76
- - **Configurable browser** — Chrome/Firefox with customizable arguments, viewport, timeout, and headless mode
77
- - **Linting** — RuboCop and Reek configs included
78
-
79
- For Appium projects, download the example [app](https://github.com/RaiderHQ/raider_test_app) and start the server:
82
+ Every generated project loads the Raider runtime (`ruby_raider/runtime`). It adds locators, waiting and assertions to Selenium and Appium themselves.
80
83
 
81
- ```bash
82
- raider u start_appium
84
+ ### Locators
85
+
86
+ Find elements the way a person sees them, on top of Selenium's `id:`, `css:`, `xpath:`, `name:`, `class:` and `link_text:`:
87
+
88
+ | Key | Finds |
89
+ |-----|-------|
90
+ | `label:` | the field with that label, `aria-label` or `aria-labelledby` |
91
+ | `placeholder:` | the field with that placeholder |
92
+ | `alt:` | the element with that `alt` text |
93
+ | `title:` | the element with that `title` |
94
+ | `text:` | the innermost element whose text contains it |
95
+ | `test_id:` | the element whose `data-testid` equals it |
96
+ | `by_role(...)` | the element with that ARIA role and name |
97
+
98
+ ```ruby
99
+ driver.find_element(label: 'Email')
100
+ driver.find_element(label: ['Email', { exact: true }]) # whole text only
101
+ driver.find_element(by_role('button', name: 'Save'))
102
+ driver.find_element(by_role('heading', level: 1))
103
+ ```
104
+
105
+ - Text keys match part of the text and ignore extra spaces. Pass `{ exact: true }` for the whole text.
106
+ - `test_id:` reads another attribute with `Raider.test_id_attribute = 'data-qa'` or `RAIDER_TEST_ID_ATTRIBUTE`.
107
+ - **Strict matching:** `find_element` fails with `Raider::AmbiguousMatch` when a locator matches more than one element. `find_elements` never does. Turn it off with `raider u strict off`.
108
+
109
+ These keys are for web pages. Mobile projects use Appium's locators (see [Mobile Testing](#mobile-testing)).
110
+
111
+ ### Waiting and actionability
112
+
113
+ - `find_element` and `find_elements` wait for a match. There is no implicit wait and no `sleep`.
114
+ - Before an action, the runtime waits until the element is ready:
115
+
116
+ | Action | Waits until the element is |
117
+ |--------|----------------------------|
118
+ | `click` | visible, enabled, not inert, stable and not covered |
119
+ | `send_keys`, `clear` | visible, enabled, editable and not inert |
120
+ | `submit` | visible, enabled and not inert |
121
+
122
+ - `force: true` skips the checks for one call: `button.click(force: true)`.
123
+ - Set the rules with `timeout`, `strict` and `actionability` in `config/config.yml`, or `RAIDER_TIMEOUT`, `RAIDER_STRICT` and `RAIDER_ACTIONABILITY`.
124
+
125
+ ### Assertions
126
+
127
+ RSpec matchers retry until the page matches or `timeout` runs out:
128
+
129
+ `have_text`, `have_value`, `have_attribute`, `have_css`, `have_xpath`, `have_element`, `have_count`, `have_title`, `have_url`, `be_visible`, `be_hidden`, `be_enabled`, `be_disabled`, `be_checked`
130
+
131
+ ```ruby
132
+ expect { account_page.welcome_message }.to have_text('Welcome')
133
+ expect { driver.find_element(id: 'status').text }.to eq('done') # any value matcher retries in a block
83
134
  ```
84
135
 
136
+ ### Raider reporting
137
+
138
+ Projects include [raider_reporter](https://github.com/RaiderHQ/raider_reporter).
139
+
140
+ - Results go to `raider-results/`.
141
+ - Runs upload to your Raider Hideout server when `RAIDER_TOKEN` and `RAIDER_ENDPOINT` are set.
142
+ - CI runs the suite through `raider-reporter exec` with `RAIDER_QUARANTINE`, so quarantined tests do not fail the build.
143
+ - Set the CI variable `RAIDER_GATE` to `1` to add a `raider-gate` job that fails CI when the Raider quality gate fails.
144
+
145
+ ### Failure video replay
146
+
147
+ Each test is recorded. The video is kept only when the test fails and attached to the report as `video`.
148
+
149
+ - **Web:** WebM built from page screenshots. ffmpeg is downloaded once, SHA-256 checked and cached in `~/.cache/raider`: an LGPL ffmpeg 7.1 build with libvpx for macOS (Apple silicon and Intel), Linux x64 and Windows x64, built by [`.github/workflows/ffmpeg.yml`](.github/workflows/ffmpeg.yml). On other platforms set `RAIDER_FFMPEG_PATH` to an ffmpeg with libvpx.
150
+ - **Mobile:** Appium's own MP4. iOS needs `ffmpeg` on the Appium server host. Android stops at 180 s by default, so it needs no ffmpeg; a longer `video.time_limit` needs ffmpeg too.
151
+ - Turn it off per run with `VIDEO=off`, or per project with `raider u video off`.
152
+
153
+ ### Failure trace replay (web)
154
+
155
+ Failing tests keep a step-by-step trace:
156
+
157
+ - each action with its locator and wait time
158
+ - a screenshot and the page HTML after every step that changes the page
159
+ - network requests and console messages on Chrome, Edge and Firefox
160
+
161
+ It is saved as `raider-results/attachments/<n>-trace.zip`, attached to the report as `trace`, and opens in [Raider Desktop](https://ruby-raider.com) or on Hideout.
162
+
163
+ - `TRACE=on` keeps passing tests too. `TRACE=off` captures nothing. `raider u trace on|off` sets it for the project.
164
+ - Password fields and `RAIDER_SECRET_*` values are hidden. Other typed values, page text and URLs are kept, so treat a trace like the video.
165
+ - Request headers, cookies and bodies are never captured.
166
+
167
+ ### Network mocking (web)
168
+
169
+ Answer a request with your own response, or block it:
170
+
171
+ ```ruby
172
+ mock_request('**/api/users', json: { users: [] })
173
+ mock_request(url, status: 500)
174
+ block_request('**/*.png')
175
+ ```
176
+
177
+ - `mock_request` returns the mock; `mock.calls` lists the requests it answered.
178
+ - Works through WebDriver BiDi on Chrome, Edge and Firefox. Safari and `BIDI=off` raise `NetworkHelper::Unavailable`.
179
+ - Handles CORS for cross-origin mocks, clears mocks after each test, and marks mocked requests in the trace.
180
+
181
+ ### Screenshot comparison (web)
182
+
183
+ ```ruby
184
+ match_screenshot('home')
185
+ ```
186
+
187
+ - Compares the page, or one element with `element:`, with a baseline in `screenshots/baselines/<browser>/`.
188
+ - The first run saves the baseline. Later runs fail on a difference and attach the expected, actual and diff pictures to the report and the trace.
189
+ - `SCREENSHOTS=update` rewrites baselines. A missing baseline fails on CI.
190
+ - Tolerances (`threshold`, `max_diff_pixels`, `max_diff_ratio`) go in `config/config.yml` under `screenshots:`.
191
+ - Generate baselines where CI runs: a Retina laptop makes 2x pictures.
192
+
193
+ ### Login reuse (web)
194
+
195
+ Example projects log in once and restore the saved session for examples tagged `:logged_in`.
196
+
197
+ - `RAIDER_LOGIN_STATE=off|refresh` and `login_state: max_age:` control it.
198
+ - The state stays in git-ignored `.raider/auth/`.
199
+ - Skeletons (`examples:false`) get `SessionState#reuse` to wire your own login.
200
+
201
+ ### Also included
202
+
203
+ - **Page Object Model:** base page and component classes with example pages
204
+ - **Example tests:** working tests against the [Raider Test Store](https://raider-test-site.onrender.com/) (web) or the [Raider test app](https://github.com/RaiderHQ/raider_test_app) (mobile), or an empty skeleton with `examples:false` (see [Getting Started](#getting-started))
205
+ - **CI:** GitHub Actions, or Azure Pipelines for web. It caches ffmpeg and comments results on pull requests
206
+ - **Failure screenshots:** each failing test attaches a screenshot to the report
207
+ - **Rake tasks:** default, smoke and regression, filtered by tag
208
+ - **Retries:** RSpec retry, with the count set by `RETRY_COUNT`
209
+ - **Re-run failed tests:** RSpec keeps `spec/examples.txt` for `--only-failures`
210
+ - **Parallel execution:** `parallel_tests` and `parallel_split_test`
211
+ - **Configurable browser:** Chrome or Firefox, with arguments, viewport, timeout and headless mode
212
+ - **Linting:** RuboCop and Reek configs
213
+
85
214
  ## Getting Started
86
215
 
87
216
  Install the gem:
@@ -102,6 +231,12 @@ Or skip the menu with parameters:
102
231
  raider new [project_name] -p framework:rspec automation:selenium
103
232
  ```
104
233
 
234
+ Create a mobile project (`automation:ios`, `automation:android` or `automation:cross_platform`):
235
+
236
+ ```bash
237
+ raider new my_app -p framework:rspec automation:android
238
+ ```
239
+
105
240
  Add accessibility testing:
106
241
 
107
242
  ```bash
data/RELEASE.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Release Notes
4
4
 
5
+ ### 4.0.1
6
+
7
+ - Failure video works for web projects without `RAIDER_FFMPEG_PATH`: the RaiderHQ `ffmpeg-7.1` release is published and `RubyRaider::Ffmpeg::MANIFEST` pins the SHA-256 of its four binaries (macOS arm64 and x64, Linux x64, Windows x64). The first failing test, or `raider u video install`, downloads the one for your platform, checks it and caches it in `~/.cache/raider` (`%LOCALAPPDATA%\raider` on Windows).
8
+ - The binaries are FFmpeg 7.1.5 built from source by `.github/workflows/ffmpeg.yml` (`.github/ffmpeg/build.sh`): LGPL (no `--enable-gpl`, no `--enable-nonfree`), statically linked with libvpx 1.15.2 and zlib 1.3.1, with only the PNG input, scaling, VP8/VP9 encoding and WebM output that the video helper uses. The release carries the license texts, the source tarballs and the exact configure line. macOS binaries are ad-hoc signed, not notarized (`spctl` rejects them). Gatekeeper only checks files carrying the quarantine attribute, which a browser download gets and the gem's Net::HTTP download does not, so the gem's copy runs; a copy downloaded with a browser would be blocked.
9
+ - On other platforms (Linux arm64, for example) `RAIDER_FFMPEG_PATH` is still the way to get web videos; the `[video]` line now says so.
10
+ - Existing 4.0.0 projects need no change beyond `bundle update ruby_raider`.
11
+
5
12
  ### 4.0.0
6
13
 
7
14
  - Watir is removed: `automation:watir` is no longer a valid automation value.
@@ -746,17 +746,16 @@ The video is a `video.webm` built from page screenshots with ffmpeg.
746
746
  `raider u video off` (`raider u video on` turns it back on).
747
747
  <%- end -%>
748
748
  <%- unless mobile? -%>
749
- - ffmpeg is downloaded on first use, checked against a pinned SHA-256 and cached in `~/.cache/raider`
750
- (`%LOCALAPPDATA%\raider` on Windows). `raider u video install` downloads it ahead of time (CI does this before the
749
+ - ffmpeg 7.1 (an LGPL build with libvpx, from the RaiderHQ `ffmpeg-7.1` release) is downloaded on first use,
750
+ checked against a pinned SHA-256 and cached in `~/.cache/raider` (`%LOCALAPPDATA%\raider` on Windows). Builds exist
751
+ for macOS (Apple silicon and Intel), Linux x64 and Windows x64. `raider u video install` downloads it ahead of time (CI does this before the
751
752
  tests and caches the folder), and `raider u video status` prints the binary in use.
752
- - `RAIDER_FFMPEG_PATH` uses your own ffmpeg (it needs libvpx) instead of the download, and `RAIDER_CACHE_DIR` moves
753
- the cache.
753
+ - `RAIDER_FFMPEG_PATH` uses your own ffmpeg (it needs libvpx) instead of the download, for example on Linux arm64,
754
+ and `RAIDER_CACHE_DIR` moves the cache.
754
755
  <%- if azure? -%>
755
756
  - `azure-pipelines.yml` sets `RAIDER_CACHE_DIR` to `$(Pipeline.Workspace)/.cache/raider`, the folder its `Cache@2`
756
757
  step keeps between runs.
757
758
  <%- end -%>
758
- - The RaiderHQ `ffmpeg-7.1` release that the download comes from is not published yet. Until it is, set
759
- `RAIDER_FFMPEG_PATH` to get videos; without it the suite runs as usual and prints one `[video]` line.
760
759
  - If ffmpeg is missing or recording fails, the suite still runs and its exit code is unchanged; you get one `[video]`
761
760
  line on stderr and no video.
762
761
  - Known limits: a frame is taken after each page load, click and text change, plus one at the end of a failing test,
@@ -777,7 +776,7 @@ The video is a `video.webm` built from page screenshots with ffmpeg.
777
776
  than 180 s needs ffmpeg on the Appium host.
778
777
  - `raider u video install` and `raider u video status` manage the ffmpeg that web projects use, cached in
779
778
  `~/.cache/raider` (override with `RAIDER_FFMPEG_PATH` or `RAIDER_CACHE_DIR`); they do not install ffmpeg on the
780
- Appium host. The RaiderHQ `ffmpeg-7.1` release it comes from is not published yet.
779
+ Appium host.
781
780
  - If recording fails, the suite still runs and its exit code is unchanged; you get one `[video]` line on stderr and
782
781
  no video.
783
782
  <%- end -%>
@@ -24,8 +24,7 @@ module VideoHelper
24
24
  <%- unless mobile? -%>
25
25
  MAX_FRAME_SECONDS = 2.0
26
26
  ENCODE_TIMEOUT = 120
27
- NO_FFMPEG = 'no ffmpeg available for this platform yet (set RAIDER_FFMPEG_PATH, or run ' \
28
- '`raider u video install` once the ffmpeg release is published)'
27
+ NO_FFMPEG = 'no ffmpeg download for this platform (set RAIDER_FFMPEG_PATH to an ffmpeg with libvpx)'
29
28
  <%- end -%>
30
29
 
31
30
  @disabled = false
@@ -14,16 +14,31 @@ module RubyRaider
14
14
  MAX_REDIRECTS = 5
15
15
  # Seconds for the whole download, redirects included.
16
16
  DOWNLOAD_TIMEOUT = 300
17
+ # The binaries are 3-5 MB; a bigger body is not ours and must not fill the disk before the checksum.
18
+ MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024
17
19
 
18
- # Checksums are filled in when the RaiderHQ `ffmpeg-7.1` release is cut.
20
+ # The RaiderHQ `ffmpeg-7.1` release: FFmpeg 7.1.5, LGPL, built by .github/workflows/ffmpeg.yml
21
+ # (.github/ffmpeg/build.sh), run 42440762165. Each SHA-256 pins the exact bytes of that release's asset.
19
22
  RELEASE_BASE = 'https://github.com/RaiderHQ/ruby_raider/releases/download/ffmpeg-7.1/ffmpeg-7.1-'
20
23
  MANIFEST = {
21
24
  version: '7.1',
22
25
  assets: {
23
- 'darwin-arm64' => { url: "#{RELEASE_BASE}darwin-arm64".freeze, sha256: nil }.freeze,
24
- 'darwin-x64' => { url: "#{RELEASE_BASE}darwin-x64".freeze, sha256: nil }.freeze,
25
- 'linux-x64' => { url: "#{RELEASE_BASE}linux-x64".freeze, sha256: nil }.freeze,
26
- 'windows-x64' => { url: "#{RELEASE_BASE}windows-x64.exe".freeze, sha256: nil }.freeze
26
+ 'darwin-arm64' => {
27
+ url: "#{RELEASE_BASE}darwin-arm64".freeze,
28
+ sha256: 'e6c9240c133f22ced9f1e55fc2d0889991274e1b787c296583cb74f7242ef417'
29
+ }.freeze,
30
+ 'darwin-x64' => {
31
+ url: "#{RELEASE_BASE}darwin-x64".freeze,
32
+ sha256: 'e649ff3c5abe05700a657a12d549ee27ac53e190cd1aaee7a113765d3189e91f'
33
+ }.freeze,
34
+ 'linux-x64' => {
35
+ url: "#{RELEASE_BASE}linux-x64".freeze,
36
+ sha256: '108b685bbfba103c8f25c641fc4c21e5ea79acecd131f9ea964db959e894e7d4'
37
+ }.freeze,
38
+ 'windows-x64' => {
39
+ url: "#{RELEASE_BASE}windows-x64.exe".freeze,
40
+ sha256: 'e27c4d32675e8715a6b3260c87ed3834f959275af0412f501ed5781d5324ac1a'
41
+ }.freeze
27
42
  }.freeze
28
43
  }.freeze
29
44
 
@@ -115,12 +130,8 @@ module RubyRaider
115
130
  def handle_response(response, destination, deadline, timeout)
116
131
  case response
117
132
  when Net::HTTPSuccess
118
- File.open(destination, 'wb') do |file|
119
- response.read_body do |chunk|
120
- check_deadline(deadline, timeout)
121
- file.write(chunk)
122
- end
123
- end
133
+ check_size(response['content-length'].to_i)
134
+ write_body(response, destination, deadline, timeout)
124
135
  nil
125
136
  when Net::HTTPRedirection
126
137
  response['location'] || raise(IOError, 'redirect without Location header')
@@ -129,6 +140,22 @@ module RubyRaider
129
140
  end
130
141
  end
131
142
 
143
+ def write_body(response, destination, deadline, timeout)
144
+ written = 0
145
+ File.open(destination, 'wb') do |file|
146
+ response.read_body do |chunk|
147
+ check_deadline(deadline, timeout)
148
+ written += chunk.bytesize
149
+ check_size(written)
150
+ file.write(chunk)
151
+ end
152
+ end
153
+ end
154
+
155
+ def check_size(bytes)
156
+ raise IOError, "download larger than #{MAX_DOWNLOAD_BYTES} bytes" if bytes > MAX_DOWNLOAD_BYTES
157
+ end
158
+
132
159
  def socket_timeouts(deadline, timeout)
133
160
  left = check_deadline(deadline, timeout)
134
161
  { open_timeout: [10, left].min, read_timeout: [60, left].min }
data/lib/version CHANGED
@@ -1 +1 @@
1
- 4.0.0
1
+ 4.0.1
@@ -5,6 +5,7 @@ module RubyRaider
5
5
 
6
6
  MAX_REDIRECTS: Integer
7
7
  DOWNLOAD_TIMEOUT: Integer
8
+ MAX_DOWNLOAD_BYTES: Integer
8
9
  RELEASE_BASE: String
9
10
  MANIFEST: manifest
10
11
  PLATFORMS: Array[String]
@@ -128,6 +128,19 @@ describe 'Generated VideoHelper' do
128
128
  expect(argv.each_cons(2).to_a).to include(['-vf', "scale='min(1280,iw)':-2"])
129
129
  end
130
130
 
131
+ # The pinned ffmpeg has only what this encode needs (.github/ffmpeg/build.sh), and
132
+ # .github/ffmpeg/check.sh proves it on every build: both must run the same arguments.
133
+ it 'runs the encode that .github/ffmpeg/check.sh proves on the pinned ffmpeg builds' do
134
+ recorder = VideoHelper.recorder_for(driver)
135
+ recorder.start
136
+ recorder.stop(keep: true)
137
+
138
+ check = File.read(File.expand_path('../../.github/ffmpeg/check.sh', __dir__)).gsub(/\\\n\s*/, '')
139
+ shell = argv[0..-2].map { |arg| arg.include?("'") ? %("#{arg}") : arg }
140
+ shell[shell.index('-i') + 1] = '"$work/list.txt"'
141
+ expect(check).to include(%("$binary" #{shell.join(' ')} "$work/video.webm"))
142
+ end
143
+
131
144
  context 'when the config sets other fps and max_width' do
132
145
  let(:video_config) { { 'enabled' => true, 'fps' => 10, 'max_width' => 640 } }
133
146
 
@@ -351,9 +364,14 @@ describe 'Generated VideoHelper' do
351
364
  end
352
365
  end
353
366
 
354
- context 'when no ffmpeg can be obtained (no override, placeholder manifest)' do
367
+ context 'when no ffmpeg can be obtained (no override, no build for this platform)' do
355
368
  let(:env) { { 'RAIDER_FFMPEG_PATH' => nil } }
356
369
 
370
+ before do
371
+ require 'ruby_raider/ffmpeg'
372
+ stub_const('RubyRaider::Ffmpeg::MANIFEST', { version: '7.1', assets: {} })
373
+ end
374
+
357
375
  it 'decides once, at the first call: listener nil, NullRecorder, exactly one [video] line' do
358
376
  require 'ruby_raider/ffmpeg'
359
377
  allow(RubyRaider::Ffmpeg).to receive(:ensure!)
@@ -362,9 +380,8 @@ describe 'Generated VideoHelper' do
362
380
  expect(warnings.size).to eq(1)
363
381
  expect(VideoHelper.recorder_for(driver)).to be_a(VideoHelper::NullRecorder)
364
382
  expect(VideoHelper.listener).to be_nil
365
- expect(warnings).to eq(['[video] no ffmpeg available for this platform yet (set RAIDER_FFMPEG_PATH, or run ' \
366
- '`raider u video install` once the ffmpeg release is published); ' \
367
- "recording disabled for this run\n"])
383
+ expect(warnings).to eq(['[video] no ffmpeg download for this platform (set RAIDER_FFMPEG_PATH to an ffmpeg ' \
384
+ "with libvpx); recording disabled for this run\n"])
368
385
  expect(RubyRaider::Ffmpeg).not_to have_received(:ensure!)
369
386
  expect(shots).to be_empty
370
387
  end
@@ -17,8 +17,13 @@ describe 'Generated Readme content' do
17
17
  'RAIDER_CACHE_DIR', 'raider-results/attachments/')
18
18
  end
19
19
 
20
- it 'says that video needs RAIDER_FFMPEG_PATH until the ffmpeg release is published' do
21
- expect(section).to include('ffmpeg-7.1', 'not published yet')
20
+ it 'names the ffmpeg-7.1 release and no longer says it is unpublished' do
21
+ expect(section).to include('ffmpeg-7.1') if web
22
+ expect(section).not_to include('not published yet')
23
+ end
24
+
25
+ it 'lists the platforms with an ffmpeg download for web projects' do
26
+ expect(section).to include('macOS (Apple silicon and Intel), Linux x64 and Windows x64') if web
22
27
  end
23
28
 
24
29
  it 'names the right video format and how to turn video off for this project' do
@@ -321,9 +321,9 @@ describe 'End-to-End Project Generation and Execution' do
321
321
  JSON.parse(File.read(File.join(project, 'raider-results', 'run.json')))['results']
322
322
  end
323
323
 
324
- # Failure video replay against real browsers. Needs an ffmpeg with libvpx (RAIDER_FFMPEG_PATH), because the
325
- # RaiderHQ ffmpeg release has no checksums yet. Skipped when it, or the browser, is missing. Firefox can be
326
- # pointed at a specific binary with FIREFOX_BINARY.
324
+ # Failure video replay against real browsers. Needs an ffmpeg with libvpx (RAIDER_FFMPEG_PATH), so most examples
325
+ # never download the release binary; RAIDER_E2E_DOWNLOAD=1 adds one that does. Skipped when it, or the browser,
326
+ # is missing. Firefox can be pointed at a specific binary with FIREFOX_BINARY.
327
327
  context 'Failure video replay', :slow, :video do
328
328
  let(:ffmpeg_path) { ENV.fetch('RAIDER_FFMPEG_PATH', '') }
329
329
  let(:cache_dir) { Dir.mktmpdir('raider-cache') }
@@ -421,16 +421,28 @@ describe 'End-to-End Project Generation and Execution' do
421
421
  it 'runs unchanged with one [video] line and no download when no ffmpeg is usable', :aggregate_failures do
422
422
  off = run_in_project(project, command, env: video_env('VIDEO' => 'off'))
423
423
  FileUtils.rm_rf(File.join(project, 'raider-results'))
424
- result = run_in_project(project, command, env: video_env('RAIDER_FFMPEG_PATH' => nil))
424
+ # A file where the cache dir should be: the download cannot even start, so nothing is fetched.
425
+ blocker = File.join(cache_dir, 'not-a-dir')
426
+ File.write(blocker, '')
427
+ result = run_in_project(project, command, env: video_env('RAIDER_FFMPEG_PATH' => nil, 'RAIDER_CACHE_DIR' => blocker))
425
428
 
426
429
  expect(result[:exit_code]).to eq(off[:exit_code]), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
427
430
  video_lines = result[:stderr].lines.grep(/\[video\]/)
428
431
  expect(video_lines.size).to eq(1), "STDERR: #{result[:stderr]}"
429
- # Decided at the first driver/recorder call, before any capture or fetch: the placeholder
430
- # manifest has no checksum, so nothing is captured or downloaded.
431
- expect(video_lines.first).to include('no ffmpeg available for this platform yet')
432
+ expect(video_lines.first).to include('ffmpeg download failed')
432
433
  expect(video_files(project)).to be_empty
433
- expect(Dir.glob(File.join(cache_dir, '**', '*'))).to be_empty
434
+ expect(Dir.glob(File.join(cache_dir, '**', '*'))).to eq([blocker])
435
+ end
436
+
437
+ # Downloads the pinned ffmpeg-7.1 release asset for this platform into an empty cache.
438
+ it 'downloads the pinned ffmpeg into an empty cache and attaches the video', :aggregate_failures do
439
+ skip 'set RAIDER_E2E_DOWNLOAD=1 to download the ffmpeg-7.1 release asset' unless ENV['RAIDER_E2E_DOWNLOAD'] == '1'
440
+
441
+ result = run_in_project(project, command, env: video_env('RAIDER_FFMPEG_PATH' => nil))
442
+ expect(result[:exit_code]).to eq(1), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
443
+ expect(result[:stderr]).not_to include('[video]')
444
+ expect(Dir.glob(File.join(cache_dir, 'ffmpeg', '7.1', 'ffmpeg{,.exe}')).size).to eq(1)
445
+ expect_video_for_failure_only(project, 'fails after driving the page', result)
434
446
  end
435
447
  end
436
448
  end
@@ -41,11 +41,51 @@ describe RubyRaider::Ffmpeg do
41
41
  expect(described_class.platform(host_os: 'linux-gnu', host_cpu: 'aarch64')).to be_nil
42
42
  end
43
43
 
44
- it 'ships a placeholder manifest with https urls and nil checksums for all four platforms' do
45
- assets = described_class::MANIFEST[:assets]
46
- expect(assets.keys).to contain_exactly('darwin-arm64', 'darwin-x64', 'linux-x64', 'windows-x64')
47
- expect(assets.values.map { |a| a[:url] }).to all(start_with('https://'))
48
- expect(assets.values.map { |a| a[:sha256] }).to all(be_nil)
44
+ it 'pins a SHA-256 and the ffmpeg-7.1 release url for each of the four platforms', :aggregate_failures do
45
+ expect(described_class::MANIFEST[:version]).to eq('7.1')
46
+ base = 'https://github.com/RaiderHQ/ruby_raider/releases/download/ffmpeg-7.1/ffmpeg-7.1-'
47
+ expect(described_class::MANIFEST[:assets].transform_values { |a| a[:url] }).to eq(
48
+ 'darwin-arm64' => "#{base}darwin-arm64", 'darwin-x64' => "#{base}darwin-x64",
49
+ 'linux-x64' => "#{base}linux-x64", 'windows-x64' => "#{base}windows-x64.exe"
50
+ )
51
+ shas = described_class::MANIFEST[:assets].values.map { |a| a[:sha256] }
52
+ expect(shas).to all(match(/\A\h{64}\z/))
53
+ expect(shas.uniq.size).to eq(4)
54
+ end
55
+
56
+ describe 'the pinned checksums' do
57
+ let(:real) { described_class::MANIFEST }
58
+
59
+ it 'is obtainable on every supported platform without an override' do
60
+ described_class::PLATFORMS.each do |p|
61
+ expect(described_class.obtainable?(env: {}, platform: p)).to be(true)
62
+ end
63
+ end
64
+
65
+ it 'discards a download whose bytes do not match the pinned checksum', :aggregate_failures do
66
+ described_class::PLATFORMS.each do |p|
67
+ result = described_class.ensure!(env:, manifest: real, fetch:, warn: warner, platform: p)
68
+ expect(result).to be_nil
69
+ end
70
+ expect(calls).to eq(real[:assets].values.map { |a| a[:url] })
71
+ expect(warnings).to all(include('checksum mismatch'))
72
+ expect(Dir.glob(File.join(cache, '**', '*'))).to be_empty
73
+ end
74
+
75
+ # FFMPEG_ARTIFACTS=<dir> holding ffmpeg-7.1-<platform>[.exe] (the build workflow's artifacts
76
+ # or the release assets) proves those exact bytes pass verification and get cached.
77
+ it 'accepts the built binaries', unless: ENV.fetch('FFMPEG_ARTIFACTS', '').empty? do
78
+ described_class::PLATFORMS.each do |p|
79
+ name = File.basename(real[:assets][p][:url])
80
+ source = File.join(ENV.fetch('FFMPEG_ARTIFACTS'), name)
81
+ copy = ->(_url, dest) { FileUtils.cp(source, dest) }
82
+ own = { 'RAIDER_CACHE_DIR' => File.join(cache, p) }
83
+ path = described_class.ensure!(env: own, manifest: real, fetch: copy, warn: warner, platform: p)
84
+ expect(path).to eq(described_class.cached_path(env: own, manifest: real, platform: p))
85
+ expect(File.executable?(path)).to be(true)
86
+ end
87
+ expect(warnings).to be_empty
88
+ end
49
89
  end
50
90
 
51
91
  it 'derives the cache dir from RAIDER_CACHE_DIR or the home cache', :aggregate_failures do
@@ -254,6 +294,39 @@ describe RubyRaider::Ffmpeg do
254
294
  expect(warnings).to eq(['cached ffmpeg failed checksum; video disabled'])
255
295
  end
256
296
 
297
+ describe 'download size cap' do
298
+ let(:dest) { File.join(cache, 'download.part') }
299
+ let(:deadline) { Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60 }
300
+
301
+ def ok_response(length, chunks)
302
+ response = Net::HTTPOK.new('1.1', '200', 'OK')
303
+ response['content-length'] = length.to_s if length
304
+ allow(response).to receive(:read_body) { |&block| chunks.each(&block) }
305
+ response
306
+ end
307
+
308
+ it 'refuses a Content-Length over the cap before writing anything', :aggregate_failures do
309
+ response = ok_response(described_class::MAX_DOWNLOAD_BYTES + 1, ['x'])
310
+ expect { described_class.send(:handle_response, response, dest, deadline, 60) }
311
+ .to raise_error(IOError, /larger than/)
312
+ expect(File).not_to exist(dest)
313
+ end
314
+
315
+ it 'stops writing once the body passes the cap, whatever Content-Length said' do
316
+ stub_const("#{described_class}::MAX_DOWNLOAD_BYTES", 10)
317
+ response = ok_response(nil, ['x' * 6, 'x' * 6, 'x' * 6])
318
+ expect { described_class.send(:handle_response, response, dest, deadline, 60) }
319
+ .to raise_error(IOError, /larger than/)
320
+ expect(File.size(dest)).to be <= 10
321
+ end
322
+
323
+ it 'writes a body within the cap' do
324
+ response = ok_response(4, %w[ab cd])
325
+ expect(described_class.send(:handle_response, response, dest, deadline, 60)).to be_nil
326
+ expect(File.read(dest)).to eq('abcd')
327
+ end
328
+ end
329
+
257
330
  describe 'DEFAULT_WARN' do
258
331
  it 'writes to $stderr even when $VERBOSE is nil' do
259
332
  original = $VERBOSE
@@ -281,8 +354,8 @@ describe RubyRaider::Ffmpeg do
281
354
  expect(described_class.obtainable?(env: {}, manifest:, platform: 'linux-x64')).to be(true)
282
355
  end
283
356
 
284
- it 'is false with no override and a nil checksum (the placeholder manifest)' do
285
- expect(described_class.obtainable?(env: {}, platform: 'darwin-arm64')).to be(false)
357
+ it 'is false with no override and a nil checksum' do
358
+ expect(described_class.obtainable?(env: {}, manifest: unpublished, platform: 'darwin-arm64')).to be(false)
286
359
  end
287
360
 
288
361
  it 'is false for an unknown platform or a non-executable override' do
@@ -44,12 +44,25 @@ RSpec.describe UtilityCommands do
44
44
 
45
45
  describe 'video install' do
46
46
  it 'exits 1 and explains when the build is not published yet' do
47
+ require 'ruby_raider/ffmpeg'
48
+ unpublished = { version: '7.1', assets: { RubyRaider::Ffmpeg.platform.to_s => { url: 'https://example.test/x', sha256: nil } } }
49
+ stub_const('RubyRaider::Ffmpeg::MANIFEST', unpublished)
47
50
  out, err, status = run_cli('video', 'install')
48
51
  expect(status).to eq(1)
49
52
  expect(err).to include('[video]', 'ffmpeg build not published yet')
50
53
  expect(out).to eq('')
51
54
  end
52
55
 
56
+ it 'exits 1 and installs nothing when the download does not match the pinned checksum', :aggregate_failures do
57
+ require 'ruby_raider/ffmpeg'
58
+ allow(RubyRaider::Ffmpeg).to receive(:http_get) { |_url, dest| File.write(dest, 'not ffmpeg') }
59
+ out, err, status = run_cli('video', 'install')
60
+ expect(status).to eq(1)
61
+ expect(err).to include('[video]', 'checksum mismatch')
62
+ expect(out).to eq('')
63
+ expect(Dir.glob(File.join(cache, '**', '*'))).to be_empty
64
+ end
65
+
53
66
  it 'prints the path and exits 0 when ensure! succeeds' do
54
67
  require 'ruby_raider/ffmpeg'
55
68
  allow(RubyRaider::Ffmpeg).to receive(:ensure!).and_return('/tmp/ffmpeg')