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,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
|