ui_guardrails 1.5.0 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b29232255e89ade665ffe0aa02661183fbf71c5918c53704d8241293be757454
4
- data.tar.gz: 5d3cf78b2091bb6e781f6bd563976c9bc1c583a8269309ce897ed90d8eae3b3b
3
+ metadata.gz: a0bae61006af0fcdb91d543ab37539ed5f01bc653faa3a1732b56ee4f450d075
4
+ data.tar.gz: ee1b11b2668ee764377741beeac44b50e9507029c1469b5db866f1aa1e61ac93
5
5
  SHA512:
6
- metadata.gz: 99aa335b527770be65aa661262461ec9b9cc0e1a9f1f2b70153b804b584c5f3de497c0e38f18183d6eac1977d343506d99f42685a49c3bbe3e953781ba3cd176
7
- data.tar.gz: 894f7504d9e0f559b79051645f7f2c29de05215de57b2f4e04f78f9eb0bb4145568d13bd593123be401a15f626b9c3872174467dd78920a4ce3f265709bb3712
6
+ metadata.gz: fe558c786b8286a5f5766c265e1b40b581ef139d05a4b989744ee6d98e1b2dc5d885fc9c1470853d393b23d9c10d99be70c5a1cd7127da6afab653aa0bee21ee
7
+ data.tar.gz: 89651a0654dcd990829f37e402c9a7b8d214573acf655c54c7460a3708cf7d6d1c8e4f34bdae064fb8b676faa0d02d96fa624d94c8bec492cedc86e967973e86
data/doc/ROADMAP.md CHANGED
@@ -132,7 +132,8 @@ The planned roadmap ended at V2. Running the gem on real codebases (Patchvault:
132
132
  | Emoji + glyph icon detection | ✅ shipped (1.5.0) | `Guardrails::Icons::EmojiScan` catches emoji pictographs and Unicode-dingbat glyphs used as UI iconography — grapheme-cluster iteration (VS16 / ZWJ / flag / keycap count as one), range-first single-codepoint classification, per-file-type comment masking (Prism for Ruby, `<%# %>` for ERB, `//` `/* */` for JS/SCSS, `#` for YAML), `guardrails-ok: emoji` inline marker (first inline-escape convention in the gem), `allow_files` + `allow_chars` config, `SUGGEST=1` sprite-name hints. `guardrails:icons` also flipped to blocking — exit 1 on inline SVGs, unknown sprite refs, or emoji icons. |
133
133
  | Lookbook panel on the new data layer | not started | The auto-registered panel still renders from its own `ComponentReport`; it could render `Report::Finding`s and pick up suggestions for free. Icons findings could also flow through — the panel doesn't list icon findings today. |
134
134
  | Icons on the new data layer | not started | Icons stays outside `Report::Run` — its rake task calls `Icons.new(root:).run` directly. Migrating gets it into the TUI + HTML report for free but changes the rake shape; hold until there's demand. |
135
- | CI test workflow | not started | Specs only run inside the release workflow, on one Ruby. A PR-time matrix (3.4 + head) would catch floor regressions before tag time. |
135
+ | Visual-diff adapter registry | ✅ shipped (1.6.0) | `Guardrails::VisualDiff.register_adapter` lets other gems add adapters; the name is checked when the adapter is built. `VisualDiff::Finding` gains `status`, `comment`, `regions`, `extra_details`. First consumer: ui_guardrails-browser's `:browser` adapter. |
136
+ | CI test workflow | ✅ shipped | `.github/workflows/test.yml` runs `bundle exec rspec` (including the `examples/demo` integration spec) and `gem build` on every pull request and push to `main`, on Ruby 3.4 (the floor) and 4.0. Floor regressions and packaging mistakes surface on the PR instead of at tag time. |
136
137
 
137
138
  ---
138
139
 
data/doc/VISUAL-DIFF.md CHANGED
@@ -61,6 +61,22 @@ With `enabled = true`, `rake guardrails:audit` always runs the visual-diff check
61
61
 
62
62
  Env always overrides Configuration.
63
63
 
64
+ ## Adapters from other gems
65
+
66
+ `Guardrails.configuration.visual_diff.adapter` names a registered adapter. snap_diff is built in; other gems register theirs:
67
+
68
+ ```ruby
69
+ Guardrails::VisualDiff.register_adapter(:browser) do |root:, config:|
70
+ MyAdapter.new(root: root) # #collect returns Guardrails::VisualDiff::Finding objects
71
+ end
72
+ ```
73
+
74
+ `config` is `Guardrails.configuration.visual_diff`. The name is checked when the adapter is built, so a host initializer can set `adapter = :browser` before the gem that registers it loads. An unregistered name fails with the registered ones listed.
75
+
76
+ Besides the fields below, adapter findings can set `status` (`"changed"`, `"new"`, ...), `comment`, `regions` (`[{ x:, y:, w:, h: }]`) and `extra_details` (`[[label, value], ...]`). They show up as report details, and a finding with a `status` but no ratio is titled `[changed] scenario`. As with snap_diff, a finding with `mismatch_ratio: nil` always fails.
77
+
78
+ [ui_guardrails-browser](https://github.com/meticulous/guardrails-browser) registers `:browser`, which reports unresolved items from its own capture and review loop.
79
+
64
80
  ## How the snap_diff adapter works
65
81
 
66
82
  The snap_diff-capybara gem commits baseline screenshots to git under `doc/screenshots/<name>.png` and, on test failure, writes a sibling `<name>.diff.png` (changed pixels in red) plus a `<name>.heatmap.diff.png` (variance density). Tests fail on baseline mismatches in CI, so the diff files are an artifact of "this regression slipped through to the artifact directory."
@@ -44,8 +44,9 @@ module Guardrails
44
44
  DEFAULT_ADAPTER = :snap_diff
45
45
  DEFAULT_SNAP_DIFF_DIR = "doc/screenshots"
46
46
  DEFAULT_THRESHOLD = 0.0 # any non-zero mismatch fails
47
- KNOWN_ADAPTERS = %i[snap_diff backstop].freeze
48
47
 
48
+ # `backstop_json` is reserved for the BackstopJS adapter and unused
49
+ # until it lands; kept so initializers that already set it still load.
49
50
  attr_accessor :enabled, :adapter, :snap_diff_dir, :backstop_json, :threshold
50
51
 
51
52
  def initialize
@@ -56,16 +57,14 @@ module Guardrails
56
57
  @threshold = DEFAULT_THRESHOLD
57
58
  end
58
59
 
60
+ # Whether the name is a registered adapter is checked when the
61
+ # adapter is built (VisualDiff#adapter), not here: an adapter from
62
+ # another gem may register after this initializer runs.
59
63
  def adapter=(value)
60
64
  raise ArgumentError, "visual_diff.adapter cannot be nil" if value.nil?
61
65
  raise ArgumentError, "visual_diff.adapter cannot be blank" if value.respond_to?(:strip) && value.strip.empty?
62
66
 
63
- sym = value.to_sym
64
- unless KNOWN_ADAPTERS.include?(sym)
65
- raise ArgumentError,
66
- "Unknown visual_diff adapter #{value.inspect}; expected one of #{KNOWN_ADAPTERS.inspect}"
67
- end
68
- @adapter = sym
67
+ @adapter = value.to_sym
69
68
  end
70
69
 
71
70
  def threshold=(value)
@@ -43,9 +43,19 @@ module Guardrails
43
43
  {
44
44
  category: category, severity: severity, title: title, suggestion: suggestion,
45
45
  locations: locations.map(&:to_h), snippet: snippet,
46
- details: details.to_h, auto_fix: auto_fix || false
46
+ details: details_hash, auto_fix: auto_fix || false
47
47
  }
48
48
  end
49
+
50
+ # `details` allows a label more than once (two screenshots, several
51
+ # regions). A plain `to_h` kept only the last value; a repeated
52
+ # label collects its values into an array instead. Labels that
53
+ # appear once stay scalars, so existing JSON doesn't change.
54
+ def details_hash
55
+ details.each_with_object({}) do |(label, value), hash|
56
+ hash[label] = hash.key?(label) ? Array(hash[label]) << value : value
57
+ end
58
+ end
49
59
  end
50
60
 
51
61
  # A named group of findings plus the framing paragraph that
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Guardrails
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
@@ -14,7 +14,9 @@ module Guardrails
14
14
  #
15
15
  # Adapter selection comes from `Guardrails.configuration.visual_diff.adapter`
16
16
  # (default `:snap_diff`, the Rails-native baselines-in-git workflow).
17
- # BackstopJS support tracked in issue #15.
17
+ # Other gems add adapters with `VisualDiff.register_adapter`; the
18
+ # ui_guardrails-browser gem registers `:browser`. BackstopJS support
19
+ # tracked in issue #15.
18
20
  class VisualDiff
19
21
  # Normalized across every adapter. Fields adapter-specific shapes
20
22
  # don't supply are nil — consumers should not assume presence.
@@ -30,6 +32,10 @@ module Guardrails
30
32
  :diff_path, # relative path to the diff image, nil when no diff exists
31
33
  :url, # optional — set by adapters whose scenarios carry URLs (BackstopJS)
32
34
  :selector, # optional — same
35
+ :status, # optional — adapter's review state, e.g. "changed", "new", "rejected"
36
+ :comment, # optional — a reviewer's note on the change
37
+ :regions, # optional — changed areas, [{ x:, y:, w:, h: }, ...]
38
+ :extra_details, # optional — more [label, value] pairs for the report
33
39
  keyword_init: true
34
40
  ) do
35
41
  def to_h
@@ -37,6 +43,34 @@ module Guardrails
37
43
  end
38
44
  end
39
45
 
46
+ @adapters = {}
47
+
48
+ class << self
49
+ # Adds an adapter under `name`. The block builds it:
50
+ #
51
+ # Guardrails::VisualDiff.register_adapter(:browser) do |root:, config:|
52
+ # MyAdapter.new(root: root)
53
+ # end
54
+ #
55
+ # `config` is `Guardrails.configuration.visual_diff`. The adapter's
56
+ # `#collect` returns `VisualDiff::Finding`s; a finding with
57
+ # `mismatch_ratio: nil` always fails, one with a ratio fails above
58
+ # the threshold.
59
+ def register_adapter(name, &factory)
60
+ raise ArgumentError, "register_adapter needs a block that builds the adapter" unless factory
61
+
62
+ @adapters[name.to_sym] = factory
63
+ end
64
+
65
+ def adapters
66
+ @adapters.keys
67
+ end
68
+
69
+ def adapter_factory(name)
70
+ @adapters[name]
71
+ end
72
+ end
73
+
40
74
  def initialize(root:, output: $stdout,
41
75
  adapter: nil, threshold: nil, style: nil)
42
76
  @root = Pathname(root)
@@ -82,8 +116,7 @@ module Guardrails
82
116
  title: "#{ratio_label(f)} #{f.scenario}#{f.viewport ? " (#{f.viewport})" : ""}",
83
117
  suggestion: SUGGESTION,
84
118
  locations: [f.diff_path, f.baseline_path].compact.map { |path| Report::Location.new(file: path) },
85
- details: [["baseline", f.baseline_path], ["diff", f.diff_path], ["url", f.url], ["selector", f.selector]]
86
- .reject { |_, v| v.nil? }
119
+ details: details_for(f)
87
120
  )
88
121
  }
89
122
  )]
@@ -100,19 +133,27 @@ module Guardrails
100
133
  SUGGESTION = "compare baseline ↔ diff; accept the new baseline or fix the regression"
101
134
 
102
135
  def ratio_label(finding)
103
- finding.mismatch_ratio.nil? ? "[diff present]" : "[#{(finding.mismatch_ratio * 100).round(2)}% mismatch]"
136
+ return "[#{(finding.mismatch_ratio * 100).round(2)}% mismatch]" unless finding.mismatch_ratio.nil?
137
+
138
+ finding.status ? "[#{finding.status}]" : "[diff present]"
139
+ end
140
+
141
+ def details_for(finding)
142
+ regions = finding.regions&.map { |r| "#{r[:x] || r["x"]},#{r[:y] || r["y"]} #{r[:w] || r["w"]}x#{r[:h] || r["h"]}" }
143
+ [["baseline", finding.baseline_path], ["current", finding.current_path], ["diff", finding.diff_path],
144
+ ["url", finding.url], ["selector", finding.selector], ["status", finding.status],
145
+ ["comment", finding.comment], ["regions", regions&.join("; ")]]
146
+ .reject { |_, v| v.nil? || v == "" } + Array(finding.extra_details)
104
147
  end
105
148
 
106
149
  def adapter
107
- case @adapter_name
108
- when :snap_diff
109
- SnapDiff.new(
110
- root: @root,
111
- dir: Guardrails.configuration.visual_diff.snap_diff_dir
112
- )
113
- else
114
- raise ArgumentError, "Unknown visual_diff adapter: #{@adapter_name.inspect}"
150
+ factory = self.class.adapter_factory(@adapter_name)
151
+ unless factory
152
+ raise ArgumentError,
153
+ "Unknown visual_diff adapter: #{@adapter_name.inspect}; registered: #{self.class.adapters.inspect}"
115
154
  end
155
+
156
+ factory.call(root: @root, config: Guardrails.configuration.visual_diff)
116
157
  end
117
158
 
118
159
  def coerce_adapter(value)
@@ -149,9 +190,14 @@ module Guardrails
149
190
  @output.puts " #{@style.location("diff: #{f.diff_path}")}" if f.diff_path
150
191
  @output.puts " #{@style.location("url: #{f.url}")}" if f.url
151
192
  @output.puts " #{@style.location("selector: #{f.selector}")}" if f.selector
193
+ @output.puts " #{@style.location("comment: #{f.comment}")}" if f.comment
152
194
  end
153
195
  end
154
196
  end
155
197
  end
156
198
 
157
199
  require_relative "visual_diff/snap_diff"
200
+
201
+ Guardrails::VisualDiff.register_adapter(:snap_diff) do |root:, config:|
202
+ Guardrails::VisualDiff::SnapDiff.new(root: root, dir: config.snap_diff_dir)
203
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ui_guardrails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Athayde