capybara-storyboard 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 +266 -0
- data/Rakefile +12 -0
- data/docs/github-actions.md +95 -0
- data/lib/capybara/storyboard/configuration.rb +78 -0
- data/lib/capybara/storyboard/context.rb +13 -0
- data/lib/capybara/storyboard/page_stability.rb +141 -0
- data/lib/capybara/storyboard/policies/env_policy.rb +16 -0
- data/lib/capybara/storyboard/policies/target_list_policy.rb +30 -0
- data/lib/capybara/storyboard/session.rb +149 -0
- data/lib/capybara/storyboard/test_helper.rb +94 -0
- data/lib/capybara/storyboard/version.rb +7 -0
- data/lib/capybara/storyboard.rb +206 -0
- data/sig/capybara/storyboard.rbs +6 -0
- data/skills/capybara-storyboard/SKILL.md +96 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0f2357cb5edac81b907bc4196d8469d85c5de0636932fa2dbe25d2985edaa6a9
|
|
4
|
+
data.tar.gz: 2c4dffec621810ef339178aafacefffaefec409c3f6b72ef7c4944d1b770e7c3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 57a736051c0a33c64227ae7f576d1c5b4739e8c3e1a9c5e969be1534abf1b1072463845a92bbff69b1396df8dbc0a5fa3fd4211c9412ed69133ca2d81a3cfe95
|
|
7
|
+
data.tar.gz: c366ae904aada06f091ded0cb7697754506f40a5ceac87f6383a1a83cf0bda2f619e3a4901afc9c53690a399f5ace1a1064fcb6e7bc0b3e006b725e78e770f43
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"capybara-storyboard" 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,266 @@
|
|
|
1
|
+
# Capybara::Storyboard
|
|
2
|
+
|
|
3
|
+
Capybara::Storyboard records the Capybara operations performed during your RSpec system
|
|
4
|
+
tests — `visit`, `click_on`, and more — together with a screenshot taken at each step, and
|
|
5
|
+
organizes them into an ordered "storyboard" per test. This lets you review the flow of a
|
|
6
|
+
system test at a glance, without re-running it or reading through the spec line by line.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Ruby >= 3.4.0
|
|
11
|
+
- A Rails application with RSpec system specs. ActiveSupport is expected to already be
|
|
12
|
+
loaded (the gem relies on core extensions such as `present?`); it is not declared as a
|
|
13
|
+
gemspec dependency, since a Rails application loads it already.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle add capybara-storyboard
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gem install capybara-storyboard
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
Require the gem from `spec_helper.rb` or `rails_helper.rb`:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "capybara/storyboard"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then include `Capybara::Storyboard::TestHelper` in your system specs via `RSpec.configure`,
|
|
38
|
+
and register a `before(:suite)` hook that clears stale screenshots from previous runs (see
|
|
39
|
+
[Clearing previous screenshots](#clearing-previous-screenshots)):
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
RSpec.configure do |config|
|
|
43
|
+
config.include Capybara::Storyboard::TestHelper, type: :system
|
|
44
|
+
config.before(:suite) { Capybara::Storyboard.clear_output! }
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`TestHelper` overrides Capybara DSL methods (`visit`, `click_on`, etc.) and chains into them
|
|
49
|
+
via `super`, so it must be included **after** `Capybara::DSL` has been included. In a normal
|
|
50
|
+
RSpec system spec setup, Capybara is already configured for `type: :system` examples, so
|
|
51
|
+
including `TestHelper` for the same `type: :system` examples (as shown above) works without
|
|
52
|
+
any extra ordering concerns.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
Once `TestHelper` is included, the Capybara DSL calls in your system specs are automatically
|
|
57
|
+
hooked, and — when enabled (see [Enabling screenshots](#enabling-screenshots)) — a screenshot
|
|
58
|
+
is captured before and/or after each call.
|
|
59
|
+
|
|
60
|
+
The following DSL methods are hooked: `visit`, `click_on`, `click_link`, `click_button`,
|
|
61
|
+
`fill_in`, `select`, `check`, `uncheck`, `choose`, `attach_file`, `accept_confirm`,
|
|
62
|
+
`accept_alert`.
|
|
63
|
+
|
|
64
|
+
- The click methods (`click_on`, `click_link`, `click_button`) capture **two** screenshots:
|
|
65
|
+
one before and one after the operation.
|
|
66
|
+
- All other methods capture **one** screenshot, taken after the operation.
|
|
67
|
+
|
|
68
|
+
You can also take a screenshot manually at any point in a spec by calling
|
|
69
|
+
`storyboard_screenshot`:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
storyboard_screenshot("some label")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Manual screenshots taken via `storyboard_screenshot` obey the same enabling switch as the
|
|
76
|
+
automatic hooks: they are captured **only when the mechanism is enabled** (i.e. when the
|
|
77
|
+
policy — driven by `SCREENSHOTS` and the target list — evaluates to true), and are skipped
|
|
78
|
+
otherwise. If you need an unconditional screenshot regardless of that switch, use Capybara's
|
|
79
|
+
own `save_screenshot` instead.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
RSpec.describe "Login", type: :system do
|
|
85
|
+
it "logs in successfully" do
|
|
86
|
+
visit "/login"
|
|
87
|
+
fill_in "Email", with: "user@example.com"
|
|
88
|
+
fill_in "Password", with: "password"
|
|
89
|
+
click_on "Log in"
|
|
90
|
+
|
|
91
|
+
storyboard_screenshot("logged in")
|
|
92
|
+
|
|
93
|
+
expect(page).to have_content("Welcome")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Enabling screenshots
|
|
99
|
+
|
|
100
|
+
Whether screenshots are captured at all is controlled by two independent layers: an
|
|
101
|
+
enabling switch (`SCREENSHOTS`), and, within that, an optional target list that narrows
|
|
102
|
+
which test files are captured.
|
|
103
|
+
|
|
104
|
+
| Case | `SCREENSHOTS` | Target list | Behavior |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| Disabled | unset | — | No screenshots. Hooks have effectively zero overhead. |
|
|
107
|
+
| Capture all (default) | `1` | unset (both ENV vars unset) | Screenshots for all system tests. |
|
|
108
|
+
| Selective capture | `1` | set via `SCREENSHOT_TESTS_FILE` / `SCREENSHOT_TESTS` | Only the listed test files are captured. If the resulting set is empty, zero screenshots are taken (an empty selection does NOT fall back to capturing everything). |
|
|
109
|
+
|
|
110
|
+
In short: `SCREENSHOTS` arms the mechanism as a whole, and the target list — when present —
|
|
111
|
+
filters down which tests are captured within that armed mechanism.
|
|
112
|
+
|
|
113
|
+
## Selecting which tests to capture
|
|
114
|
+
|
|
115
|
+
When `SCREENSHOTS` is enabled, you can narrow capture to specific test files using either
|
|
116
|
+
(or both) of these environment variables:
|
|
117
|
+
|
|
118
|
+
| ENV var | Format | Purpose |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| `SCREENSHOT_TESTS_FILE` | Path to a file containing newline-separated test file paths | Primary channel. Suited for large lists / CI generation. |
|
|
121
|
+
| `SCREENSHOT_TESTS` | Comma-separated test file paths | Secondary. For a small number of manual paths. |
|
|
122
|
+
|
|
123
|
+
When both are set, the union of the two lists is used.
|
|
124
|
+
|
|
125
|
+
If `SCREENSHOT_TESTS_FILE` points to a file that does not exist, `Capybara::Storyboard::Error`
|
|
126
|
+
is raised. (This existence check only runs when `SCREENSHOTS` is enabled; when the mechanism
|
|
127
|
+
is disabled, the target list is never read.)
|
|
128
|
+
|
|
129
|
+
Examples:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Inline list of test files
|
|
133
|
+
SCREENSHOTS=1 SCREENSHOT_TESTS=spec/system/login_spec.rb,spec/system/signup_spec.rb bundle exec rspec
|
|
134
|
+
|
|
135
|
+
# List generated into a file (e.g. by CI, listing only changed specs)
|
|
136
|
+
SCREENSHOTS=1 SCREENSHOT_TESTS_FILE=tmp/screenshot_targets.txt bundle exec rspec
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Output layout
|
|
140
|
+
|
|
141
|
+
Screenshots are written under:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
tmp/screenshots/{spec_path}/{example_name}/{NNN_action_detail}.png
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`{spec_path}` mirrors the spec file's own path, with the leading `spec/` segment and the
|
|
148
|
+
`_spec.rb` suffix removed. For example, `spec/system/signup_spec.rb` becomes
|
|
149
|
+
`system/signup/...`, and a nested spec such as `spec/system/admin/users_spec.rb` becomes
|
|
150
|
+
`system/admin/users/...`.
|
|
151
|
+
|
|
152
|
+
Each test gets its own directory, and a zero-padded sequence number (`NNN`) preserves the
|
|
153
|
+
order in which actions occurred. For example:
|
|
154
|
+
|
|
155
|
+
- Click methods capture two files: `001_before_click_on_Done.png`, `002_after_click_on_Done.png`
|
|
156
|
+
- Other methods capture one file: `001_visit_users.png`
|
|
157
|
+
|
|
158
|
+
Non-ASCII descriptions and labels (e.g. Japanese) are preserved as-is in file and directory
|
|
159
|
+
names; only symbols and whitespace are replaced with underscores.
|
|
160
|
+
|
|
161
|
+
The default output root is `<Rails.root>/tmp/screenshots` (overridable, see
|
|
162
|
+
[Configuration](#configuration)).
|
|
163
|
+
|
|
164
|
+
### Clearing previous screenshots
|
|
165
|
+
|
|
166
|
+
To avoid a run's screenshots mixing with stale files left behind by a previous
|
|
167
|
+
run, register `Capybara::Storyboard.clear_output!` in a `before(:suite)` hook (as
|
|
168
|
+
shown in [Setup](#setup)):
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
RSpec.configure do |config|
|
|
172
|
+
config.before(:suite) { Capybara::Storyboard.clear_output! }
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
When screenshots are enabled (`SCREENSHOTS` is set), this empties the output root
|
|
177
|
+
once at the start of the rspec run.
|
|
178
|
+
|
|
179
|
+
- When `SCREENSHOTS` is unset, `clear_output!` leaves the output root untouched
|
|
180
|
+
(preserving the "disabled → nothing happens" contract), so the hook is safe to
|
|
181
|
+
register unconditionally.
|
|
182
|
+
- `clear_output!` clears at most once per process, so registering the hook is
|
|
183
|
+
idempotent even if it runs more than once.
|
|
184
|
+
- **With `parallel_tests`**: `before(:suite)` fires once per RSpec process, so
|
|
185
|
+
each worker empties the shared output root at startup — the root is not cleared
|
|
186
|
+
exactly once across all processes.
|
|
187
|
+
|
|
188
|
+
## Configuration
|
|
189
|
+
|
|
190
|
+
Use `Capybara::Storyboard.configure` to override the defaults:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
Capybara::Storyboard.configure do |config|
|
|
194
|
+
config.output_dir = Rails.root.join("tmp", "my_screenshots")
|
|
195
|
+
config.policy = ->(context) { ... } # or any object responding to #call(context) -> Boolean
|
|
196
|
+
|
|
197
|
+
config.page_stability_interval = 0.5
|
|
198
|
+
config.page_stability_max_attempts = 10
|
|
199
|
+
config.page_stability_excluded_animations = []
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `config.output_dir`: overrides the output root directory. Defaults to
|
|
204
|
+
`<Rails.root>/tmp/screenshots`.
|
|
205
|
+
- `config.policy`: overrides the policy that decides whether to capture. Must respond to
|
|
206
|
+
`#call(context) -> Boolean` (a proc works too). Defaults to a policy composed from
|
|
207
|
+
`SCREENSHOTS` and the target list, as described in
|
|
208
|
+
[Enabling screenshots](#enabling-screenshots).
|
|
209
|
+
|
|
210
|
+
If you don't set `config.policy` explicitly, the default policy described above (driven by
|
|
211
|
+
`SCREENSHOTS` and the target list) is used.
|
|
212
|
+
|
|
213
|
+
### Page-stability wait
|
|
214
|
+
|
|
215
|
+
Just before each screenshot is captured, the gem waits for the page to become visually
|
|
216
|
+
stable — no running CSS/JS animations (via `document.getAnimations()`) and no DOM mutations
|
|
217
|
+
for a short quiet window (tracked by a `MutationObserver`) — so screenshots don't catch the
|
|
218
|
+
page mid-transition. The wait never blocks the screenshot itself: on a non-JS driver (e.g.
|
|
219
|
+
`Rack::Test`), or if anything goes wrong while waiting, it's a safe no-op and the screenshot
|
|
220
|
+
is still taken. The wait is tunable via:
|
|
221
|
+
|
|
222
|
+
- `config.page_stability_interval`: seconds between polls, and the required DOM-quiet window.
|
|
223
|
+
Defaults to `0.5`.
|
|
224
|
+
- `config.page_stability_max_attempts`: maximum number of polls before giving up. On timeout
|
|
225
|
+
the gem does not raise; it prints a warning to STDERR and captures the screenshot anyway.
|
|
226
|
+
Defaults to `10`.
|
|
227
|
+
- `config.page_stability_excluded_animations`: an array of CSS animation names to ignore when
|
|
228
|
+
deciding whether animations are still running (e.g. perpetual spinners). Defaults to `[]`.
|
|
229
|
+
|
|
230
|
+
## Caveats and limitations
|
|
231
|
+
|
|
232
|
+
- Test files are expected to be laid out flat as `spec/system/*_spec.rb`. Nested
|
|
233
|
+
subdirectories may work but are not comprehensively verified in this initial version.
|
|
234
|
+
- The target-list selection matches on test file paths. A PR that changes only views
|
|
235
|
+
(leaving the system spec file itself unchanged) cannot be picked up by the target-list
|
|
236
|
+
approach — this is a known, deliberate limitation of the initial version.
|
|
237
|
+
|
|
238
|
+
## Guides
|
|
239
|
+
|
|
240
|
+
- [docs/github-actions.md](docs/github-actions.md) — a recipe for capturing screenshots on a
|
|
241
|
+
diff basis in GitHub Actions.
|
|
242
|
+
- [skills/capybara-storyboard](skills/capybara-storyboard) — an agent skill for visually
|
|
243
|
+
verifying captured screenshots (Claude Code, or any agent that can read PNG files from the
|
|
244
|
+
filesystem).
|
|
245
|
+
|
|
246
|
+
## Development
|
|
247
|
+
|
|
248
|
+
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.
|
|
249
|
+
|
|
250
|
+
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](https://rubygems.org).
|
|
251
|
+
|
|
252
|
+
## Contributing
|
|
253
|
+
|
|
254
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/aki77/capybara-storyboard. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/aki77/capybara-storyboard/blob/main/CODE_OF_CONDUCT.md).
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
259
|
+
|
|
260
|
+
## Code of Conduct
|
|
261
|
+
|
|
262
|
+
Everyone interacting in the Capybara::Storyboard project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/aki77/capybara-storyboard/blob/main/CODE_OF_CONDUCT.md).
|
|
263
|
+
|
|
264
|
+
## Acknowledgments
|
|
265
|
+
|
|
266
|
+
The idea for this gem was inspired by [Giving Claude Code Eyes: Round Trip Screenshot Testing](https://medium.com/@rotbart/giving-claude-code-eyes-round-trip-screenshot-testing-ce52f7dcc563).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# GitHub Actions recipe: capture screenshots on a diff basis
|
|
2
|
+
|
|
3
|
+
Capybara::Storyboard only receives a target set — it does not know how to compute one. This
|
|
4
|
+
recipe shows the CI side of that split: diff against the PR base branch, extract the changed
|
|
5
|
+
system spec files, write them to a file, and pass that file in via `SCREENSHOT_TESTS_FILE`.
|
|
6
|
+
|
|
7
|
+
For how enabling and target-list selection actually behave inside the gem, see the README's
|
|
8
|
+
[Enabling screenshots](../README.md#enabling-screenshots) and
|
|
9
|
+
[Selecting which tests to capture](../README.md#selecting-which-tests-to-capture) sections —
|
|
10
|
+
this document does not repeat that reference material.
|
|
11
|
+
|
|
12
|
+
## Workflow
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
name: Screenshot storyboard
|
|
16
|
+
|
|
17
|
+
on:
|
|
18
|
+
pull_request:
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
storyboard:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- name: Check out code
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
|
+
with:
|
|
27
|
+
fetch-depth: 0
|
|
28
|
+
|
|
29
|
+
- name: Fetch base branch
|
|
30
|
+
run: git fetch origin "${{ github.event.pull_request.base.ref }}"
|
|
31
|
+
|
|
32
|
+
- name: Set up Ruby
|
|
33
|
+
uses: ruby/setup-ruby@v1
|
|
34
|
+
with:
|
|
35
|
+
bundler-cache: true
|
|
36
|
+
|
|
37
|
+
- name: Extract changed system spec files
|
|
38
|
+
run: |
|
|
39
|
+
mkdir -p tmp
|
|
40
|
+
git diff --name-only --diff-filter=d \
|
|
41
|
+
"origin/${{ github.event.pull_request.base.ref }}...HEAD" \
|
|
42
|
+
| grep -E '^spec/system/.*_spec\.rb$' \
|
|
43
|
+
> tmp/screenshot_targets.txt || true
|
|
44
|
+
touch tmp/screenshot_targets.txt
|
|
45
|
+
|
|
46
|
+
- name: Run system specs
|
|
47
|
+
env:
|
|
48
|
+
SCREENSHOTS: "1"
|
|
49
|
+
SCREENSHOT_TESTS_FILE: tmp/screenshot_targets.txt
|
|
50
|
+
run: bundle exec rspec spec/system
|
|
51
|
+
|
|
52
|
+
- name: Upload screenshots
|
|
53
|
+
if: always()
|
|
54
|
+
uses: actions/upload-artifact@v4
|
|
55
|
+
with:
|
|
56
|
+
name: storyboard-screenshots
|
|
57
|
+
path: tmp/screenshots/**
|
|
58
|
+
if-no-files-found: ignore
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Notes on the steps above:
|
|
62
|
+
|
|
63
|
+
- `fetch-depth: 0` on checkout, plus explicitly fetching the base branch, ensures the
|
|
64
|
+
three-dot diff (`origin/<base>...HEAD`) has both sides of history available. A shallow
|
|
65
|
+
checkout without fetching the base ref will fail or produce an incomplete diff.
|
|
66
|
+
- The extraction step pipes through `grep -E` and always ends with `touch
|
|
67
|
+
tmp/screenshot_targets.txt`. `grep` exits non-zero when there are no matches, so `|| true`
|
|
68
|
+
keeps the step from failing, and the trailing `touch` guarantees the file exists — empty if
|
|
69
|
+
nothing matched. This is required: see the notes below on why the file must always be
|
|
70
|
+
created.
|
|
71
|
+
- The run step passes `SCREENSHOTS=1` and `SCREENSHOT_TESTS_FILE=tmp/screenshot_targets.txt`
|
|
72
|
+
as env for that step only, scoping the target list to this job.
|
|
73
|
+
- The upload step uses `if: always()` so screenshots are attached even if a spec fails
|
|
74
|
+
partway through, and `if-no-files-found: ignore` so an empty target list (and therefore no
|
|
75
|
+
screenshots) doesn't fail the upload step.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- **View-only PRs are not picked up.** This recipe selects targets by matching test file
|
|
80
|
+
paths in the diff. A PR that only changes views or other application code — leaving the
|
|
81
|
+
system spec file itself untouched — will not add anything to
|
|
82
|
+
`tmp/screenshot_targets.txt`, because the spec file never appears in the diff. This is a
|
|
83
|
+
known, deliberate limitation of the initial version; see the README's
|
|
84
|
+
[Caveats and limitations](../README.md#caveats-and-limitations).
|
|
85
|
+
- **An empty target file producing zero screenshots is correct, not a bug.** When no changed
|
|
86
|
+
file matches `spec/system/.*_spec\.rb`, `tmp/screenshot_targets.txt` is empty, the target
|
|
87
|
+
list narrows down to nothing, and zero screenshots are taken. That's the narrowing working
|
|
88
|
+
as intended — it is not the same as omitting `SCREENSHOT_TESTS_FILE` entirely, which instead
|
|
89
|
+
captures every system spec.
|
|
90
|
+
- **A missing file fails the job loudly.** If the extraction step were to fail in a way that
|
|
91
|
+
leaves `SCREENSHOT_TESTS_FILE` pointing at a path that doesn't exist, the run raises
|
|
92
|
+
`Capybara::Storyboard::Error` instead of silently capturing everything or nothing. This
|
|
93
|
+
detection only happens in a job that also passes `SCREENSHOTS=1` — a job without it never
|
|
94
|
+
reads the target list at all. This is exactly why the extraction step above always creates
|
|
95
|
+
the file (empty when there are no matches) rather than skipping the write on no matches.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Storyboard
|
|
7
|
+
# Holds the user-overridable settings: where screenshots are rooted and
|
|
8
|
+
# which policy decides whether to capture. Defaults are resolved lazily in
|
|
9
|
+
# the getters so that merely constructing (or loading the gem) never reads
|
|
10
|
+
# ENV or builds the default policy — that keeps the "disabled -> zero
|
|
11
|
+
# overhead" contract intact.
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_PAGE_STABILITY_INTERVAL = 0.5
|
|
14
|
+
DEFAULT_PAGE_STABILITY_MAX_ATTEMPTS = 10
|
|
15
|
+
DEFAULT_PAGE_STABILITY_EXCLUDED_ANIMATIONS = [].freeze
|
|
16
|
+
|
|
17
|
+
# An object responding to #call(context) -> Boolean. Assign nil (or call
|
|
18
|
+
# reset_policy!) to restore the default on the next read.
|
|
19
|
+
attr_writer :policy
|
|
20
|
+
|
|
21
|
+
# Assign nil to any of these to restore its default on the next read:
|
|
22
|
+
# page_stability_interval= seconds between polls (default 0.5)
|
|
23
|
+
# page_stability_max_attempts= poll count before warning (default 10)
|
|
24
|
+
# page_stability_excluded_animations= names to ignore (default [])
|
|
25
|
+
attr_writer :page_stability_interval, :page_stability_max_attempts, :page_stability_excluded_animations
|
|
26
|
+
|
|
27
|
+
# The output root under which Session appends <group>/<example>. Returns
|
|
28
|
+
# the default (<base>/tmp/screenshots) until overridden.
|
|
29
|
+
def output_dir
|
|
30
|
+
@output_dir || default_output_dir
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Accepts nil (restore default) or any value coercible to a Pathname.
|
|
34
|
+
# A non-nil value is stored as a Pathname because Session calls #join on
|
|
35
|
+
# it; a relative path is kept as-is (resolved against the cwd, which is
|
|
36
|
+
# Rails.root under normal use).
|
|
37
|
+
def output_dir=(value)
|
|
38
|
+
@output_dir = value.nil? ? nil : Pathname(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def policy
|
|
42
|
+
@policy ||= Capybara::Storyboard.default_policy
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Equivalent to `self.policy = nil`; named for readable `after` hooks that
|
|
46
|
+
# prevent a custom policy from leaking into later examples.
|
|
47
|
+
def reset_policy!
|
|
48
|
+
@policy = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Seconds to wait between page-stability polls, and the same value the
|
|
52
|
+
# DOM-quiet check must exceed. Returns the default (0.5) until overridden.
|
|
53
|
+
def page_stability_interval
|
|
54
|
+
@page_stability_interval || DEFAULT_PAGE_STABILITY_INTERVAL
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Maximum number of stability polls before giving up (and warning rather
|
|
58
|
+
# than raising). Returns the default (10) until overridden.
|
|
59
|
+
def page_stability_max_attempts
|
|
60
|
+
@page_stability_max_attempts || DEFAULT_PAGE_STABILITY_MAX_ATTEMPTS
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Animation names ignored by the running-animation check (e.g. infinite
|
|
64
|
+
# spinners). Returns the default (empty list) until overridden.
|
|
65
|
+
def page_stability_excluded_animations
|
|
66
|
+
@page_stability_excluded_animations || DEFAULT_PAGE_STABILITY_EXCLUDED_ANIMATIONS
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Screenshots live under <base>/tmp/screenshots, where the base directory
|
|
72
|
+
# (Rails.root or the cwd fallback) is resolved in one shared place.
|
|
73
|
+
def default_output_dir
|
|
74
|
+
Capybara::Storyboard.rails_root_or_pwd.join('tmp', 'screenshots')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Storyboard
|
|
5
|
+
# Value object passed to a policy's #call. Derivation from the RSpec example
|
|
6
|
+
# lives in TestHelper; this object only carries values.
|
|
7
|
+
#
|
|
8
|
+
# test_file is the raw RSpec file_path string; this object never normalizes
|
|
9
|
+
# it. Canonicalization to a base-relative path is done on demand by
|
|
10
|
+
# Capybara::Storyboard.normalize_test_path (used by TargetListPolicy).
|
|
11
|
+
Context = Data.define(:test_class_name, :test_method_name, :test_file)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Storyboard
|
|
7
|
+
# Waits for a page to become visually stable before a screenshot is taken:
|
|
8
|
+
# no running CSS/JS animations (via document.getAnimations()) AND no DOM
|
|
9
|
+
# mutations for at least +interval+ seconds (tracked by a MutationObserver).
|
|
10
|
+
#
|
|
11
|
+
# Ported from SonicGarden/wlb-morning-mail's capybara_screenshot_helper.rb.
|
|
12
|
+
#
|
|
13
|
+
# Stateless, so exposed as module functions. On a non-JS driver (Rack::Test)
|
|
14
|
+
# or when the driver rejects the script, the whole thing is a safe no-op: a
|
|
15
|
+
# failed wait must never prevent the screenshot itself from being taken.
|
|
16
|
+
module PageStability
|
|
17
|
+
# Reports the running-animation count and time since the last DOM mutation.
|
|
18
|
+
# Static (no interpolation), so built once and reused on every poll.
|
|
19
|
+
CHECK_SCRIPT = <<~JS
|
|
20
|
+
(function() {
|
|
21
|
+
const timeSinceLastMutation = Date.now() - window._lastMutationTime;
|
|
22
|
+
const excludedAnimations = window._excludedAnimations ?? [];
|
|
23
|
+
const animations = document.getAnimations();
|
|
24
|
+
const runningAnimations = animations.filter(animation => {
|
|
25
|
+
// getAnimations() keeps finished/paused animations attached to the
|
|
26
|
+
// element until they are cancelled or the node is removed, so only
|
|
27
|
+
// count the ones actually playing.
|
|
28
|
+
if (animation.playState !== 'running') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (animation instanceof CSSAnimation) {
|
|
32
|
+
return !excludedAnimations.includes(animation.animationName);
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}).length;
|
|
36
|
+
return { timeSinceLastMutation: timeSinceLastMutation, runningAnimations: runningAnimations };
|
|
37
|
+
})();
|
|
38
|
+
JS
|
|
39
|
+
|
|
40
|
+
# Tears down the observer and its globals. Static, so built once and reused.
|
|
41
|
+
CLEANUP_SCRIPT = <<~JS
|
|
42
|
+
if (window._pageStabilityObserver) {
|
|
43
|
+
window._pageStabilityObserver.disconnect();
|
|
44
|
+
delete window._pageStabilityObserver;
|
|
45
|
+
delete window._lastMutationTime;
|
|
46
|
+
delete window._excludedAnimations;
|
|
47
|
+
}
|
|
48
|
+
JS
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# Polls +page+ until stable or +max_attempts+ is reached. When the limit
|
|
53
|
+
# is hit the page is deemed "good enough": a warning is printed to STDERR
|
|
54
|
+
# and control returns normally (no exception), so capture proceeds.
|
|
55
|
+
def wait_for_stable_page(page, interval:, max_attempts:, excluded_animations:)
|
|
56
|
+
setup(page, excluded_animations)
|
|
57
|
+
|
|
58
|
+
result = nil
|
|
59
|
+
stable = false
|
|
60
|
+
max_attempts.times do |attempt|
|
|
61
|
+
result = check(page)
|
|
62
|
+
stable = stable?(result, interval)
|
|
63
|
+
break if stable
|
|
64
|
+
|
|
65
|
+
# No point sleeping after the final check — nothing re-checks it.
|
|
66
|
+
sleep(interval) unless attempt == max_attempts - 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Unstable (or max_attempts was 0): the page is deemed good enough.
|
|
70
|
+
warn_unstable(result, interval) unless stable
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
# A failed wait must never prevent the screenshot from being taken, so
|
|
73
|
+
# swallow every error and let capture proceed. Expected "this driver
|
|
74
|
+
# can't run JS" errors (e.g. Rack::Test) are silent; anything else is
|
|
75
|
+
# surfaced via warn so a real driver problem stays visible.
|
|
76
|
+
warn("capybara-storyboard: page stability wait skipped after error: #{e.class}: #{e.message}") unless
|
|
77
|
+
non_js_driver_error?(e)
|
|
78
|
+
nil
|
|
79
|
+
ensure
|
|
80
|
+
cleanup(page)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def setup(page, excluded_animations)
|
|
84
|
+
page.execute_script(<<~JS)
|
|
85
|
+
window._lastMutationTime = Date.now();
|
|
86
|
+
window._excludedAnimations = #{excluded_animations.to_json};
|
|
87
|
+
window._pageStabilityObserver = new MutationObserver(() => {
|
|
88
|
+
window._lastMutationTime = Date.now();
|
|
89
|
+
});
|
|
90
|
+
window._pageStabilityObserver.observe(document.body, {
|
|
91
|
+
childList: true, subtree: true, attributes: true, characterData: true
|
|
92
|
+
});
|
|
93
|
+
JS
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check(page)
|
|
97
|
+
page.evaluate_script(CHECK_SCRIPT)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Best-effort teardown so no globals leak between screenshots. Runs from an
|
|
101
|
+
# ensure block, so it must never raise even when setup never happened or
|
|
102
|
+
# the driver can't run JS.
|
|
103
|
+
def cleanup(page)
|
|
104
|
+
page.execute_script(CLEANUP_SCRIPT)
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# evaluate_script returns string-keyed hashes on the real drivers; the
|
|
110
|
+
# DOM-quiet window is measured in ms, so compare against interval * 1000.
|
|
111
|
+
def stable?(result, interval)
|
|
112
|
+
result['runningAnimations'].zero? && result['timeSinceLastMutation'] >= (interval * 1000)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Warns using the last observed poll result (nil when max_attempts is 0),
|
|
116
|
+
# so no extra JS round-trip is made and the reported numbers match the
|
|
117
|
+
# values that actually failed the stability check.
|
|
118
|
+
def warn_unstable(result, interval)
|
|
119
|
+
warn(
|
|
120
|
+
'capybara-storyboard: page did not become stable before the screenshot ' \
|
|
121
|
+
"(runningAnimations=#{result && result['runningAnimations']}, " \
|
|
122
|
+
"timeSinceLastMutation=#{result && result['timeSinceLastMutation']}ms, threshold=#{interval * 1000}ms)."
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# True when +error+ means "this driver can't run our JS" — the expected,
|
|
127
|
+
# non-alarming case (e.g. Rack::Test) that should skip silently rather
|
|
128
|
+
# than warn. Capybara is optional at load time (the gem's own specs don't
|
|
129
|
+
# require it) and Selenium may be absent, so resolve each constant only
|
|
130
|
+
# when defined; an undefined constant simply never matches.
|
|
131
|
+
def non_js_driver_error?(error)
|
|
132
|
+
return true if defined?(Capybara::NotSupportedByDriverError) &&
|
|
133
|
+
error.is_a?(Capybara::NotSupportedByDriverError)
|
|
134
|
+
return true if defined?(Selenium::WebDriver::Error::JavascriptError) &&
|
|
135
|
+
error.is_a?(Selenium::WebDriver::Error::JavascriptError)
|
|
136
|
+
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Storyboard
|
|
5
|
+
module Policies
|
|
6
|
+
# Arms the whole mechanism from ENV. Backward-compatible with the gist:
|
|
7
|
+
# true iff SCREENSHOTS is set to a non-blank value. Ignores the context
|
|
8
|
+
# entirely — the argument exists only to honor the call(context) contract.
|
|
9
|
+
class EnvPolicy
|
|
10
|
+
def call(_context)
|
|
11
|
+
ENV['SCREENSHOTS'].present?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|