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,91 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Measures Browsable::AssetResolver's success rate against a corpus of fixture
5
+ # HTML files. The runtime-mode v0.2 success bar is ≥95% — this script is the
6
+ # pre-merge guard against regressions in the resolver's heuristics.
7
+ #
8
+ # Usage:
9
+ # bin/benchmark-asset-resolution [CORPUS_DIR]
10
+ #
11
+ # CORPUS_DIR defaults to ./benchmark/corpus. Each subdirectory of the corpus
12
+ # is treated as a synthetic Rails app: it should contain an `app/` (or
13
+ # `public/`) tree mirroring an app's layout, plus a `pages/` directory of
14
+ # representative rendered HTML responses.
15
+
16
+ require_relative "../lib/browsable"
17
+
18
+ DEFAULT_CORPUS = File.expand_path("../benchmark/corpus", __dir__)
19
+
20
+ corpus = ARGV[0] || DEFAULT_CORPUS
21
+
22
+ unless File.directory?(corpus)
23
+ warn "No corpus at #{corpus}. Create one under benchmark/corpus/<app-name>."
24
+ warn "Each subdirectory should look like a Rails app with a pages/ folder of HTML samples."
25
+ exit 1
26
+ end
27
+
28
+ results = { total: 0, resolved: 0, by_strategy: Hash.new(0), unresolved: [] }
29
+
30
+ Dir.children(corpus).sort.each do |app_dir|
31
+ app_root = File.join(corpus, app_dir)
32
+ next unless File.directory?(app_root)
33
+
34
+ resolver = Browsable::AssetResolver.new(root: app_root)
35
+ pages = Dir.glob(File.join(app_root, "pages", "**/*.html"))
36
+
37
+ pages.each do |page|
38
+ html = File.read(page)
39
+ extraction = Browsable::HtmlExtractor.extract(html)
40
+
41
+ extraction.asset_paths.each do |ref|
42
+ results[:total] += 1
43
+ detailed = resolver.detailed_resolve(ref.url)
44
+ if detailed.resolved?
45
+ results[:resolved] += 1
46
+ results[:by_strategy][detailed.strategy] += 1
47
+ else
48
+ results[:by_strategy][detailed.strategy] += 1
49
+ results[:unresolved] << { app: app_dir, page: File.basename(page), url: ref.url }
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ if results[:total].zero?
56
+ warn "No asset references found in any page of #{corpus}."
57
+ exit 1
58
+ end
59
+
60
+ ratio = results[:resolved].fdiv(results[:total])
61
+ percent = (ratio * 100).round(2)
62
+
63
+ puts "Browsable asset resolver — benchmark"
64
+ puts "------------------------------------"
65
+ puts "Corpus: #{corpus}"
66
+ puts "Total refs: #{results[:total]}"
67
+ puts "Resolved: #{results[:resolved]} (#{percent}%)"
68
+ puts ""
69
+ puts "By strategy:"
70
+ results[:by_strategy].sort_by { |k, _| k.to_s }.each do |strategy, count|
71
+ puts " #{strategy.to_s.ljust(12)} #{count}"
72
+ end
73
+
74
+ unless results[:unresolved].empty?
75
+ puts ""
76
+ puts "Unresolved (first 20):"
77
+ results[:unresolved].first(20).each do |miss|
78
+ puts " #{miss[:app]} / #{miss[:page]}: #{miss[:url]}"
79
+ end
80
+ end
81
+
82
+ # Exit non-zero if below the v0.2 success bar so CI can gate the merge.
83
+ threshold = (ENV["BROWSABLE_RESOLVER_THRESHOLD"] || "95").to_f
84
+ if percent < threshold
85
+ warn ""
86
+ warn "BELOW THRESHOLD (#{percent}% < #{threshold}%) — fix the resolver before merging v0.2."
87
+ exit 1
88
+ end
89
+
90
+ puts ""
91
+ puts "Meets the #{threshold}% bar."
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Browsable
6
+ # Translates an asset URL discovered in an HTML response into an on-disk file
7
+ # path under the Rails app. Runtime mode hands every <link> / <script src>
8
+ # through here so the end-of-suite analyzers can read those files directly.
9
+ #
10
+ # The resolver is intentionally conservative: it returns `nil` rather than
11
+ # guessing when a URL clearly belongs to a third-party CDN, when the digested
12
+ # filename does not map back to a real source on disk, or when the host Rails
13
+ # app cannot be introspected. The TestReport surfaces those misses as skipped
14
+ # entries — never as fabricated findings.
15
+ class AssetResolver
16
+ # The strategy used to resolve a given URL, exposed for diagnostics.
17
+ Result = Data.define(:path, :strategy) do
18
+ def resolved? = !path.nil?
19
+ end
20
+
21
+ DIGEST_PATTERN = /-[0-9a-f]{7,64}(?=\.\w+\z)/.freeze
22
+
23
+ DEFAULT_ROOTS = %w[
24
+ app/assets/stylesheets
25
+ app/assets/javascripts
26
+ app/javascript
27
+ app/assets/builds
28
+ vendor/assets/stylesheets
29
+ vendor/assets/javascripts
30
+ public
31
+ ].freeze
32
+
33
+ attr_reader :rails_app
34
+
35
+ def initialize(rails_app: nil, root: nil, search_roots: nil)
36
+ @rails_app = rails_app || (defined?(Rails) ? Rails.application : nil)
37
+ @root = root && File.expand_path(root)
38
+ @search_roots = search_roots
39
+ end
40
+
41
+ # Resolve a URL to an absolute on-disk path. Returns nil for URLs we cannot
42
+ # honestly attribute to a file in the host application.
43
+ def resolve(url)
44
+ detailed_resolve(url).path
45
+ end
46
+
47
+ # Same as #resolve, but returns a Result carrying which strategy matched —
48
+ # useful for the benchmark script and for skipped-entry diagnostics.
49
+ def detailed_resolve(url)
50
+ return Result.new(path: nil, strategy: :empty) if url.nil? || url.empty?
51
+
52
+ path = path_component(url)
53
+ return Result.new(path: nil, strategy: :external) if path.nil?
54
+
55
+ undigested = strip_digest(path)
56
+
57
+ if (hit = resolve_via_propshaft(undigested))
58
+ return Result.new(path: hit, strategy: :propshaft)
59
+ end
60
+
61
+ if (hit = resolve_via_sprockets(undigested))
62
+ return Result.new(path: hit, strategy: :sprockets)
63
+ end
64
+
65
+ if (hit = resolve_via_filesystem(undigested))
66
+ return Result.new(path: hit, strategy: :filesystem)
67
+ end
68
+
69
+ if (hit = resolve_via_public(undigested))
70
+ return Result.new(path: hit, strategy: :public)
71
+ end
72
+
73
+ Result.new(path: nil, strategy: :unresolved)
74
+ end
75
+
76
+ private
77
+
78
+ # Reduce a URL to its path component. Returns nil for absolute URLs whose
79
+ # host does not match the application's configured asset host — those are
80
+ # treated as external (CDN-hosted) and skipped.
81
+ #
82
+ # TODO(v0.2.1): handle CDN-hosted absolute URLs that *do* match
83
+ # `config.asset_host`, including digest-only URLs (e.g. a CloudFront prefix
84
+ # that retains the same path layout the Rails app uses).
85
+ def path_component(url)
86
+ uri = URI.parse(url)
87
+ return nil if uri.scheme == "data" || uri.scheme == "javascript"
88
+
89
+ if uri.absolute?
90
+ return nil unless matches_asset_host?(uri)
91
+
92
+ uri.path
93
+ else
94
+ uri.path
95
+ end
96
+ rescue URI::InvalidURIError
97
+ url.split("?", 2).first
98
+ end
99
+
100
+ def matches_asset_host?(uri)
101
+ hosts = configured_asset_hosts
102
+ return false if hosts.empty?
103
+
104
+ hosts.any? { |host| host == uri.host || (host.respond_to?(:include?) && host.include?(uri.host.to_s)) }
105
+ end
106
+
107
+ def configured_asset_hosts
108
+ return [] unless rails_app
109
+
110
+ asset_host = rails_app.config.action_controller.asset_host rescue nil
111
+ Array(asset_host).compact.map do |entry|
112
+ URI.parse(entry).host || entry rescue entry
113
+ end
114
+ end
115
+
116
+ def strip_digest(path)
117
+ path.sub(DIGEST_PATTERN, "")
118
+ end
119
+
120
+ # --- Propshaft (Rails 7+ default) ---------------------------------------
121
+
122
+ def resolve_via_propshaft(path)
123
+ return nil unless rails_app
124
+ return nil unless rails_app.respond_to?(:assets)
125
+
126
+ assets = rails_app.assets
127
+ return nil unless assets.respond_to?(:resolver)
128
+
129
+ logical = path.sub(%r{\A/assets/}, "").sub(%r{\A/}, "")
130
+ asset = assets.resolver.asset_for(logical) rescue nil
131
+ return nil unless asset
132
+
133
+ candidate = asset.respond_to?(:path) ? asset.path.to_s : nil
134
+ return nil unless candidate && File.file?(candidate)
135
+
136
+ candidate
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ # --- Sprockets fallback --------------------------------------------------
142
+
143
+ def resolve_via_sprockets(path)
144
+ return nil unless rails_app
145
+ assets = rails_app.config.assets rescue nil
146
+ return nil unless assets
147
+
148
+ paths = Array(assets.respond_to?(:paths) ? assets.paths : nil)
149
+ return nil if paths.empty?
150
+
151
+ logical = path.sub(%r{\A/assets/}, "").sub(%r{\A/}, "")
152
+ paths.each do |asset_root|
153
+ candidate = File.expand_path(logical, asset_root.to_s)
154
+ return candidate if File.file?(candidate)
155
+ end
156
+ nil
157
+ rescue StandardError
158
+ nil
159
+ end
160
+
161
+ # --- Filesystem walk -----------------------------------------------------
162
+
163
+ # Many Rails apps emit `<script src="/application-abc123.js">` (jsbundling)
164
+ # or `<link href="/application.css">` (cssbundling). Neither Propshaft nor
165
+ # Sprockets owns those — they live under app/assets/builds and public/.
166
+ def resolve_via_filesystem(path)
167
+ basename = File.basename(path)
168
+ return nil if basename.empty? || basename == "/"
169
+
170
+ app_root = root
171
+ return nil unless app_root
172
+
173
+ search_roots.each do |root_segment|
174
+ absolute = File.join(app_root, root_segment, basename)
175
+ return absolute if File.file?(absolute)
176
+ end
177
+ nil
178
+ end
179
+
180
+ # As a last resort try the URL path verbatim under public/, where Rails
181
+ # serves precompiled assets and any static file mounted with sendfile.
182
+ def resolve_via_public(path)
183
+ app_root = root
184
+ return nil unless app_root
185
+
186
+ candidate = File.join(app_root, "public", path.sub(%r{\A/}, ""))
187
+ return candidate if File.file?(candidate)
188
+
189
+ nil
190
+ end
191
+
192
+ def root
193
+ @root || (rails_app.respond_to?(:root) ? rails_app.root.to_s : nil)
194
+ end
195
+
196
+ def search_roots
197
+ @search_roots ||= DEFAULT_ROOTS
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "set"
5
+
6
+ module Browsable
7
+ # Thread-safe, in-memory accumulator for responses observed by the runtime
8
+ # middleware. Recording is per-request; analysis is one-shot at suite end.
9
+ #
10
+ # A single global instance lives at `Browsable.audit_log`. Tests can replace
11
+ # it (or call `#clear`) for isolation. Nothing in this class invokes an
12
+ # analyzer — recording must be cheap and side-effect-free.
13
+ class AuditLog
14
+ # One observed response.
15
+ #
16
+ # endpoint "PostsController#show"
17
+ # request_path "/posts/42"
18
+ # policy Browsable::Policy (the effective policy for this endpoint)
19
+ # html the rendered response body as a String
20
+ # asset_paths Array<HtmlExtractor::AssetRef>
21
+ # inline_blocks Array<HtmlExtractor::InlineBlock>
22
+ # recorded_at Time (used for debugging only, not analysis)
23
+ Entry = Data.define(
24
+ :endpoint, :request_path, :policy, :html,
25
+ :asset_paths, :inline_blocks, :recorded_at
26
+ )
27
+
28
+ def initialize
29
+ @entries = Concurrent::Array.new
30
+ end
31
+
32
+ def record(endpoint:, request_path:, policy:, html:, asset_paths:, inline_blocks:)
33
+ @entries << Entry.new(
34
+ endpoint: endpoint,
35
+ request_path: request_path,
36
+ policy: policy,
37
+ html: html,
38
+ asset_paths: asset_paths,
39
+ inline_blocks: inline_blocks,
40
+ recorded_at: Time.now
41
+ )
42
+ end
43
+
44
+ def entries
45
+ @entries.to_a
46
+ end
47
+
48
+ def empty?
49
+ @entries.empty?
50
+ end
51
+
52
+ def size
53
+ @entries.size
54
+ end
55
+
56
+ # The deduplicated union of every resolved asset path seen across all
57
+ # entries — what TestReport hands to stylelint and eslint in one go.
58
+ def asset_path_universe
59
+ paths = Set.new
60
+ @entries.each do |entry|
61
+ entry.asset_paths.each do |ref|
62
+ paths << ref.resolved_path if ref.resolved_path
63
+ end
64
+ end
65
+ paths
66
+ end
67
+
68
+ # Every entry whose response loaded the given asset path. Used to attribute
69
+ # an end-of-suite finding back to the endpoints that triggered it.
70
+ def entries_loading(asset_path)
71
+ @entries.select do |entry|
72
+ entry.asset_paths.any? { |ref| ref.resolved_path == asset_path }
73
+ end
74
+ end
75
+
76
+ def clear
77
+ @entries.clear
78
+ end
79
+ end
80
+
81
+ class << self
82
+ def audit_log
83
+ @audit_log ||= AuditLog.new
84
+ end
85
+
86
+ # Tests / drivers can install their own instance.
87
+ attr_writer :audit_log
88
+
89
+ # The shared AssetResolver. Lazily constructed against the current Rails
90
+ # app the first time it's asked for.
91
+ def asset_resolver
92
+ @asset_resolver ||= AssetResolver.new
93
+ end
94
+
95
+ attr_writer :asset_resolver
96
+ end
97
+ end
data/lib/browsable/cli.rb CHANGED
@@ -137,6 +137,30 @@ module Browsable
137
137
  end
138
138
  end
139
139
 
140
+ desc "replay PATH", "Reformat a JSON audit dump from a previous test-suite run"
141
+ long_desc <<~DESC
142
+ Reads the JSON produced by `Browsable::TestReport#to_json` (runtime mode)
143
+ or `--json` (static mode) and re-renders it through any formatter. Handy
144
+ for emitting GitHub annotations in CI from a saved test-suite dump.
145
+ DESC
146
+ option :"fail-on", type: :string, enum: %w[warning error never], default: "error"
147
+ def replay(path)
148
+ abort_with("Cannot read #{path}: not a file") unless File.file?(path)
149
+
150
+ replay = Replay.from_file(path)
151
+ formatter = case (options[:format] || (options[:json] ? "json" : "human"))
152
+ when "json" then Formatters::Json
153
+ when "github" then Formatters::Github
154
+ else Formatters::Human
155
+ end
156
+ puts formatter.new(replay).render
157
+
158
+ fail_on = options["fail-on"] || "error"
159
+ exit(fail_on == "never" ? 0 : replay.exit_code(fail_on: fail_on))
160
+ rescue JSON::ParserError => e
161
+ abort_with("#{path} is not valid JSON: #{e.message}")
162
+ end
163
+
140
164
  desc "version", "Print the browsable version"
141
165
  def version
142
166
  puts "browsable #{Browsable::VERSION}"
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Drivers
5
+ # The Minitest driver — activated by `require "browsable/minitest"`.
6
+ #
7
+ # Mirrors the RSpec driver: insert the middleware, clear the audit log on
8
+ # boot, render the TestReport once Minitest finishes. Uses
9
+ # `Minitest.after_run` for end-of-suite reporting.
10
+ class Minitest
11
+ DEFAULTS = {
12
+ fail_on: :error,
13
+ format: :human,
14
+ output: :stdout,
15
+ enabled: true
16
+ }.freeze
17
+
18
+ class Configuration
19
+ attr_accessor :fail_on, :format, :output, :enabled
20
+
21
+ def initialize
22
+ DEFAULTS.each { |key, value| public_send("#{key}=", value) }
23
+ end
24
+ end
25
+
26
+ class << self
27
+ def configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
31
+ def configure
32
+ yield configuration
33
+ end
34
+
35
+ def reset!
36
+ @configuration = nil
37
+ end
38
+
39
+ def install!
40
+ require "minitest"
41
+ ensure_rails!
42
+ insert_middleware
43
+ Browsable.audit_log.clear
44
+ ::Minitest.after_run { Browsable::Drivers::Minitest.after_run }
45
+ end
46
+
47
+ def rails_application
48
+ return nil unless defined?(::Rails)
49
+
50
+ ::Rails.application
51
+ end
52
+
53
+ def after_run
54
+ return unless configuration.enabled
55
+ return if Browsable.audit_log.empty?
56
+
57
+ report = Browsable::TestReport.new
58
+ emit(report)
59
+ report.fail_suite_if_errors!(fail_on: configuration.fail_on) unless configuration.fail_on == :never
60
+ end
61
+
62
+ private
63
+
64
+ def ensure_rails!
65
+ return if defined?(::Rails) && rails_application
66
+
67
+ raise Browsable::Error,
68
+ "browsable/minitest requires a Rails application — load it after Rails is initialized."
69
+ end
70
+
71
+ def insert_middleware
72
+ app = rails_application
73
+ return unless app
74
+
75
+ stack = app.config.middleware
76
+ return if middleware_present?(stack)
77
+
78
+ stack.use(Browsable::Middleware)
79
+ end
80
+
81
+ def middleware_present?(stack)
82
+ stack.respond_to?(:include?) && stack.include?(Browsable::Middleware)
83
+ rescue StandardError
84
+ false
85
+ end
86
+
87
+ def emit(report)
88
+ rendered = report.render(format: configuration.format)
89
+ target = configuration.output
90
+ case target
91
+ when :stdout then $stdout.puts(rendered)
92
+ when :stderr then $stderr.puts(rendered)
93
+ when IO, StringIO then target.puts(rendered)
94
+ else File.write(target.to_s, rendered)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ module Drivers
5
+ # The RSpec driver — activated by `require "browsable/rspec"`.
6
+ #
7
+ # On load it (a) verifies a Rails app is reachable, (b) inserts the
8
+ # runtime middleware idempotently, and (c) registers before(:suite) /
9
+ # after(:suite) hooks so the audit log is cleared and the report rendered
10
+ # automatically without per-spec boilerplate.
11
+ class RSpec
12
+ DEFAULTS = {
13
+ fail_on: :error, # :error | :warning | :never
14
+ format: :human, # :human | :json | :github
15
+ output: :stdout, # :stdout | :stderr | "path/to/file"
16
+ enabled: true
17
+ }.freeze
18
+
19
+ class Configuration
20
+ attr_accessor :fail_on, :format, :output, :enabled
21
+
22
+ def initialize
23
+ DEFAULTS.each { |key, value| public_send("#{key}=", value) }
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def configure
33
+ yield configuration
34
+ end
35
+
36
+ def reset!
37
+ @configuration = nil
38
+ end
39
+
40
+ # Wire the middleware and RSpec hooks. Called once when the entry-point
41
+ # file is required.
42
+ def install!
43
+ require "rspec/core"
44
+ ensure_rails!
45
+ insert_middleware
46
+ register_hooks
47
+ end
48
+
49
+ # The Rails app whose middleware stack we mutate. Extracted so tests
50
+ # can stub the lookup.
51
+ def rails_application
52
+ return nil unless defined?(::Rails)
53
+
54
+ ::Rails.application
55
+ end
56
+
57
+ private
58
+
59
+ def ensure_rails!
60
+ return if defined?(::Rails) && rails_application
61
+
62
+ raise Browsable::Error,
63
+ "browsable/rspec requires a Rails application — load it after Rails is initialized."
64
+ end
65
+
66
+ def insert_middleware
67
+ app = rails_application
68
+ return unless app
69
+
70
+ stack = app.config.middleware
71
+ return if middleware_present?(stack)
72
+
73
+ stack.use(Browsable::Middleware)
74
+ end
75
+
76
+ def middleware_present?(stack)
77
+ stack.respond_to?(:include?) && stack.include?(Browsable::Middleware)
78
+ rescue StandardError
79
+ false
80
+ end
81
+
82
+ def register_hooks
83
+ ::RSpec.configure do |c|
84
+ c.before(:suite) { Browsable::Drivers::RSpec.before_suite }
85
+ c.after(:suite) { Browsable::Drivers::RSpec.after_suite }
86
+ end
87
+ end
88
+
89
+ public
90
+
91
+ def before_suite
92
+ Browsable.audit_log.clear
93
+ end
94
+
95
+ def after_suite
96
+ return unless configuration.enabled
97
+ return if Browsable.audit_log.empty?
98
+
99
+ report = Browsable::TestReport.new
100
+ emit(report)
101
+ report.fail_suite_if_errors!(fail_on: configuration.fail_on) unless configuration.fail_on == :never
102
+ end
103
+
104
+ def emit(report)
105
+ rendered = report.render(format: configuration.format)
106
+ target = configuration.output
107
+ case target
108
+ when :stdout then $stdout.puts(rendered)
109
+ when :stderr then $stderr.puts(rendered)
110
+ when IO, StringIO then target.puts(rendered)
111
+ else File.write(target.to_s, rendered)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end