jitcatch 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module JitCatch
|
|
6
|
+
# Reads the routes `config/routes.rb` draws as source, without booting the
|
|
7
|
+
# app, so a request a test sends to a path or a path helper names the
|
|
8
|
+
# controller action that serves it. It reads `resources`, `resource`,
|
|
9
|
+
# `namespace`, `scope`, `member`, `collection`, `root`, and a verb with
|
|
10
|
+
# `to:`, `=>`, or `controller:` and `action:`, and names helpers and
|
|
11
|
+
# controllers with the app's inflections. A route that a `draw` file, a
|
|
12
|
+
# loop, or a concern draws is not read, so its helper stays unknown.
|
|
13
|
+
class Routes
|
|
14
|
+
Route = Data.define(:verb, :path, :helper, :action)
|
|
15
|
+
# `resource` is the resource whose block the statements are in: the
|
|
16
|
+
# path and helper of one member, and its controller. `controller` is
|
|
17
|
+
# the one `scope controller:` names.
|
|
18
|
+
Scope = Data.define(:path, :helper, :modules, :resource, :controller)
|
|
19
|
+
Resource = Data.define(:member_path, :collection_path, :member_helper, :collection_helper, :controller)
|
|
20
|
+
|
|
21
|
+
VERBS = %i[get post put patch delete].freeze
|
|
22
|
+
# The actions `resources` draws, in the order Rails matches them: the
|
|
23
|
+
# verb, the path after the collection's, and whether the helper names
|
|
24
|
+
# the collection, a member, or the member with a prefix.
|
|
25
|
+
PLURAL_ACTIONS = [
|
|
26
|
+
[:index, "GET", "", :collection], [:create, "POST", "", :collection], [:new, "GET", "/new", :new_member],
|
|
27
|
+
[:edit, "GET", "/:id/edit", :edit_member], [:show, "GET", "/:id", :member], [:update, "PATCH", "/:id", :member],
|
|
28
|
+
[:update, "PUT", "/:id", :member], [:destroy, "DELETE", "/:id", :member]
|
|
29
|
+
].freeze
|
|
30
|
+
SINGULAR_ACTIONS = [
|
|
31
|
+
[:new, "GET", "/new", :new_member], [:edit, "GET", "/edit", :edit_member], [:show, "GET", "", :member],
|
|
32
|
+
[:create, "POST", "", :member], [:update, "PATCH", "", :member], [:update, "PUT", "", :member], [:destroy, "DELETE", "", :member]
|
|
33
|
+
].freeze
|
|
34
|
+
# Blocks whose routes keep the scope around them.
|
|
35
|
+
PASS_THROUGH = %i[constraints defaults].freeze
|
|
36
|
+
|
|
37
|
+
def self.parse(source, inflections = Inflections.rails)
|
|
38
|
+
routes = new(inflections)
|
|
39
|
+
result = source ? Prism.parse(source) : nil
|
|
40
|
+
routes.read(result.value, Scope.new(path: "", helper: "", modules: [], resource: nil, controller: nil)) if result&.success?
|
|
41
|
+
routes
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(inflections)
|
|
45
|
+
@inflections = inflections
|
|
46
|
+
@routes = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def drawn?(action)
|
|
50
|
+
@routes.any? { |route| route.action == action }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def helper?(name)
|
|
54
|
+
!route_for_helper(name).nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The first route a `*_path` or `*_url` helper builds a URL for, or nil.
|
|
58
|
+
def route_for_helper(name)
|
|
59
|
+
return nil unless name.end_with?("_path", "_url")
|
|
60
|
+
|
|
61
|
+
bare = name.sub(/_(?:path|url)\z/, "")
|
|
62
|
+
@routes.find { |route| route.helper == bare }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The controller actions a request (`POST things_path`, or
|
|
66
|
+
# `GET /things/7`, `*` for a segment the test computes) reaches: by
|
|
67
|
+
# helper, each action that helper serves for the verb; by path, the
|
|
68
|
+
# first route Rails would match, a computed segment preferring a
|
|
69
|
+
# `:param` to a literal one.
|
|
70
|
+
def actions(request)
|
|
71
|
+
verb, target = request.split(" ", 2)
|
|
72
|
+
if SourceIndex.route_helper_request?(request)
|
|
73
|
+
bare = target.delete_suffix("_path")
|
|
74
|
+
return @routes.select { |route| route.verb == verb && route.helper == bare }.map(&:action).uniq
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
drawn = @routes.select { |route| route.verb == verb }
|
|
78
|
+
found = drawn.find { |route| reaches?(target, route.path, false) } || drawn.find { |route| reaches?(target, route.path, true) }
|
|
79
|
+
found ? [found.action] : []
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A path the app serves itself, so the request names every segment of
|
|
83
|
+
# the route, unless it starts with a part the test computes.
|
|
84
|
+
def reaches?(sent_path, route_path, computed_matches_literal)
|
|
85
|
+
sent = sent_path.split("/").reject(&:empty?)
|
|
86
|
+
open_mount = sent.first == SourceIndex::COMPUTED_SEGMENT
|
|
87
|
+
sent = sent.drop(1) if open_mount
|
|
88
|
+
wanted = route_path.split("/").reject(&:empty?)
|
|
89
|
+
return false if open_mount ? sent.size > wanted.size : sent.size != wanted.size
|
|
90
|
+
|
|
91
|
+
sent.zip(wanted.last(sent.size)).all? do |part, want|
|
|
92
|
+
want.start_with?(":") || part == want || (part == SourceIndex::COMPUTED_SEGMENT && computed_matches_literal)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read(node, scope)
|
|
97
|
+
statements(node).each do |stmt|
|
|
98
|
+
next unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil?
|
|
99
|
+
|
|
100
|
+
args = Array(stmt.arguments&.arguments)
|
|
101
|
+
options = keyword_options(args)
|
|
102
|
+
case stmt.name
|
|
103
|
+
when :resources, :resource
|
|
104
|
+
plural = stmt.name == :resources
|
|
105
|
+
symbol_names(args).each { |name| read_resource(name, plural, options, stmt.block, scope) }
|
|
106
|
+
when :namespace
|
|
107
|
+
name = symbol_names(args).first
|
|
108
|
+
next unless name && stmt.block
|
|
109
|
+
|
|
110
|
+
read(stmt.block.body, scope.with(path: "#{scope.path}/#{name}", helper: "#{scope.helper}#{name}_", modules: [*scope.modules, name], resource: nil, controller: nil))
|
|
111
|
+
when :scope
|
|
112
|
+
path = string_of(args.first) || string_of(options["path"])
|
|
113
|
+
as = string_of(options["as"])
|
|
114
|
+
read(stmt.block&.body, scope.with(path: path ? "#{scope.path}/#{path.delete_prefix("/")}" : scope.path, modules: modules_of(scope, options),
|
|
115
|
+
helper: as ? "#{scope.helper}#{as}_" : scope.helper, controller: string_of(options["controller"]) || scope.controller))
|
|
116
|
+
when :member, :collection
|
|
117
|
+
resource = scope.resource
|
|
118
|
+
next unless resource && stmt.block
|
|
119
|
+
|
|
120
|
+
member = stmt.name == :member
|
|
121
|
+
read_actions(stmt.block.body, scope, member ? resource.member_path : resource.collection_path, member ? resource.member_helper : resource.collection_helper, resource.controller)
|
|
122
|
+
when :root
|
|
123
|
+
target = string_of(args.first) || target_of(options, scope, nil)
|
|
124
|
+
add("GET", scope.path.empty? ? "/" : scope.path, "#{scope.helper}root", target, scope)
|
|
125
|
+
when *VERBS
|
|
126
|
+
read_verb(stmt.name, args, options, scope)
|
|
127
|
+
when *PASS_THROUGH
|
|
128
|
+
read(stmt.block&.body, scope)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Rails names the helpers after `as:` when it is given, and adds
|
|
136
|
+
# `_index` to the collection's when the name has one form for both.
|
|
137
|
+
def read_resource(name, plural, options, block, scope)
|
|
138
|
+
only = symbol_list(options["only"])
|
|
139
|
+
except = symbol_list(options["except"]) || []
|
|
140
|
+
segment = string_of(options["path"]) || name
|
|
141
|
+
named = string_of(options["as"]) || name
|
|
142
|
+
singular = plural ? @inflections.singularize(named) : named
|
|
143
|
+
controller = string_of(options["controller"]) || (plural ? name : @inflections.pluralize(name))
|
|
144
|
+
scope = scope.with(modules: modules_of(scope, options))
|
|
145
|
+
collection_path = "#{scope.path}/#{segment}"
|
|
146
|
+
member_path = plural ? "#{collection_path}/:id" : collection_path
|
|
147
|
+
collection_helper = "#{scope.helper}#{plural && singular == named ? "#{named}_index" : named}"
|
|
148
|
+
member_helper = "#{scope.helper}#{singular}"
|
|
149
|
+
action_of = ->(action) { action(scope.modules, controller, action) }
|
|
150
|
+
(plural ? PLURAL_ACTIONS : SINGULAR_ACTIONS).each do |action, verb, suffix, helper_kind|
|
|
151
|
+
next if (only && !only.include?(action.to_s)) || except.include?(action.to_s)
|
|
152
|
+
|
|
153
|
+
helper = case helper_kind
|
|
154
|
+
when :collection then collection_helper
|
|
155
|
+
when :member then member_helper
|
|
156
|
+
else "#{helper_kind.to_s.delete_suffix("_member")}_#{member_helper}"
|
|
157
|
+
end
|
|
158
|
+
@routes << Route.new(verb: verb, path: "#{collection_path}#{suffix}", helper: helper, action: action_of.(action))
|
|
159
|
+
end
|
|
160
|
+
return unless block
|
|
161
|
+
|
|
162
|
+
nested_path = plural ? "#{collection_path}/:#{singular}_id" : collection_path
|
|
163
|
+
resource = Resource.new(member_path: member_path, collection_path: collection_path, member_helper: member_helper, collection_helper: collection_helper,
|
|
164
|
+
controller: controller)
|
|
165
|
+
read(block.body, scope.with(path: nested_path, helper: "#{member_helper}_", resource: resource))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `post :shelve` in a `member` or `collection` block, or with `on:`.
|
|
169
|
+
def read_actions(body, scope, path, helper, controller)
|
|
170
|
+
statements(body).each do |stmt|
|
|
171
|
+
next unless stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? && VERBS.include?(stmt.name)
|
|
172
|
+
|
|
173
|
+
symbol_names(Array(stmt.arguments&.arguments)).each do |action|
|
|
174
|
+
add_action(stmt.name, "#{path}/#{action}", "#{action}_#{helper}", controller, action, scope)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def add_action(verb, path, helper, controller, action, scope)
|
|
180
|
+
@routes << Route.new(verb: verb.to_s.upcase, path: path, helper: helper, action: action(scope.modules, controller, action))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_verb(verb, args, options, scope)
|
|
184
|
+
resource = scope.resource
|
|
185
|
+
on = string_of(options["on"])
|
|
186
|
+
if resource && %w[member collection].include?(on)
|
|
187
|
+
member = on == "member"
|
|
188
|
+
path = member ? resource.member_path : resource.collection_path
|
|
189
|
+
helper = member ? resource.member_helper : resource.collection_helper
|
|
190
|
+
symbol_names(args).each { |action| add_action(verb, "#{path}/#{action}", "#{action}_#{helper}", resource.controller, action, scope) }
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# In a `resources` block with no `on:`, Rails nests the action under
|
|
195
|
+
# the member's id, as `/things/:thing_id/refresh`.
|
|
196
|
+
if resource && args.first.is_a?(Prism::SymbolNode) && !options.key?("to")
|
|
197
|
+
action = args.first.unescaped
|
|
198
|
+
return add_action(verb, "#{scope.path}/#{action}", "#{scope.helper}#{action}", resource.controller, action, scope)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
path, target = path_and_target(args, options, scope)
|
|
202
|
+
return unless path && target
|
|
203
|
+
|
|
204
|
+
helper = string_of(options["as"]) || (path.match?(%r{\A[\w/]+\z}) ? path.delete_prefix("/").tr("/", "_") : nil)
|
|
205
|
+
add(verb.to_s.upcase, "#{scope.path}/#{path.delete_prefix("/")}", helper && "#{scope.helper}#{helper}", target, scope)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# `get "about", to: "pages#about"`, `get "about" => "pages#about"`, and
|
|
209
|
+
# `get "about", controller: :pages, action: :about`, whose controller
|
|
210
|
+
# can come from `scope controller:` and whose action defaults to the
|
|
211
|
+
# path.
|
|
212
|
+
def path_and_target(args, options, scope)
|
|
213
|
+
first = args.first
|
|
214
|
+
if first.is_a?(Prism::KeywordHashNode)
|
|
215
|
+
pair = first.elements.grep(Prism::AssocNode).find { |assoc| assoc.key.is_a?(Prism::StringNode) }
|
|
216
|
+
return [pair.key.unescaped, string_of(pair.value)] if pair
|
|
217
|
+
end
|
|
218
|
+
path = string_of(first)
|
|
219
|
+
[path, target_of(options, scope, path)]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def target_of(options, scope, path)
|
|
223
|
+
to = string_of(options["to"])
|
|
224
|
+
return to if to
|
|
225
|
+
|
|
226
|
+
controller = string_of(options["controller"]) || scope.controller
|
|
227
|
+
action = string_of(options["action"]) || (path if path&.match?(/\A\w+\z/))
|
|
228
|
+
"#{controller}##{action}" if controller && action
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def modules_of(scope, options)
|
|
232
|
+
(module_name = string_of(options["module"])) ? [*scope.modules, module_name] : scope.modules
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def add(verb, path, helper, target, scope)
|
|
236
|
+
controller, action = target.to_s.split("#", 2)
|
|
237
|
+
return unless controller && action
|
|
238
|
+
|
|
239
|
+
@routes << Route.new(verb: verb, path: path, helper: helper, action: action(scope.modules, controller, action))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Rails camelizes `admin/api_keys` with the app's acronyms.
|
|
243
|
+
def action(modules, controller, action)
|
|
244
|
+
names = [*modules, *controller.split("/")]
|
|
245
|
+
"#{names[0...-1].map { |name| @inflections.camelize(name) }.push("#{@inflections.camelize(names.last)}Controller").join("::")}##{action}"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def statements(node)
|
|
249
|
+
case node
|
|
250
|
+
when nil then []
|
|
251
|
+
when Prism::ProgramNode then statements(node.statements)
|
|
252
|
+
when Prism::StatementsNode then node.body.flat_map { |stmt| draw_body(stmt) }
|
|
253
|
+
when Prism::BeginNode then statements(node.statements)
|
|
254
|
+
else [node]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The statements of `Rails.application.routes.draw do ... end`.
|
|
259
|
+
def draw_body(stmt)
|
|
260
|
+
return [stmt] unless stmt.is_a?(Prism::CallNode) && stmt.name == :draw && stmt.block
|
|
261
|
+
|
|
262
|
+
statements(stmt.block.body)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def keyword_options(args)
|
|
266
|
+
hash = args.grep(Prism::KeywordHashNode).first
|
|
267
|
+
return {} unless hash
|
|
268
|
+
|
|
269
|
+
hash.elements.grep(Prism::AssocNode).select { |pair| pair.key.is_a?(Prism::SymbolNode) }.to_h { |pair| [pair.key.unescaped, pair.value] }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def symbol_names(args)
|
|
273
|
+
args.select { |arg| arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode) }.map(&:unescaped)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def symbol_list(node)
|
|
277
|
+
case node
|
|
278
|
+
when nil then nil
|
|
279
|
+
when Prism::ArrayNode then node.elements.filter_map { |element| string_of(element) }
|
|
280
|
+
else [string_of(node)].compact
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def string_of(node)
|
|
285
|
+
node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What a rerun line loads with `rspec -r jitcatch/rspec`. RSpec cuts an
|
|
4
|
+
# object it prints to 200 characters, which can cut the class and message a
|
|
5
|
+
# raise_error got out of the failure.
|
|
6
|
+
require "rspec/core"
|
|
7
|
+
require "rspec/support/object_formatter"
|
|
8
|
+
|
|
9
|
+
RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JitCatch
|
|
4
|
+
# Parses the report RSpec's json formatter writes (`--format json --out`).
|
|
5
|
+
# One example is one test, named by its full description.
|
|
6
|
+
class RSpecParser
|
|
7
|
+
STATUSES = { "passed" => "pass", "pending" => "skip" }.freeze
|
|
8
|
+
# RSpec raises these when an expectation does not hold, so they are a
|
|
9
|
+
# claim the example made, as Minitest::Assertion is.
|
|
10
|
+
FAILURES = %w[
|
|
11
|
+
RSpec::Expectations::ExpectationNotMetError
|
|
12
|
+
RSpec::Expectations::MultipleExpectationsNotMetError
|
|
13
|
+
RSpec::Mocks::MockExpectationError
|
|
14
|
+
RSpec::Core::Pending::PendingExampleFixedError
|
|
15
|
+
].freeze
|
|
16
|
+
# --backtrace keeps gem frames, which tell a gem's error from the code's,
|
|
17
|
+
# but RSpec's own frames say nothing about the code under test.
|
|
18
|
+
OWN_FRAME = %r{/gems/rspec-(?:core|expectations|mocks|support)-[^/]+/|\A-e\z}.freeze
|
|
19
|
+
# A raise_error failure repeats the backtrace of the error it got, after
|
|
20
|
+
# this phrase. The frames move to the backtrace, so the phrase goes too.
|
|
21
|
+
QUOTED_FRAME = /\A\s*# \S.*:\d+:in /.freeze
|
|
22
|
+
QUOTED_HEADER = / with backtrace:\s*\z/.freeze
|
|
23
|
+
# The backtrace goes on past the error's own frames into the error it
|
|
24
|
+
# was raised from: this line, the class, then the message and the
|
|
25
|
+
# frames indented under it.
|
|
26
|
+
CAUSED_BY = "--- Caused by: ---"
|
|
27
|
+
|
|
28
|
+
def self.parse(report, stdout, stderr, normalizer: ->(text) { text })
|
|
29
|
+
new(report, normalizer.call(stdout.to_s), normalizer.call(stderr.to_s), normalizer).parse
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(report, stdout, stderr, normalizer)
|
|
33
|
+
@report = report
|
|
34
|
+
@stdout = stdout
|
|
35
|
+
@stderr = stderr
|
|
36
|
+
@normalizer = normalizer
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse
|
|
40
|
+
doc = read_report
|
|
41
|
+
return { "tests" => {}, "lines" => {}, "file_error" => stream_error } unless doc
|
|
42
|
+
|
|
43
|
+
tests = {}
|
|
44
|
+
lines = {}
|
|
45
|
+
Array(doc["examples"]).sort_by { |example| example["line_number"].to_i }.each do |example|
|
|
46
|
+
name = example["full_description"].to_s
|
|
47
|
+
name = "#{name} (line #{example["line_number"]})" while tests.key?(name)
|
|
48
|
+
tests[name] = result_of(example)
|
|
49
|
+
lines[name] = example["line_number"]
|
|
50
|
+
end
|
|
51
|
+
file_error = tests.empty? ? outside_error(doc) : nil
|
|
52
|
+
{ "tests" => tests, "lines" => lines, "file_error" => file_error }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def read_report
|
|
58
|
+
return nil if @report.to_s.strip.empty?
|
|
59
|
+
|
|
60
|
+
doc = JSON.parse(@report)
|
|
61
|
+
doc.is_a?(Hash) ? doc : nil
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def result_of(example)
|
|
67
|
+
status = STATUSES[example["status"]]
|
|
68
|
+
return Failure.passed(status) if status
|
|
69
|
+
|
|
70
|
+
exception = example["exception"] || {}
|
|
71
|
+
error_class = exception["class"].to_s
|
|
72
|
+
message, quoted = split_quoted(@normalizer.call(exception["message"].to_s).lines)
|
|
73
|
+
backtrace = Array(exception["backtrace"]).map { |line| @normalizer.call(line.to_s) }
|
|
74
|
+
at = backtrace.index(CAUSED_BY)
|
|
75
|
+
own = frames(backtrace.take(at || backtrace.size))
|
|
76
|
+
# The expectation's line comes first, then the frames of the error it
|
|
77
|
+
# got, as Minitest prints an assert_raises failure.
|
|
78
|
+
Failure.result(FAILURES.include?(error_class) ? "fail" : "error", error_class, message, [*own.first(1), *frames(quoted), *own.drop(1)],
|
|
79
|
+
cause: at && cause(backtrace.drop(at + 1)))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A diff line can look like a quoted frame, so only the frames that end
|
|
83
|
+
# the message right after the header are quoted.
|
|
84
|
+
def split_quoted(lines)
|
|
85
|
+
at = lines.size
|
|
86
|
+
at -= 1 while at.positive? && lines[at - 1].match?(QUOTED_FRAME)
|
|
87
|
+
return [lines.join, []] unless at.positive? && lines[at - 1].match?(QUOTED_HEADER)
|
|
88
|
+
|
|
89
|
+
[[*lines.take(at - 1), lines[at - 1].sub(QUOTED_HEADER, "")].join, lines.drop(at)]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cause(lines)
|
|
93
|
+
message = lines.drop(1).take_while { |line| line.start_with?(" ") && !line.match?(Failure::FRAME) }
|
|
94
|
+
{ "error_class" => lines.first.to_s.delete_suffix(":"), "message" => message.map(&:strip).join("\n") }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def frames(backtrace)
|
|
98
|
+
Failure.frames_in(Array(backtrace).map { |line| @normalizer.call(line.to_s) })
|
|
99
|
+
.reject { |frame| frame["file"].match?(OWN_FRAME) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# RSpec reports an error while it loads the file, or in a suite hook, as
|
|
103
|
+
# a message outside every example: "Failure/Error: ...", then the error
|
|
104
|
+
# class on a line of its own and the message indented under it.
|
|
105
|
+
def outside_error(doc)
|
|
106
|
+
return nil if doc.dig("summary", "errors_outside_of_examples_count").to_i.zero?
|
|
107
|
+
|
|
108
|
+
text = @normalizer.call(Array(doc["messages"]).first.to_s)
|
|
109
|
+
lines = text.lines.map(&:rstrip)
|
|
110
|
+
at = lines.index { |line| line.match?(/\A[A-Z][\w:]*:\z/) }
|
|
111
|
+
return Failure.result("error", "RuntimeError", text, frames(lines)) unless at
|
|
112
|
+
|
|
113
|
+
message = lines.drop(at + 1).take_while { |line| line.start_with?(" ") }.map(&:strip).join("\n")
|
|
114
|
+
Failure.result("error", lines[at].delete_suffix(":"), message, frames(lines))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stream_error
|
|
118
|
+
Failure.from_streams(@stdout, @stderr) ||
|
|
119
|
+
Failure.result("error", "RuntimeError", "rspec report not found", [])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|