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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-07-21
4
+
5
+ - Initial release
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,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