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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Storyboard
|
|
5
|
+
module Policies
|
|
6
|
+
# Restricts screenshots to an explicit set of test files. The set holds
|
|
7
|
+
# already-normalized paths (see Capybara::Storyboard.normalize_test_path);
|
|
8
|
+
# #call normalizes the context's raw test_file the same way before matching.
|
|
9
|
+
#
|
|
10
|
+
# An empty set means "explicitly no targets" and always returns false. The
|
|
11
|
+
# backward-compatible "capture everything" behavior is handled upstream by
|
|
12
|
+
# default_policy, which simply never constructs this policy when no target
|
|
13
|
+
# list is configured.
|
|
14
|
+
class TargetListPolicy
|
|
15
|
+
def initialize(paths)
|
|
16
|
+
@paths = Set.new(paths)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(context)
|
|
20
|
+
return false if @paths.empty?
|
|
21
|
+
|
|
22
|
+
test_file = context&.test_file
|
|
23
|
+
return false if test_file.nil?
|
|
24
|
+
|
|
25
|
+
@paths.include?(Capybara::Storyboard.normalize_test_path(test_file))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module Capybara
|
|
7
|
+
module Storyboard
|
|
8
|
+
# Per-example state (sequence counter, output directory, enabled flag,
|
|
9
|
+
# example metadata) plus filename / path / sanitize derivation.
|
|
10
|
+
#
|
|
11
|
+
# Extracting this state out of TestHelper keeps its unit tests independent
|
|
12
|
+
# of Capybara and RSpec hooks: a Session can be built with a plain example
|
|
13
|
+
# double and an injected +output_root+.
|
|
14
|
+
class Session
|
|
15
|
+
def initialize(example:, enabled:, output_root: nil)
|
|
16
|
+
@example = example
|
|
17
|
+
@enabled = enabled
|
|
18
|
+
@output_root = output_root
|
|
19
|
+
@index = 0
|
|
20
|
+
@dir = nil
|
|
21
|
+
@suppression_depth = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled?
|
|
25
|
+
@enabled
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Automatic screenshot for a DSL action. No-op unless enabled.
|
|
29
|
+
def auto(page, action, detail = nil)
|
|
30
|
+
return unless @enabled
|
|
31
|
+
return if suppressed?
|
|
32
|
+
|
|
33
|
+
label = [action, sanitize(detail)].compact_blank.join('_')
|
|
34
|
+
capture_with_label(page, label)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Manual screenshot hook. Like #auto, captured only when enabled. For an
|
|
38
|
+
# unconditional screenshot, use Capybara's own save_screenshot. The page
|
|
39
|
+
# is passed explicitly rather than held as state.
|
|
40
|
+
def manual(page, label)
|
|
41
|
+
return unless @enabled
|
|
42
|
+
return if suppressed?
|
|
43
|
+
|
|
44
|
+
capture_with_label(page, sanitize(label))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Suppresses automatic/manual captures for the duration of the block. Used
|
|
48
|
+
# to skip nested captures while a confirm/alert dialog is open, where a
|
|
49
|
+
# screenshot or JS eval would raise UnexpectedAlertOpenError. A depth counter
|
|
50
|
+
# (not a boolean) supports nesting; the ensure guarantees reset on exceptions.
|
|
51
|
+
def suppress_captures
|
|
52
|
+
@suppression_depth += 1
|
|
53
|
+
yield
|
|
54
|
+
ensure
|
|
55
|
+
@suppression_depth -= 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def suppressed?
|
|
59
|
+
@suppression_depth.positive?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def capture_with_label(page, label)
|
|
65
|
+
ensure_dir!
|
|
66
|
+
@index += 1
|
|
67
|
+
filename = "#{format('%03d', @index)}_#{label}.png"
|
|
68
|
+
wait_for_stable_page(page)
|
|
69
|
+
save_screenshot_safely(page, @dir.join(filename))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A screenshot is a side effect; an unexpected failure (e.g. a dialog left
|
|
73
|
+
# open) must never break the test body. Mirrors PageStability's warn
|
|
74
|
+
# convention. There is no expected non-JS-driver case here, so warn always.
|
|
75
|
+
# (Named "safely" rather than +save_screenshot+ to avoid colliding with
|
|
76
|
+
# Capybara's DSL debugger method, which RuboCop's Lint/Debugger flags.)
|
|
77
|
+
def save_screenshot_safely(page, path)
|
|
78
|
+
page.save_screenshot(path)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
warn("capybara-storyboard: screenshot skipped after error: #{e.class}: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Both callers (#auto / #manual) are already enabled-gated, so this always
|
|
84
|
+
# runs before an actual capture and never on a disabled session.
|
|
85
|
+
def wait_for_stable_page(page)
|
|
86
|
+
config = Capybara::Storyboard.configuration
|
|
87
|
+
PageStability.wait_for_stable_page(
|
|
88
|
+
page,
|
|
89
|
+
interval: config.page_stability_interval,
|
|
90
|
+
max_attempts: config.page_stability_max_attempts,
|
|
91
|
+
excluded_animations: config.page_stability_excluded_animations
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ensure_dir!
|
|
96
|
+
@dir ||= output_dir
|
|
97
|
+
FileUtils.mkdir_p(@dir)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def output_dir
|
|
101
|
+
(@output_root || default_output_root).join(spec_relative_dir, example_name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The output root when none is injected: the configuration's output_dir
|
|
105
|
+
# (default <base>/tmp/screenshots, overridable via Storyboard.configure).
|
|
106
|
+
def default_output_root
|
|
107
|
+
Capybara::Storyboard.configuration.output_dir
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Mirrors the spec file's location as the output directory tree, so
|
|
111
|
+
# screenshots for spec/system/foo_spec.rb land under system/foo/. The
|
|
112
|
+
# spec/ prefix and the _spec.rb (or .rb) suffix are dropped; each
|
|
113
|
+
# remaining path segment is sanitized while the / separators are kept.
|
|
114
|
+
def spec_relative_dir
|
|
115
|
+
raw = @example.metadata[:file_path]
|
|
116
|
+
return 'spec' if raw.blank?
|
|
117
|
+
|
|
118
|
+
relative = Capybara::Storyboard.normalize_test_path(raw)
|
|
119
|
+
segments = relative.split('/')
|
|
120
|
+
segments.shift if segments.first == 'spec'
|
|
121
|
+
segments[-1] = strip_spec_suffix(segments.last) if segments.any?
|
|
122
|
+
|
|
123
|
+
sanitized = segments.map { |segment| sanitize(segment) }.reject(&:blank?)
|
|
124
|
+
sanitized.empty? ? 'spec' : sanitized.join('/')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Drops the trailing _spec.rb, falling back to a plain .rb, so a spec
|
|
128
|
+
# basename becomes its bare name (hoge_spec.rb -> hoge, hoge.rb -> hoge).
|
|
129
|
+
def strip_spec_suffix(basename)
|
|
130
|
+
basename.sub(/_spec\.rb\z/, '').sub(/\.rb\z/, '')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def example_name
|
|
134
|
+
name = sanitize(@example.description).truncate(80, omission: '')
|
|
135
|
+
return name if name.present?
|
|
136
|
+
|
|
137
|
+
fallback = sanitize(@example.full_description)
|
|
138
|
+
fallback.present? ? fallback.truncate(80, omission: '') : 'example'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# \p{Word} (Unicode word characters) rather than \w (ASCII-only) so that
|
|
142
|
+
# non-ASCII descriptions/labels (e.g. Japanese) are preserved in filenames
|
|
143
|
+
# and directory names instead of collapsing to underscores.
|
|
144
|
+
def sanitize(text)
|
|
145
|
+
text.to_s.gsub(/[^\p{Word}-]/, '_').gsub(/_+/, '_').gsub(/\A_|_\z/, '')
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Capybara
|
|
6
|
+
module Storyboard
|
|
7
|
+
# RSpec system-spec helper. Include AFTER Capybara::DSL so the overrides
|
|
8
|
+
# below chain into the DSL via +super+. A per-test policy call decides
|
|
9
|
+
# whether automatic screenshots are taken (see Capybara::Storyboard.policy).
|
|
10
|
+
module TestHelper
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.class_eval { before { __storyboard_init } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Manual screenshot hook. Captured only when enabled (like the automatic
|
|
16
|
+
# hooks); use Capybara's own save_screenshot for an unconditional shot.
|
|
17
|
+
def storyboard_screenshot(label)
|
|
18
|
+
@__storyboard.manual(page, label)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def visit(path, ...)
|
|
22
|
+
super.tap { @__storyboard.auto(page, 'visit', path) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def click_on(locator = nil, ...)
|
|
26
|
+
@__storyboard.auto(page, 'before_click_on', locator)
|
|
27
|
+
super.tap { @__storyboard.auto(page, 'after_click_on', locator) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def click_link(locator = nil, ...)
|
|
31
|
+
@__storyboard.auto(page, 'before_click_link', locator)
|
|
32
|
+
super.tap { @__storyboard.auto(page, 'after_click_link', locator) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def click_button(locator = nil, ...)
|
|
36
|
+
@__storyboard.auto(page, 'before_click_button', locator)
|
|
37
|
+
super.tap { @__storyboard.auto(page, 'after_click_button', locator) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fill_in(locator, ...)
|
|
41
|
+
super.tap { @__storyboard.auto(page, 'fill_in', locator) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def select(value = nil, ...)
|
|
45
|
+
super.tap { @__storyboard.auto(page, 'select', value) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check(locator, ...)
|
|
49
|
+
super.tap { @__storyboard.auto(page, 'check', locator) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def uncheck(locator, ...)
|
|
53
|
+
super.tap { @__storyboard.auto(page, 'uncheck', locator) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def choose(locator, ...)
|
|
57
|
+
super.tap { @__storyboard.auto(page, 'choose', locator) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def attach_file(locator, ...)
|
|
61
|
+
super.tap { @__storyboard.auto(page, 'attach_file', locator) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def accept_confirm(...)
|
|
65
|
+
@__storyboard.suppress_captures { super }.tap { @__storyboard.auto(page, 'accept_confirm') }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def accept_alert(...)
|
|
69
|
+
@__storyboard.suppress_captures { super }.tap { @__storyboard.auto(page, 'accept_alert') }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def __storyboard_init
|
|
75
|
+
example = RSpec.current_example
|
|
76
|
+
@__storyboard = Capybara::Storyboard::Session.new(
|
|
77
|
+
example:,
|
|
78
|
+
enabled: Capybara::Storyboard.policy.call(__storyboard_context(example))
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Derivation lives here so Context stays a plain value holder.
|
|
83
|
+
def __storyboard_context(example)
|
|
84
|
+
metadata = example.metadata
|
|
85
|
+
described = metadata[:described_class]
|
|
86
|
+
Capybara::Storyboard::Context.new(
|
|
87
|
+
test_class_name: described.respond_to?(:name) ? described.name : nil,
|
|
88
|
+
test_method_name: example.description,
|
|
89
|
+
test_file: metadata[:file_path]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
require_relative 'storyboard/version'
|
|
7
|
+
|
|
8
|
+
module Capybara
|
|
9
|
+
# Automatic Capybara screenshots for RSpec system specs, gated by a
|
|
10
|
+
# replaceable policy (see Capybara::Storyboard.policy).
|
|
11
|
+
module Storyboard
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# The single Configuration holding user overrides (output root, policy).
|
|
16
|
+
def configuration
|
|
17
|
+
@configuration ||= Configuration.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Yields the Configuration for block-style setup:
|
|
21
|
+
# Capybara::Storyboard.configure { |config| config.output_dir = ... }
|
|
22
|
+
def configure
|
|
23
|
+
yield(configuration)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Drops every override (output_dir and policy) in one shot, restoring the
|
|
27
|
+
# gem's defaults. Handy in `after` hooks so a customized run never leaks
|
|
28
|
+
# into later examples.
|
|
29
|
+
def reset_configuration!
|
|
30
|
+
@configuration = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Test-hygiene helper mirroring reset_configuration! / reset_policy!:
|
|
34
|
+
# clears the run-once flag so clear_output! can be exercised again
|
|
35
|
+
# within the same process (a real run never needs this).
|
|
36
|
+
def reset_output_cleared!
|
|
37
|
+
@output_cleared = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Empties the output root once per process so a run's screenshots never mix
|
|
41
|
+
# with stale files left by a previous run. Meant to be wired into a
|
|
42
|
+
# before(:suite) hook by the host app (see the README); not called
|
|
43
|
+
# automatically, so nothing is registered on RSpec just by requiring the gem.
|
|
44
|
+
def clear_output!
|
|
45
|
+
# Run-once guard: idempotent even if the before(:suite) hook fires more
|
|
46
|
+
# than once (e.g. one hook per RSpec process under parallel_tests).
|
|
47
|
+
return if @output_cleared
|
|
48
|
+
|
|
49
|
+
# Arm gate: same bare-SCREENSHOTS probe as default_policy's own gate
|
|
50
|
+
# (EnvPolicy ignores its argument, so call(nil) is context-free). We
|
|
51
|
+
# deliberately do NOT use configuration.policy here: a custom policy
|
|
52
|
+
# assumes a per-example Context and may read the target list (raising
|
|
53
|
+
# when SCREENSHOT_TESTS_FILE is bad). A suite-wide clear must key off
|
|
54
|
+
# the global arm state alone, never per-example data.
|
|
55
|
+
return unless Policies::EnvPolicy.new.call(nil)
|
|
56
|
+
|
|
57
|
+
# Honor the run-once contract BEFORE touching the filesystem: if the
|
|
58
|
+
# delete fails, we must not keep retrying (and re-deleting) on later
|
|
59
|
+
# registrations of the same hook.
|
|
60
|
+
@output_cleared = true
|
|
61
|
+
|
|
62
|
+
# Resolve the root against the shared base so a relative output_dir
|
|
63
|
+
# (kept relative by Configuration) maps to a single absolute target.
|
|
64
|
+
root = configuration.output_dir.expand_path(rails_root_or_pwd)
|
|
65
|
+
return unless safe_to_clear?(root)
|
|
66
|
+
# First run / nothing captured yet: silently skip. Session#ensure_dir!
|
|
67
|
+
# lazily recreates the tree on the first capture, so a missing root is
|
|
68
|
+
# not an error.
|
|
69
|
+
return unless root.exist?
|
|
70
|
+
|
|
71
|
+
FileUtils.rm_rf(root)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# An object responding to #call(context) -> Boolean.
|
|
75
|
+
# Assign nil (or call reset_policy!) to restore the default.
|
|
76
|
+
# Delegated to the Configuration so there is a single source of truth.
|
|
77
|
+
def policy
|
|
78
|
+
configuration.policy
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def policy=(value)
|
|
82
|
+
configuration.policy = value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Equivalent to `self.policy = nil`; named for readable `after` hooks that
|
|
86
|
+
# prevent a custom policy from leaking into later examples.
|
|
87
|
+
def reset_policy!
|
|
88
|
+
configuration.reset_policy!
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Normalizes a test file path to a base-relative string so that target
|
|
92
|
+
# list entries and Context#test_file compare equal regardless of leading
|
|
93
|
+
# `./`, absolute vs relative form, or surrounding whitespace/newlines.
|
|
94
|
+
# Shared by default_policy (list side) and TargetListPolicy#call
|
|
95
|
+
# (context side) so both sides always agree on the canonical form.
|
|
96
|
+
def normalize_test_path(path)
|
|
97
|
+
base = rails_root_or_pwd
|
|
98
|
+
Pathname(path.to_s.strip)
|
|
99
|
+
.expand_path(base)
|
|
100
|
+
.relative_path_from(base)
|
|
101
|
+
.to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The single place that resolves the base directory: Rails.root when
|
|
105
|
+
# available (the gem stays Rails-optional), else the current directory.
|
|
106
|
+
# Session#default_output_root joins its own subpath onto this.
|
|
107
|
+
def rails_root_or_pwd
|
|
108
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
109
|
+
Rails.root
|
|
110
|
+
else
|
|
111
|
+
Pathname(Dir.pwd)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The single place that builds the default policy. Internal API, public
|
|
116
|
+
# only so Configuration#policy can call it without `send`; use `policy`.
|
|
117
|
+
#
|
|
118
|
+
# When SCREENSHOTS is unset the mechanism is disarmed: return EnvPolicy
|
|
119
|
+
# alone WITHOUT touching the target list. This honors the "disabled ->
|
|
120
|
+
# nothing happens" contract even when SCREENSHOT_TESTS_FILE points at a
|
|
121
|
+
# missing file (its existence check must not blow up a disarmed suite).
|
|
122
|
+
#
|
|
123
|
+
# When armed: no target list configured (neither ENV set) -> EnvPolicy
|
|
124
|
+
# alone, preserving the gist's "SCREENSHOTS=1 captures everything". A
|
|
125
|
+
# target list configured (even an empty file) -> EnvPolicy AND
|
|
126
|
+
# TargetListPolicy; an empty set then means "explicitly zero targets".
|
|
127
|
+
def default_policy
|
|
128
|
+
env = Policies::EnvPolicy.new
|
|
129
|
+
# env.call(nil) probes the bare SCREENSHOTS switch (same gate
|
|
130
|
+
# clear_output! uses), inlined here so we can reuse this exact `env`
|
|
131
|
+
# instance as the return value below instead of building a second one.
|
|
132
|
+
# Disarmed -> skip reading/validating the target list entirely.
|
|
133
|
+
return env unless env.call(nil)
|
|
134
|
+
|
|
135
|
+
raw_targets = raw_target_list
|
|
136
|
+
return env if raw_targets.nil?
|
|
137
|
+
|
|
138
|
+
# Drop blank entries BEFORE normalizing: an empty string normalizes to
|
|
139
|
+
# "." (the base dir), which is not blank and would leak into the set.
|
|
140
|
+
targets = raw_targets.map { |path| path.to_s.strip }.compact_blank.map { |path| normalize_test_path(path) }
|
|
141
|
+
target = Policies::TargetListPolicy.new(targets)
|
|
142
|
+
|
|
143
|
+
# Composition style: a plain lambda. A proc responds to #call, so it
|
|
144
|
+
# satisfies the #call(context) -> Boolean policy contract, and it can
|
|
145
|
+
# still be replaced wholesale via #policy=.
|
|
146
|
+
->(context) { env.call(context) && target.call(context) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Minimal foot-gun guard for the suite-wide clear: refuse the obviously
|
|
152
|
+
# dangerous roots (a filesystem root, or the project/cwd root itself)
|
|
153
|
+
# rather than rm_rf-ing them. Mirrors the "safe no-op + warn" convention
|
|
154
|
+
# used elsewhere (see Session#save_screenshot_safely); we deliberately
|
|
155
|
+
# stop at these two cases rather than attempting broad path sanitizing.
|
|
156
|
+
def safe_to_clear?(root)
|
|
157
|
+
if root.parent == root || root == rails_root_or_pwd
|
|
158
|
+
warn("capybara-storyboard: refusing to clear unsafe output root: #{root}")
|
|
159
|
+
return false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Returns the raw (un-normalized) target list, or nil when no target list
|
|
166
|
+
# is configured at all. SCREENSHOT_TESTS_FILE and SCREENSHOT_TESTS are
|
|
167
|
+
# unioned when both are present.
|
|
168
|
+
def raw_target_list
|
|
169
|
+
file_entries = raw_target_list_from_file
|
|
170
|
+
inline_entries = raw_target_list_inline
|
|
171
|
+
return nil if file_entries.nil? && inline_entries.nil?
|
|
172
|
+
|
|
173
|
+
(file_entries || []) + (inline_entries || [])
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Reads SCREENSHOT_TESTS_FILE. Returns nil when unset/blank, raises when
|
|
177
|
+
# the path is set but missing (a silent all/none capture would be worse
|
|
178
|
+
# than a loud failure), otherwise the newline-split contents.
|
|
179
|
+
def raw_target_list_from_file
|
|
180
|
+
path = ENV.fetch('SCREENSHOT_TESTS_FILE', nil)
|
|
181
|
+
return nil unless path.present?
|
|
182
|
+
|
|
183
|
+
raise Error, "SCREENSHOT_TESTS_FILE does not exist: #{path}" unless File.exist?(path)
|
|
184
|
+
|
|
185
|
+
File.read(path).split("\n")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Reads SCREENSHOT_TESTS. Returns nil when unset/blank, otherwise the
|
|
189
|
+
# comma-split entries.
|
|
190
|
+
def raw_target_list_inline
|
|
191
|
+
value = ENV.fetch('SCREENSHOT_TESTS', nil)
|
|
192
|
+
return nil unless value.present?
|
|
193
|
+
|
|
194
|
+
value.split(',')
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
require_relative 'storyboard/context'
|
|
201
|
+
require_relative 'storyboard/policies/env_policy'
|
|
202
|
+
require_relative 'storyboard/policies/target_list_policy'
|
|
203
|
+
require_relative 'storyboard/configuration'
|
|
204
|
+
require_relative 'storyboard/page_stability'
|
|
205
|
+
require_relative 'storyboard/session'
|
|
206
|
+
require_relative 'storyboard/test_helper'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: capybara-storyboard
|
|
3
|
+
description: Visually verify UI changes by running system specs with capybara-storyboard and reading the captured screenshot sequence. Use whenever app/views or app/components changes need a visual check, or when a system spec is changed and run.
|
|
4
|
+
model: sonnet
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Capybara Storyboard visual review
|
|
8
|
+
|
|
9
|
+
Capybara::Storyboard captures a Capybara operation (`visit`, `click_on`, ...) together with a
|
|
10
|
+
screenshot at each step of a system spec, and lays the screenshots out as an ordered sequence
|
|
11
|
+
per test. This skill drives an agent through generating and reading that sequence to catch
|
|
12
|
+
things a passing test can't: layout breakage, leftover UI state (a modal that didn't close, a
|
|
13
|
+
flash message still showing), error screens, or anything else that looks wrong for the action
|
|
14
|
+
and point in the test where it occurs.
|
|
15
|
+
|
|
16
|
+
Reviewing screenshots one image at a time only works if the set stays small — narrow to the
|
|
17
|
+
system specs actually touched by the change, not the whole suite. Capturing every system spec
|
|
18
|
+
would produce hundreds of images, which defeats the purpose.
|
|
19
|
+
|
|
20
|
+
## When this applies
|
|
21
|
+
|
|
22
|
+
- `app/views` or `app/components` were changed and the resulting screen needs a visual check.
|
|
23
|
+
- A system spec was changed and is being run.
|
|
24
|
+
|
|
25
|
+
## Steps
|
|
26
|
+
|
|
27
|
+
### 1. Determine the target specs
|
|
28
|
+
|
|
29
|
+
Narrow to the system specs relevant to the change:
|
|
30
|
+
|
|
31
|
+
- If `app/views` or `app/components` changed, identify which system specs exercise the changed
|
|
32
|
+
screen(s) — search for the view/component name, the controller action, or the route it
|
|
33
|
+
belongs to. Use this bullet whenever the call sites are countable: if you can enumerate all
|
|
34
|
+
the places the changed view/component is used and there are roughly 10 or fewer, target those
|
|
35
|
+
specific call sites directly — even if the changed file is itself called a shared "component".
|
|
36
|
+
If more than one spec is a plausible match and it isn't obvious which apply, ask the user.
|
|
37
|
+
- If enumerating every call site is impractical, or the changed file is a layout or
|
|
38
|
+
partial/component rendered by most/all pages rather than a handful of named places (so the
|
|
39
|
+
search above would return most or all system specs, not a short list), don't try to enumerate
|
|
40
|
+
every caller. Instead propose a small representative sample (roughly 3-6 specs) spanning
|
|
41
|
+
distinct usage contexts where the change is most likely to visually clash — e.g. a signed-out
|
|
42
|
+
page, a standard authenticated page, an admin/dense-UI page, a mobile/narrow-viewport spec if
|
|
43
|
+
one exists — and confirm the sample with the user before running, since you can't be sure
|
|
44
|
+
those spec files exist or still match without checking.
|
|
45
|
+
- If a system spec itself was changed, that spec is the target.
|
|
46
|
+
|
|
47
|
+
### 2. Run the specs and capture screenshots
|
|
48
|
+
|
|
49
|
+
Run the target specs locally with only `SCREENSHOTS=1` set, passing the target spec files
|
|
50
|
+
directly as `rspec` arguments so only the relevant tests produce screenshots:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
SCREENSHOTS=1 bundle exec rspec spec/system/login_spec.rb spec/system/signup_spec.rb
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Running `bundle exec rspec` with `SCREENSHOTS=1` and no spec path arguments captures **every**
|
|
57
|
+
system spec — avoid this unless the user explicitly wants a full-suite review, since it
|
|
58
|
+
produces an impractically large set of images to read one at a time.
|
|
59
|
+
|
|
60
|
+
### 3. Enumerate the output
|
|
61
|
+
|
|
62
|
+
Screenshots land at:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
tmp/screenshots/{GroupName}/{example_name}/{NNN_action_detail}.png
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Each `{GroupName}/{example_name}` directory is one system spec example — treat it as one unit
|
|
69
|
+
of review. Within a directory, list PNGs in ascending `NNN` order: that zero-padded prefix is
|
|
70
|
+
the order the actions happened during the test, and the rest of the filename names the action
|
|
71
|
+
(e.g. `001_visit_users.png`, `002_before_click_on_Done.png`,
|
|
72
|
+
`003_after_click_on_Done.png`). Click methods (`click_on`, `click_link`, `click_button`)
|
|
73
|
+
produce a before/after pair; everything else produces one screenshot taken after the action.
|
|
74
|
+
|
|
75
|
+
### 4. Read each image in sequence
|
|
76
|
+
|
|
77
|
+
For each `{GroupName}/{example_name}` directory, read its PNGs in `NNN` order, one at a time.
|
|
78
|
+
For each image, use the filename (the action) and its position in the sequence (what happened
|
|
79
|
+
right before it) to judge whether the screen looks right at that point — check for layout
|
|
80
|
+
breakage, unintended or leftover UI state, error pages, and anything else inconsistent with
|
|
81
|
+
the action just performed.
|
|
82
|
+
|
|
83
|
+
### 5. Report per test
|
|
84
|
+
|
|
85
|
+
For each `{GroupName}/{example_name}` directory report:
|
|
86
|
+
|
|
87
|
+
- Which test it is.
|
|
88
|
+
- For any issue found: which action/sequence number (by filename) it appeared right after,
|
|
89
|
+
and what was observed.
|
|
90
|
+
|
|
91
|
+
A directory with no issues just needs a short confirmation — don't pad the report.
|
|
92
|
+
|
|
93
|
+
### 6. Clean up
|
|
94
|
+
|
|
95
|
+
Remove the local `tmp/screenshots/**` output once the review is done, unless the user wants it
|
|
96
|
+
kept for later reference.
|
metadata
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: capybara-storyboard
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- aki
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: capybara
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: Capybara::Storyboard records Capybara system test operations and their
|
|
27
|
+
screenshots, then visualizes them as a storyboard so you can review test flows at
|
|
28
|
+
a glance.
|
|
29
|
+
email:
|
|
30
|
+
- lala.akira@gmail.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- CHANGELOG.md
|
|
36
|
+
- CODE_OF_CONDUCT.md
|
|
37
|
+
- LICENSE.txt
|
|
38
|
+
- README.md
|
|
39
|
+
- Rakefile
|
|
40
|
+
- docs/github-actions.md
|
|
41
|
+
- lib/capybara/storyboard.rb
|
|
42
|
+
- lib/capybara/storyboard/configuration.rb
|
|
43
|
+
- lib/capybara/storyboard/context.rb
|
|
44
|
+
- lib/capybara/storyboard/page_stability.rb
|
|
45
|
+
- lib/capybara/storyboard/policies/env_policy.rb
|
|
46
|
+
- lib/capybara/storyboard/policies/target_list_policy.rb
|
|
47
|
+
- lib/capybara/storyboard/session.rb
|
|
48
|
+
- lib/capybara/storyboard/test_helper.rb
|
|
49
|
+
- lib/capybara/storyboard/version.rb
|
|
50
|
+
- sig/capybara/storyboard.rbs
|
|
51
|
+
- skills/capybara-storyboard/SKILL.md
|
|
52
|
+
homepage: https://github.com/aki77/capybara-storyboard
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
homepage_uri: https://github.com/aki77/capybara-storyboard
|
|
57
|
+
source_code_uri: https://github.com/aki77/capybara-storyboard/tree/main
|
|
58
|
+
changelog_uri: https://github.com/aki77/capybara-storyboard/blob/main/CHANGELOG.md
|
|
59
|
+
rubygems_mfa_required: 'true'
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 3.4.0
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 4.0.15
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: Capture and visualize Capybara system test flows as screenshot storyboards.
|
|
77
|
+
test_files: []
|