browsable 0.1.0 → 0.2.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "tmpdir"
6
+
7
+ module Browsable
8
+ # The end-of-suite reporting layer for runtime mode.
9
+ #
10
+ # TestReport reads the AuditLog, invokes the v0.1 analyzers **in batch** —
11
+ # one stylelint call, one eslint call regardless of suite size — then groups
12
+ # the resulting findings by endpoint and renders them. It is the only place
13
+ # in runtime mode where a subprocess is spawned.
14
+ #
15
+ # The per-endpoint policy is applied when grouping findings: a CSS feature
16
+ # that flagged the batch target is attributed to every endpoint that loaded
17
+ # the file. HTML findings, which carry exact required versions, can be
18
+ # re-evaluated against each endpoint's specific policy (TODO: v0.2.1).
19
+ class TestReport
20
+ # An analyzer outcome grouped by endpoint, used for rendering.
21
+ EndpointReport = Data.define(:endpoint, :policy, :findings)
22
+
23
+ attr_reader :audit_log, :config, :root
24
+
25
+ def initialize(audit_log: Browsable.audit_log, config: nil, root: nil)
26
+ @audit_log = audit_log
27
+ @root = root || (defined?(Rails) && Rails.application ? Rails.root.to_s : Dir.pwd)
28
+ @config = config || Config.load(root: @root)
29
+ @analyzed = false
30
+ end
31
+
32
+ # Findings (Array<Finding>) discovered across the whole suite.
33
+ def findings
34
+ analyze_if_needed
35
+ @findings
36
+ end
37
+
38
+ # Findings grouped per observed endpoint, sorted by endpoint name.
39
+ def endpoint_reports
40
+ analyze_if_needed
41
+ @endpoint_reports
42
+ end
43
+
44
+ # Asset paths that could not be resolved against the host app — surfaced as
45
+ # skipped entries so the user understands what the audit could not cover.
46
+ def skipped_assets
47
+ analyze_if_needed
48
+ @skipped_assets
49
+ end
50
+
51
+ def errors?
52
+ analyze_if_needed
53
+ @findings.any?(&:error?)
54
+ end
55
+
56
+ def warnings?
57
+ analyze_if_needed
58
+ @findings.any?(&:warning?)
59
+ end
60
+
61
+ # Build a Report whose shape v0.1's formatters can consume, so runtime
62
+ # output can use the same Human/Json/Github renderers as the CLI.
63
+ def to_report
64
+ analyze_if_needed
65
+ Report.new(
66
+ findings: @findings,
67
+ skips: @skips,
68
+ notes: @notes,
69
+ policies: PolicyScanner.call(root),
70
+ target: batch_target,
71
+ root: root,
72
+ config_file: config.config_file
73
+ )
74
+ end
75
+
76
+ # Render the report as a string in the requested format.
77
+ def render(format: :human)
78
+ formatter_for(format).new(to_report).render
79
+ end
80
+
81
+ def to_json(*_args)
82
+ JSON.pretty_generate(to_report.as_json.merge(
83
+ endpoints: endpoint_reports.map { |er| endpoint_as_json(er) },
84
+ skipped_assets: skipped_assets
85
+ ))
86
+ end
87
+
88
+ # Convenience: exit non-zero when any error finding exists. Drivers call
89
+ # this from their after(:suite) hook when configured to fail on errors.
90
+ def fail_suite_if_errors!(fail_on: :error)
91
+ should_fail =
92
+ case fail_on.to_s
93
+ when "warning" then errors? || warnings?
94
+ when "error" then errors?
95
+ else false
96
+ end
97
+
98
+ Kernel.exit(1) if should_fail
99
+ end
100
+
101
+ private
102
+
103
+ # ---- batch analysis ------------------------------------------------------
104
+
105
+ def analyze_if_needed
106
+ return if @analyzed
107
+
108
+ @analyzed = true
109
+ @findings = []
110
+ @skips = []
111
+ @notes = []
112
+ @skipped_assets = []
113
+ @endpoint_reports = []
114
+
115
+ return if audit_log.empty?
116
+
117
+ target = batch_target
118
+ run_css_batch(target)
119
+ run_js_batch(target)
120
+ run_html_per_entry
121
+
122
+ @endpoint_reports = group_by_endpoint
123
+ end
124
+
125
+ # The target the batch invocations are configured against: the most-strict
126
+ # union of every recorded policy, so the analyzer flags any feature that
127
+ # could matter for any endpoint. Per-endpoint precision is re-asserted
128
+ # later when severities are reassigned.
129
+ def batch_target
130
+ @batch_target ||= compute_batch_target
131
+ end
132
+
133
+ def compute_batch_target
134
+ targets = audit_log.entries.map { |entry| entry.policy.target }.uniq { |t| t.browsers }
135
+ return config.target if targets.empty?
136
+
137
+ # Minimum version per browser across all observed targets — i.e. the
138
+ # browser-support floor every endpoint shares.
139
+ union = {}
140
+ targets.each do |t|
141
+ t.browsers.each do |browser, version|
142
+ existing = union[browser]
143
+ union[browser] = version if existing.nil? || gem_version(version) < gem_version(existing)
144
+ end
145
+ end
146
+
147
+ Target.new("runtime-union", resolved: union)
148
+ end
149
+
150
+ def run_css_batch(target)
151
+ paths_on_disk = audit_log.asset_path_universe.select { |p| css?(p) && File.file?(p) }
152
+ tmp_files = write_inline_blocks(:css)
153
+ all_files = paths_on_disk.to_a + tmp_files
154
+
155
+ track_unresolved(:css)
156
+ return if all_files.empty?
157
+ return record_skip(:css, "stylelint not found — run `browsable doctor`") unless available?(:css)
158
+
159
+ analyzer = Analyzers::CSS.new(target: target, config: config)
160
+ collected = safe_analyze(:css, analyzer, all_files)
161
+ remap_tmp_findings(collected, tmp_files, :css)
162
+ @findings.concat(collected)
163
+ ensure
164
+ cleanup(tmp_files) if tmp_files
165
+ end
166
+
167
+ def run_js_batch(target)
168
+ paths_on_disk = audit_log.asset_path_universe.select { |p| js?(p) && File.file?(p) }
169
+ tmp_files = write_inline_blocks(:js)
170
+ all_files = paths_on_disk.to_a + tmp_files
171
+
172
+ track_unresolved(:js)
173
+ return if all_files.empty?
174
+ return record_skip(:js, "eslint not found — run `browsable doctor`") unless available?(:js)
175
+
176
+ analyzer = Analyzers::Javascript.new(target: target, config: config)
177
+ collected = safe_analyze(:js, analyzer, all_files)
178
+ remap_tmp_findings(collected, tmp_files, :js)
179
+ @findings.concat(collected)
180
+ ensure
181
+ cleanup(tmp_files) if tmp_files
182
+ end
183
+
184
+ def run_html_per_entry
185
+ audit_log.entries.each do |entry|
186
+ next if entry.html.empty?
187
+
188
+ analyzer = Analyzers::HTML.new(target: entry.policy.target, config: config)
189
+ results = analyzer.analyze_source(entry.html, file: synthetic_path_for(entry))
190
+ @findings.concat(results)
191
+ rescue StandardError => e
192
+ record_skip(:html, "HTML analysis failed for #{entry.endpoint}: #{e.message}")
193
+ end
194
+ end
195
+
196
+ # ---- attribution + grouping ---------------------------------------------
197
+
198
+ # Build per-endpoint groupings. For each endpoint, gather:
199
+ # - findings on HTML it produced (via synthetic path)
200
+ # - findings on assets it loaded (looked up via AuditLog#entries_loading)
201
+ def group_by_endpoint
202
+ by_endpoint = Hash.new { |h, k| h[k] = [] }
203
+ endpoint_policies = {}
204
+
205
+ audit_log.entries.each do |entry|
206
+ endpoint_policies[entry.endpoint] ||= entry.policy
207
+ end
208
+
209
+ @findings.each do |finding|
210
+ owners = endpoints_for(finding)
211
+ owners.each do |endpoint|
212
+ by_endpoint[endpoint] << finding
213
+ endpoint_policies[endpoint] ||= policy_for(endpoint)
214
+ end
215
+ end
216
+
217
+ by_endpoint.sort.map do |endpoint, list|
218
+ EndpointReport.new(
219
+ endpoint: endpoint,
220
+ policy: endpoint_policies[endpoint],
221
+ findings: list.uniq
222
+ )
223
+ end
224
+ end
225
+
226
+ def endpoints_for(finding)
227
+ file = finding.file
228
+ synthetic = synthetic_prefix
229
+ return [file.sub(synthetic, "")] if file.start_with?(synthetic)
230
+
231
+ audit_log.entries_loading(file).map(&:endpoint).uniq
232
+ end
233
+
234
+ def policy_for(endpoint)
235
+ entry = audit_log.entries.find { |e| e.endpoint == endpoint }
236
+ entry&.policy
237
+ end
238
+
239
+ # ---- helpers ------------------------------------------------------------
240
+
241
+ def write_inline_blocks(kind)
242
+ blocks = audit_log.entries.flat_map(&:inline_blocks).select { |b| b.kind == kind }
243
+ return [] if blocks.empty?
244
+
245
+ dir = (@inline_dir ||= Dir.mktmpdir("browsable-inline"))
246
+ ext = kind == :css ? "css" : "js"
247
+ blocks.uniq(&:content).each_with_index.map do |block, idx|
248
+ path = File.join(dir, "inline-#{idx}.#{ext}")
249
+ File.write(path, block.content)
250
+ path
251
+ end
252
+ end
253
+
254
+ def cleanup(paths)
255
+ paths.each { |p| File.delete(p) if File.file?(p) }
256
+ rescue StandardError
257
+ nil
258
+ end
259
+
260
+ # Rewrite a finding's file from the tmpdir path to something users will
261
+ # recognize ("(inline <style> block)"), so reports never leak temp paths.
262
+ def remap_tmp_findings(findings, tmp_files, kind)
263
+ tmp_set = tmp_files.to_set
264
+ label = kind == :css ? "(inline <style> block)" : "(inline <script> block)"
265
+ findings.map! do |f|
266
+ next f unless tmp_set.include?(f.file)
267
+
268
+ Finding.new(**f.to_h.merge(file: label))
269
+ end
270
+ end
271
+
272
+ def track_unresolved(kind)
273
+ audit_log.entries.each do |entry|
274
+ entry.asset_paths.each do |ref|
275
+ next unless ref.kind == kind
276
+ next unless ref.resolved_path.nil?
277
+
278
+ @skipped_assets << { url: ref.url, kind: ref.kind.to_s, endpoint: entry.endpoint }
279
+ end
280
+ end
281
+ end
282
+
283
+ def safe_analyze(kind, analyzer, files)
284
+ analyzer.analyze(files)
285
+ rescue StandardError => e
286
+ record_skip(kind, "#{kind} analysis failed: #{e.message}")
287
+ []
288
+ end
289
+
290
+ def record_skip(kind, reason)
291
+ @skips << Report::Skip.new(kind: kind, reason: reason)
292
+ []
293
+ end
294
+
295
+ def available?(kind)
296
+ Doctor.new.available_kinds.include?(kind)
297
+ end
298
+
299
+ def css?(path) = %w[.css .scss].include?(File.extname(path).downcase)
300
+ def js?(path) = %w[.js .mjs].include?(File.extname(path).downcase)
301
+
302
+ SYNTHETIC_PREFIX = "[response] "
303
+
304
+ def synthetic_prefix = SYNTHETIC_PREFIX
305
+ def synthetic_path_for(entry) = "#{SYNTHETIC_PREFIX}#{entry.endpoint}"
306
+
307
+ def endpoint_as_json(report)
308
+ {
309
+ endpoint: report.endpoint,
310
+ policy: report.policy&.as_json,
311
+ findings: report.findings.map(&:as_json)
312
+ }
313
+ end
314
+
315
+ def formatter_for(format)
316
+ case format.to_sym
317
+ when :json then Formatters::Json
318
+ when :github then Formatters::Github
319
+ else Formatters::Human
320
+ end
321
+ end
322
+
323
+ def gem_version(value)
324
+ Gem::Version.new(value.to_s)
325
+ rescue ArgumentError
326
+ Gem::Version.new("0")
327
+ end
328
+ end
329
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browsable
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0.pre.1"
5
5
  end
data/lib/browsable.rb CHANGED
@@ -30,15 +30,19 @@ end
30
30
 
31
31
  Browsable.loader = Zeitwerk::Loader.for_gem
32
32
  Browsable.loader.inflector.inflect(
33
- "cli" => "CLI",
34
- "css" => "CSS",
35
- "erb" => "ERB",
36
- "html" => "HTML"
33
+ "cli" => "CLI",
34
+ "css" => "CSS",
35
+ "erb" => "ERB",
36
+ "html" => "HTML",
37
+ "rspec" => "RSpec"
37
38
  )
38
39
  # These files intentionally do not define a constant matching their path.
39
40
  Browsable.loader.ignore("#{__dir__}/browsable/version.rb")
40
41
  Browsable.loader.ignore("#{__dir__}/browsable/rake_tasks.rb")
41
42
  Browsable.loader.ignore("#{__dir__}/browsable/railtie.rb")
43
+ # Driver entry points: they require the gem and call into Drivers::X.install!.
44
+ Browsable.loader.ignore("#{__dir__}/browsable/rspec.rb")
45
+ Browsable.loader.ignore("#{__dir__}/browsable/minitest.rb")
42
46
  # Rails generators are discovered and loaded by Rails itself, not Zeitwerk.
43
47
  Browsable.loader.ignore("#{__dir__}/generators")
44
48
  Browsable.loader.setup
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browsable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Hood
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.2'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: herb
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +37,20 @@ dependencies:
23
37
  - - ">="
24
38
  - !ruby/object:Gem::Version
25
39
  version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: nokogiri
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.13'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.13'
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: pastel
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +79,20 @@ dependencies:
51
79
  - - ">="
52
80
  - !ruby/object:Gem::Version
53
81
  version: '1.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rack
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '2.2'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '2.2'
54
96
  - !ruby/object:Gem::Dependency
55
97
  name: thor
56
98
  requirement: !ruby/object:Gem::Requirement
@@ -121,6 +163,7 @@ extra_rdoc_files: []
121
163
  files:
122
164
  - CHANGELOG.md
123
165
  - README.md
166
+ - bin/benchmark-asset-resolution
124
167
  - bin/update-bcd-snapshot
125
168
  - data/bcd-snapshot.json
126
169
  - exe/browsable
@@ -130,18 +173,29 @@ files:
130
173
  - lib/browsable/analyzers/erb.rb
131
174
  - lib/browsable/analyzers/html.rb
132
175
  - lib/browsable/analyzers/javascript.rb
176
+ - lib/browsable/asset_resolver.rb
177
+ - lib/browsable/audit_log.rb
133
178
  - lib/browsable/cli.rb
134
179
  - lib/browsable/config.rb
135
180
  - lib/browsable/doctor.rb
181
+ - lib/browsable/drivers/minitest.rb
182
+ - lib/browsable/drivers/rspec.rb
136
183
  - lib/browsable/finding.rb
137
184
  - lib/browsable/formatters/github.rb
138
185
  - lib/browsable/formatters/human.rb
139
186
  - lib/browsable/formatters/json.rb
187
+ - lib/browsable/html_extractor.rb
188
+ - lib/browsable/middleware.rb
189
+ - lib/browsable/minitest.rb
190
+ - lib/browsable/policy.rb
140
191
  - lib/browsable/policy_detector.rb
192
+ - lib/browsable/policy_resolver.rb
141
193
  - lib/browsable/policy_scanner.rb
142
194
  - lib/browsable/railtie.rb
143
195
  - lib/browsable/rake_tasks.rb
196
+ - lib/browsable/replay.rb
144
197
  - lib/browsable/report.rb
198
+ - lib/browsable/rspec.rb
145
199
  - lib/browsable/sources/base.rb
146
200
  - lib/browsable/sources/builds.rb
147
201
  - lib/browsable/sources/importmap.rb
@@ -150,6 +204,7 @@ files:
150
204
  - lib/browsable/sources/stylesheets.rb
151
205
  - lib/browsable/sources/views.rb
152
206
  - lib/browsable/target.rb
207
+ - lib/browsable/test_report.rb
153
208
  - lib/browsable/version.rb
154
209
  - lib/generators/browsable/install/install_generator.rb
155
210
  - lib/generators/browsable/install/templates/browsable.yml.tt
@@ -179,7 +234,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
179
234
  - !ruby/object:Gem::Version
180
235
  version: '0'
181
236
  requirements: []
182
- rubygems_version: 4.0.10
237
+ rubygems_version: 4.0.0
183
238
  specification_version: 4
184
239
  summary: Rails-aware browser-compatibility audit for your frontend code.
185
240
  test_files: []