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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +340 -147
- data/bin/benchmark-asset-resolution +91 -0
- data/lib/browsable/asset_resolver.rb +200 -0
- data/lib/browsable/audit_log.rb +97 -0
- data/lib/browsable/cli.rb +24 -0
- data/lib/browsable/drivers/minitest.rb +100 -0
- data/lib/browsable/drivers/rspec.rb +117 -0
- data/lib/browsable/html_extractor.rb +114 -0
- data/lib/browsable/middleware.rb +124 -0
- data/lib/browsable/minitest.rb +25 -0
- data/lib/browsable/policy.rb +63 -0
- data/lib/browsable/policy_resolver.rb +153 -0
- data/lib/browsable/replay.rb +115 -0
- data/lib/browsable/rspec.rb +28 -0
- data/lib/browsable/test_report.rb +329 -0
- data/lib/browsable/version.rb +1 -1
- data/lib/browsable.rb +8 -4
- metadata +57 -2
|
@@ -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
|