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.
@@ -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