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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Browsable
6
+ # Pure-Ruby parser for a rendered HTML response. Walks the document for asset
7
+ # references (`<link rel="stylesheet">`, `<script src>`) and inline CSS/JS
8
+ # blocks, then asks the configured AssetResolver to translate each external
9
+ # URL into an on-disk path.
10
+ #
11
+ # This is the only HTML work the runtime middleware performs per request.
12
+ # No analysis happens here — that is the TestReport's job, end of suite.
13
+ class HtmlExtractor
14
+ # url — the raw href/src as written in the HTML
15
+ # resolved_path — absolute on-disk path, or nil when AssetResolver missed
16
+ # kind — :css or :js
17
+ AssetRef = Data.define(:url, :resolved_path, :kind)
18
+
19
+ # content — the textual body of an inline <style> or <script>
20
+ # kind — :css or :js
21
+ InlineBlock = Data.define(:content, :kind)
22
+
23
+ # The aggregated extraction result returned to the middleware.
24
+ Extraction = Data.define(:asset_paths, :inline_blocks) do
25
+ # Just the resolved on-disk paths — the shape downstream callers want.
26
+ def resolved_paths
27
+ asset_paths.filter_map(&:resolved_path).uniq
28
+ end
29
+ end
30
+
31
+ EMPTY = Extraction.new(asset_paths: [], inline_blocks: []).freeze
32
+
33
+ attr_reader :html, :asset_resolver
34
+
35
+ def initialize(html, asset_resolver: nil)
36
+ @html = html.to_s
37
+ @asset_resolver = asset_resolver
38
+ end
39
+
40
+ # Convenience entry point used by the middleware so a single call replaces
41
+ # both `new(...)` and `.extract` at the call site.
42
+ def self.extract(html, asset_resolver: nil)
43
+ new(html, asset_resolver: asset_resolver).run
44
+ end
45
+
46
+ def run
47
+ return EMPTY if html.strip.empty?
48
+
49
+ doc = Nokogiri::HTML5.parse(html)
50
+ Extraction.new(
51
+ asset_paths: extract_assets(doc),
52
+ inline_blocks: extract_inline_blocks(doc)
53
+ )
54
+ rescue StandardError
55
+ EMPTY
56
+ end
57
+
58
+ private
59
+
60
+ def extract_assets(doc)
61
+ refs = []
62
+
63
+ doc.css('link[rel~="stylesheet"][href]').each do |link|
64
+ href = link["href"].to_s.strip
65
+ next if href.empty?
66
+
67
+ refs << AssetRef.new(url: href, resolved_path: resolve(href), kind: :css)
68
+ end
69
+
70
+ doc.css("script[src]").each do |script|
71
+ src = script["src"].to_s.strip
72
+ next if src.empty?
73
+
74
+ refs << AssetRef.new(url: src, resolved_path: resolve(src), kind: :js)
75
+ end
76
+
77
+ refs.uniq(&:url)
78
+ end
79
+
80
+ def extract_inline_blocks(doc)
81
+ blocks = []
82
+
83
+ doc.css("style").each do |node|
84
+ content = node.content.to_s
85
+ blocks << InlineBlock.new(content: content, kind: :css) unless content.strip.empty?
86
+ end
87
+
88
+ doc.css("script:not([src])").each do |node|
89
+ # Skip non-executable script blocks: importmaps, JSON payloads, etc. —
90
+ # they aren't JavaScript and eslint would choke on them.
91
+ next if inert_script?(node)
92
+
93
+ content = node.content.to_s
94
+ blocks << InlineBlock.new(content: content, kind: :js) unless content.strip.empty?
95
+ end
96
+
97
+ blocks
98
+ end
99
+
100
+ def inert_script?(node)
101
+ type = node["type"].to_s.downcase
102
+ return false if type.empty? || type == "text/javascript" || type == "module"
103
+ return false if type == "application/javascript"
104
+
105
+ true
106
+ end
107
+
108
+ def resolve(url)
109
+ return nil unless asset_resolver
110
+
111
+ asset_resolver.resolve(url)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ # The Rack middleware behind runtime-mode auditing.
5
+ #
6
+ # Inserted by the RSpec / Minitest drivers (or manually in development), it
7
+ # observes each HTML response, identifies the controller#action that
8
+ # produced it, resolves the effective allow_browser policy for that
9
+ # endpoint, parses the response HTML for asset references, and records the
10
+ # whole tuple into the AuditLog. **No analysis happens here.**
11
+ #
12
+ # The middleware refuses to initialize in `Rails.env.production?` — runtime
13
+ # auditing is strictly for development and test.
14
+ class Middleware
15
+ # Paths under these prefixes are owned by Rails or Action Cable and never
16
+ # represent user-rendered HTML. Auditing them produces noise at best and
17
+ # false attributions at worst.
18
+ SKIP_PREFIXES = ["/rails/", "/assets/", "/packs/", "/cable", "/__"].freeze
19
+
20
+ def initialize(app, audit_log: nil, asset_resolver: nil)
21
+ raise Browsable::Error, "Browsable::Middleware refuses to run in production" if production?
22
+
23
+ @app = app
24
+ @audit_log = audit_log
25
+ @asset_resolver = asset_resolver
26
+ end
27
+
28
+ def call(env)
29
+ status, headers, body = @app.call(env)
30
+ return [status, headers, body] unless auditable?(env, status, headers)
31
+
32
+ chunks = drain(body)
33
+ record(env, status, headers, chunks)
34
+ [status, headers, replay(chunks)]
35
+ end
36
+
37
+ private
38
+
39
+ def production?
40
+ return false unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env
41
+
42
+ Rails.env.production?
43
+ end
44
+
45
+ def auditable?(env, status, headers)
46
+ return false unless env["REQUEST_METHOD"] == "GET"
47
+ return false unless status.to_i == 200
48
+ return false unless html_content_type?(headers)
49
+ return false if skip_path?(env["PATH_INFO"].to_s)
50
+
51
+ true
52
+ end
53
+
54
+ def html_content_type?(headers)
55
+ type = content_type(headers)
56
+ return false unless type
57
+
58
+ type.include?("text/html")
59
+ end
60
+
61
+ # Rack 3 lowercases header keys; Rack 2 preserved the original case. Look
62
+ # both up so we work on either generation.
63
+ def content_type(headers)
64
+ headers["Content-Type"] || headers["content-type"]
65
+ end
66
+
67
+ def skip_path?(path)
68
+ SKIP_PREFIXES.any? { |prefix| path.start_with?(prefix) }
69
+ end
70
+
71
+ def record(env, _status, _headers, chunks)
72
+ controller = env["action_controller.instance"]
73
+ return unless controller
74
+ return unless controller.respond_to?(:action_name) && controller.respond_to?(:class)
75
+
76
+ action = controller.action_name.to_s
77
+ return if action.empty?
78
+
79
+ html = chunks.join
80
+ return if html.empty?
81
+
82
+ extraction = HtmlExtractor.extract(html, asset_resolver: asset_resolver)
83
+ policy = PolicyResolver.for(controller.class, action)
84
+
85
+ audit_log.record(
86
+ endpoint: "#{controller.class.name}##{action}",
87
+ request_path: env["PATH_INFO"].to_s,
88
+ policy: policy,
89
+ html: html,
90
+ asset_paths: extraction.asset_paths,
91
+ inline_blocks: extraction.inline_blocks
92
+ )
93
+ rescue StandardError
94
+ # Recording must never break the request cycle. If something failed,
95
+ # silently drop the entry rather than corrupt the response.
96
+ nil
97
+ end
98
+
99
+ # Rack body is an Enumerable<String>; calling .each consumes single-shot
100
+ # streamed bodies, so we drain into chunks once and replay below.
101
+ def drain(body)
102
+ chunks = []
103
+ body.each { |chunk| chunks << chunk.to_s }
104
+ chunks
105
+ ensure
106
+ body.close if body.respond_to?(:close)
107
+ end
108
+
109
+ # Reconstruct a body that downstream middleware (and the server) can
110
+ # iterate normally. Returning the array directly satisfies Rack's body
111
+ # contract.
112
+ def replay(chunks)
113
+ chunks
114
+ end
115
+
116
+ def audit_log
117
+ @audit_log ||= Browsable.audit_log
118
+ end
119
+
120
+ def asset_resolver
121
+ @asset_resolver ||= Browsable.asset_resolver
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in entry point for runtime-mode auditing inside a Minitest suite.
4
+ #
5
+ # Place this at the top of `test/test_helper.rb`:
6
+ #
7
+ # require "browsable/minitest"
8
+ #
9
+ # After loading Rails. The driver inserts Browsable::Middleware into the
10
+ # Rails app's middleware stack and uses Minitest.after_run to render the
11
+ # report at the end of the suite.
12
+ #
13
+ # Customize via Browsable::Drivers::Minitest.configure:
14
+ #
15
+ # Browsable::Drivers::Minitest.configure do |c|
16
+ # c.fail_on = :error
17
+ # c.format = :github
18
+ # end
19
+ require_relative "../browsable"
20
+
21
+ Browsable::Drivers::Minitest.install!
22
+
23
+ module Browsable
24
+ Minitest = Drivers::Minitest
25
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ # The effective `allow_browser` policy for a specific controller#action.
5
+ #
6
+ # Policy is what runtime mode produces per response — distinct from
7
+ # PolicyScanner::Policy, which is a discovery record for one callsite. A
8
+ # Policy carries enough information to (a) build a Target against which the
9
+ # endpoint's assets are audited, and (b) explain *why* this is the policy in
10
+ # play, when the TestReport renders findings.
11
+ class Policy
12
+ attr_reader :versions, :note, :scope, :only, :except, :source
13
+
14
+ # @param versions [Hash, Symbol, nil] the resolved allow_browser argument
15
+ # @param note [String, nil] caveat when the versions could not be resolved
16
+ # @param scope [String, nil] the owning class name (nil for the fallback)
17
+ # @param only [Array<String>, nil] action filter from the call
18
+ # @param except [Array<String>, nil] action filter from the call
19
+ # @param source [Symbol] :controller, :ancestor, or :default
20
+ def initialize(versions:, note: nil, scope: nil, only: nil, except: nil, source: :controller)
21
+ @versions = versions
22
+ @note = note
23
+ @scope = scope
24
+ @only = only
25
+ @except = except
26
+ @source = source
27
+ end
28
+
29
+ # The Browsable::Target this policy implies. Falls back to the browserslist
30
+ # `defaults` query when no allow_browser versions could be resolved.
31
+ def target
32
+ return Target.from_rails_policy(versions) if versions
33
+
34
+ Target.new("defaults")
35
+ end
36
+
37
+ # A short human label, e.g. ":modern" or "{ chrome: 120, ... }" — used by
38
+ # the TestReport when it wants to print which policy applied.
39
+ def label
40
+ case versions
41
+ when Symbol then ":#{versions}"
42
+ when Hash then "{ #{versions.map { |k, v| "#{k}: #{v}" }.join(', ')} }"
43
+ else "(unresolved)"
44
+ end
45
+ end
46
+
47
+ # True when this Policy is the application-wide fallback rather than a
48
+ # specific allow_browser call. Distinguished so reports can say
49
+ # "no controller policy — audited against the project default".
50
+ def default? = source == :default
51
+
52
+ def as_json
53
+ {
54
+ versions: versions,
55
+ note: note,
56
+ scope: scope,
57
+ only: only,
58
+ except: except,
59
+ source: source.to_s
60
+ }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browsable
4
+ # Maps a `(controller_class, action_name)` pair to the effective Browsable
5
+ # Policy. This is what runtime mode uses, per response, to decide which
6
+ # browsers an endpoint's assets must support.
7
+ #
8
+ # Resolution rules (matching Rails' own filter-callback semantics):
9
+ #
10
+ # 1. Walk the controller's ancestor chain from the most-specific class up
11
+ # to ApplicationController, ignoring anonymous classes and modules.
12
+ # 2. For each class, look up its allow_browser callsites (PolicyScanner
13
+ # data) and pick the *last* call whose `only:`/`except:` filter matches.
14
+ # 3. The first ancestor with a matching call wins — its last matching call
15
+ # becomes the effective policy.
16
+ # 4. If no call matches, return the configured default Policy (the policy
17
+ # from ApplicationController, falling through to the project default).
18
+ #
19
+ # The scanned policy data is built lazily on first use. Tests can call
20
+ # `.reset!` between examples to swap roots without process restart.
21
+ class PolicyResolver
22
+ class << self
23
+ # Convenience: resolve a single controller#action using the shared state.
24
+ def for(controller_class, action_name)
25
+ new(controller_class, action_name).resolve
26
+ end
27
+
28
+ # Inject pre-scanned data — used by drivers (which know the Rails root)
29
+ # and by tests (which want to bypass disk).
30
+ def configure(root: nil, policies: nil, default: nil)
31
+ @root = root
32
+ @policies = policies
33
+ @default = default
34
+ @lookup = nil
35
+ end
36
+
37
+ # Forget any cached state. Called between test files.
38
+ def reset!
39
+ @root = nil
40
+ @policies = nil
41
+ @default = nil
42
+ @lookup = nil
43
+ end
44
+
45
+ def root
46
+ @root ||= (defined?(Rails) && Rails.application ? Rails.root.to_s : Dir.pwd)
47
+ end
48
+
49
+ def policies
50
+ @policies ||= PolicyScanner.call(root)
51
+ end
52
+
53
+ def default_policy
54
+ @default ||= build_default_policy
55
+ end
56
+
57
+ # { "PostsController" => [PolicyScanner::Policy, ...] }, built once.
58
+ def lookup
59
+ @lookup ||= policies.group_by(&:scope)
60
+ end
61
+
62
+ private
63
+
64
+ def build_default_policy
65
+ config = Config.load(root: root)
66
+ Policy.new(
67
+ versions: config.detected_policy,
68
+ note: config.policy_note,
69
+ scope: nil,
70
+ source: :default
71
+ )
72
+ rescue StandardError
73
+ Policy.new(versions: nil, note: nil, scope: nil, source: :default)
74
+ end
75
+ end
76
+
77
+ attr_reader :controller_class, :action_name
78
+
79
+ def initialize(controller_class, action_name)
80
+ @controller_class = controller_class
81
+ @action_name = action_name&.to_s
82
+ end
83
+
84
+ def resolve
85
+ return self.class.default_policy if controller_class.nil? || action_name.nil? || action_name.empty?
86
+
87
+ ancestor_class_names.each do |name|
88
+ calls = self.class.lookup[name]
89
+ next unless calls && !calls.empty?
90
+
91
+ match = calls.reverse.find { |call| applies?(call) }
92
+ next unless match
93
+
94
+ return policy_from(match, scope: name, source: same_class?(name) ? :controller : :ancestor)
95
+ end
96
+
97
+ self.class.default_policy
98
+ end
99
+
100
+ private
101
+
102
+ # Most-specific → least-specific class names in the controller's ancestor
103
+ # chain. We stop at ActionController::Base / ActionController::API because
104
+ # nothing above is user code that could carry an allow_browser call.
105
+ def ancestor_class_names
106
+ seen = []
107
+ controller_class.ancestors.each do |ancestor|
108
+ break if action_controller_root?(ancestor)
109
+ next unless ancestor.is_a?(Class)
110
+
111
+ name = ancestor.name
112
+ next if name.nil? || name.empty?
113
+
114
+ seen << name unless seen.include?(name)
115
+ end
116
+ seen
117
+ end
118
+
119
+ def action_controller_root?(ancestor)
120
+ return false unless ancestor.is_a?(Class)
121
+
122
+ name = ancestor.name.to_s
123
+ name == "ActionController::Base" || name == "ActionController::API"
124
+ end
125
+
126
+ # Does this allow_browser call's only:/except: filter apply to our action?
127
+ def applies?(call)
128
+ return action_in?(call.only) if call.only
129
+ return !action_in?(call.except) if call.except
130
+
131
+ true
132
+ end
133
+
134
+ def action_in?(list)
135
+ Array(list).map(&:to_s).include?(action_name)
136
+ end
137
+
138
+ def same_class?(name)
139
+ name == controller_class.name
140
+ end
141
+
142
+ def policy_from(call, scope:, source:)
143
+ Policy.new(
144
+ versions: call.result.policy,
145
+ note: call.result.note,
146
+ scope: scope,
147
+ only: call.only,
148
+ except: call.except,
149
+ source: source
150
+ )
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Browsable
6
+ # Rehydrates a JSON audit dump (the kind produced by
7
+ # `Browsable::TestReport#to_json`) into a Report-shaped object that any v0.1
8
+ # formatter can render. Used by the `browsable replay` CLI to translate
9
+ # a test-suite report into a different format in CI without re-running tests.
10
+ class Replay
11
+ Suggestion = Data.define(:line, :bumps)
12
+
13
+ attr_reader :data
14
+
15
+ def self.from_file(path)
16
+ raw = File.read(path)
17
+ data = JSON.parse(raw)
18
+ new(data)
19
+ end
20
+
21
+ def initialize(data)
22
+ @data = data
23
+ end
24
+
25
+ def render(format: :human, io: $stdout)
26
+ io.puts(formatter_for(format).new(self).render)
27
+ end
28
+
29
+ # ---- Report-compatible interface -----------------------------------------
30
+ # Browsable::Formatters::* call these on the report they're handed. We
31
+ # implement the same surface here so the same formatters work for replay.
32
+
33
+ def findings
34
+ @findings ||= Array(data["findings"]).map do |f|
35
+ Finding.new(
36
+ feature_id: f["feature_id"],
37
+ feature_name: f["feature_name"],
38
+ file: f["file"],
39
+ line: (f["line"] || 1).to_i,
40
+ column: (f["column"] || 1).to_i,
41
+ required_browser_versions: f["required_browser_versions"] || {},
42
+ target_browser_versions: f["target_browser_versions"] || {},
43
+ severity: (f["severity"] || "warning").to_sym,
44
+ message: f["message"].to_s
45
+ )
46
+ end
47
+ end
48
+
49
+ def errors = findings.select(&:error?)
50
+ def warnings = findings.select(&:warning?)
51
+ def infos = findings.select(&:info?)
52
+ def empty? = findings.empty?
53
+
54
+ def findings_by_file
55
+ findings
56
+ .group_by(&:file)
57
+ .sort_by(&:first)
58
+ .to_h
59
+ .transform_values { |g| g.sort_by { |f| [f.line, f.column] } }
60
+ end
61
+
62
+ def skips
63
+ Array(data["skips"]).map do |skip|
64
+ Report::Skip.new(kind: skip["kind"].to_sym, reason: skip["reason"])
65
+ end
66
+ end
67
+
68
+ def notes = Array(data["notes"])
69
+ def policies = []
70
+ def root = data.dig("target", "root") || Dir.pwd
71
+ def config_file = nil
72
+
73
+ def target
74
+ tdata = data["target"]
75
+ return nil unless tdata
76
+
77
+ Target.new(tdata["query"] || "replay", resolved: tdata["browsers"])
78
+ end
79
+
80
+ def suggestion
81
+ s = data["suggested_policy"]
82
+ return nil unless s
83
+
84
+ Suggestion.new(line: s["line"].to_s, bumps: symbolize_bumps(s["bumps"]))
85
+ end
86
+
87
+ def as_json = data
88
+
89
+ def exit_code(fail_on:)
90
+ case fail_on.to_s
91
+ when "warning" then errors.any? || warnings.any? ? 1 : 0
92
+ when "error" then errors.any? ? 1 : 0
93
+ else 0
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def symbolize_bumps(bumps)
100
+ return {} unless bumps.is_a?(Hash)
101
+
102
+ bumps.each_with_object({}) do |(browser, change), out|
103
+ out[browser] = { from: change["from"], to: change["to"] }
104
+ end
105
+ end
106
+
107
+ def formatter_for(format)
108
+ case format.to_sym
109
+ when :json then Formatters::Json
110
+ when :github then Formatters::Github
111
+ else Formatters::Human
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in entry point for runtime-mode auditing inside an RSpec suite.
4
+ #
5
+ # Place this at the top of `spec/rails_helper.rb`:
6
+ #
7
+ # require "browsable/rspec"
8
+ #
9
+ # After loading Rails. The driver inserts Browsable::Middleware into the
10
+ # Rails app's middleware stack (idempotent — safe to require twice) and
11
+ # registers before(:suite) / after(:suite) hooks so the audit log is reset
12
+ # and the report rendered automatically.
13
+ #
14
+ # Customize via Browsable::Drivers::RSpec.configure:
15
+ #
16
+ # Browsable::Drivers::RSpec.configure do |c|
17
+ # c.fail_on = :error
18
+ # c.output = "tmp/browsable_report.json"
19
+ # c.format = :json
20
+ # end
21
+ require_relative "../browsable"
22
+
23
+ Browsable::Drivers::RSpec.install!
24
+
25
+ # Convenience shim so `Browsable::RSpec.configure { ... }` works at top level.
26
+ module Browsable
27
+ RSpec = Drivers::RSpec
28
+ end