crumbly 2.0.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
+ require "crumbly"
5
+ require_relative "converter/source"
6
+ require_relative "converter/crumb_definition"
7
+
8
+ if Gem::Version.new(Prism::VERSION) < Gem::Version.new("1.2")
9
+ raise LoadError, "crumbly-convert needs prism 1.2 or later (#{Prism::VERSION} is loaded): add gem \"prism\" " \
10
+ "to your Gemfile"
11
+ end
12
+
13
+ module Crumbly
14
+ # Turns gretel's breadcrumbs files into one Crumbly::Trail subclass, and lists what it leaves out.
15
+ class Converter
16
+ Result = Data.define(:ruby, :notes)
17
+
18
+ Note = Data.define(:path, :line, :message) do
19
+ def to_s
20
+ "#{path}:#{line}: #{message}"
21
+ end
22
+ end
23
+
24
+ class Unconvertible < StandardError
25
+ attr_reader :line
26
+
27
+ def initialize(message, line)
28
+ super(message)
29
+ @line = line
30
+ end
31
+ end
32
+
33
+ Definition = Data.define(:key, :path, :line, :lines)
34
+
35
+ STATIC_NODES = [
36
+ Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode, Prism::FloatNode,
37
+ Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::ConstantReadNode, Prism::ConstantPathNode
38
+ ].freeze
39
+ LoopValue = Data.define(:source, :text)
40
+
41
+ def self.call(source_by_path, namespace: nil)
42
+ new(source_by_path, namespace:).call
43
+ end
44
+
45
+ def initialize(source_by_path, namespace:)
46
+ unless namespace.nil? || namespace.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
47
+ raise ArgumentError, "#{namespace.inspect} is not a module name"
48
+ end
49
+
50
+ @source_by_path = source_by_path
51
+ @namespace = namespace
52
+ @entries = []
53
+ @definition_by_key = {}
54
+ @notes = []
55
+ @helper_methods = []
56
+ end
57
+
58
+ def call
59
+ source_by_path.each do |path, text|
60
+ source = Source.parse(text, path:)
61
+ convert_statements(source.statements, source)
62
+ trailing_comments = source.comment_lines(source.statements.last&.location&.end_offset || 0, text.bytesize)
63
+ entries << trailing_comments if trailing_comments.any?
64
+ end
65
+ Result.new(ruby: render, notes: notes.sort_by { |note| [note.path, note.line] })
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :source_by_path, :namespace, :entries, :definition_by_key, :notes, :helper_methods
71
+
72
+ # A crumb becomes a method, which cannot see the locals assigned around it: each one it reads is copied in.
73
+ def convert_statements(statements, source, local_by_name = {})
74
+ previous_end_offset = source.start_offset
75
+ statements.each do |statement|
76
+ comments = source.comment_lines(previous_end_offset, statement.location.start_offset)
77
+ previous_end_offset = statement.location.end_offset
78
+ convert_statement(statement, source, comments, local_by_name)
79
+ rescue Unconvertible => exception
80
+ note(source, exception.line, exception.message)
81
+ end
82
+ end
83
+
84
+ def convert_statement(statement, source, comments, local_by_name)
85
+ if crumb_call?(statement)
86
+ define(CrumbDefinition.new(statement, source, local_by_name:), source, comments)
87
+ elsif statement.is_a?(Prism::LocalVariableWriteNode)
88
+ if local_by_name.key?(statement.name)
89
+ note(source, statement.location.start_line, "#{statement.name} is assigned again: a crumb above that " \
90
+ "reads it gets its first value, where gretel read the last one")
91
+ end
92
+ local = CrumbDefinition::Local.new(node: statement, source:)
93
+ helper_methods << comments + local.method_lines if local.helper_method?
94
+ local_by_name[statement.name] = local
95
+ elsif loop_call?(statement)
96
+ entries << comments if comments.any?
97
+ expand_loop(statement, source, local_by_name)
98
+ else
99
+ raise Unconvertible.new("not a crumb definition, left out: #{summary(statement)}",
100
+ statement.location.start_line)
101
+ end
102
+ end
103
+
104
+ def crumb_call?(node)
105
+ node in Prism::CallNode[receiver: nil, name: :crumb]
106
+ end
107
+
108
+ def loop_call?(node)
109
+ node in Prism::CallNode[name: :each | :each_pair, receiver: Prism::Node, block: Prism::BlockNode]
110
+ end
111
+
112
+ def define(crumb_definition, source, comments)
113
+ key = crumb_definition.key
114
+ lines = comments + crumb_definition.lines
115
+ crumb_definition.notes.uniq.each { |message| note(source, crumb_definition.line, message) }
116
+ line = crumb_definition.line
117
+
118
+ if (previous = definition_by_key[key])
119
+ entries.delete(previous)
120
+ notes << Note.new(path: previous.path, line: previous.line,
121
+ message: "crumb :#{key} is defined again on #{source.path}:#{line}, which gretel kept: left out")
122
+ end
123
+ definition = Definition.new(key:, path: source.path, line:, lines:)
124
+ definition_by_key[key] = definition
125
+ entries << definition
126
+ end
127
+
128
+ def expand_loop(call, source, local_by_name)
129
+ block = call.block
130
+ parameters = loop_parameters(block)
131
+ unless parameters
132
+ raise Unconvertible.new("crumbs defined in a loop whose block takes #{block.parameters&.slice.inspect}: " \
133
+ "left out, write them by hand", call.location.start_line)
134
+ end
135
+
136
+ bindings = literal_bindings(call.receiver, parameters, call.name)
137
+ unless bindings
138
+ raise Unconvertible.new("crumbs defined in a loop over #{summary(call.receiver)}, which is not a literal: " \
139
+ "left out, write them by hand", call.location.start_line)
140
+ end
141
+
142
+ start_offset = block.parameters ? block.parameters.location.end_offset : block.opening_loc.end_offset
143
+ bindings.each do |value_by_name|
144
+ text = source.rewrite(start_offset, block.closing_loc.start_offset, substitutions(block.body, value_by_name))
145
+ expansion = value_by_name.map { |name, value| "#{name} = #{value.source}" }.join(", ")
146
+ expanded_source = source.expansion(text, start_offset, expansion, local_by_name.keys)
147
+ convert_statements(expanded_source.statements, expanded_source, local_by_name.dup)
148
+ end
149
+ end
150
+
151
+ # A parameter destructured by the block (`|name, (parent, label)|`) is an array of names.
152
+ def loop_parameters(block)
153
+ parameters = block.parameters&.parameters
154
+ return unless parameters in {optionals: [], rest: nil, posts: [], keywords: [], keyword_rest: nil, block: nil}
155
+
156
+ parameters.requireds.map { |parameter| loop_parameter(parameter) || (return nil) }
157
+ end
158
+
159
+ def loop_parameter(parameter)
160
+ case parameter
161
+ when Prism::RequiredParameterNode then parameter.name
162
+ when Prism::MultiTargetNode
163
+ return unless parameter.rest.nil? && parameter.rights.empty?
164
+
165
+ parameter.lefts.map { |left| loop_parameter(left) || (return nil) }
166
+ end
167
+ end
168
+
169
+ # The value of each parameter, for each element: Ruby splats what the loop yields over several parameters,
170
+ # or over a single destructured one.
171
+ def literal_bindings(collection, parameters, method_name)
172
+ collection = collection.receiver while collection.is_a?(Prism::CallNode) && collection.name == :freeze
173
+ return unless static?(collection)
174
+
175
+ single_name = parameters.first if parameters.size == 1 && parameters.first.is_a?(Symbol)
176
+ case collection
177
+ when Prism::ArrayNode
178
+ return if method_name == :each_pair
179
+
180
+ collection.elements.map do |element|
181
+ next {single_name => loop_value(element)} if single_name
182
+
183
+ bind(parameters, element.is_a?(Prism::ArrayNode) ? element.elements : [element])
184
+ end
185
+ when Prism::HashNode
186
+ collection.elements.map do |association|
187
+ pair = [association.key, association.value]
188
+ next bind(parameters, pair) unless single_name
189
+
190
+ {single_name => LoopValue.new(source: "[#{pair.map { |node| loop_value(node).source }.join(", ")}]", text: nil)}
191
+ end
192
+ end
193
+ end
194
+
195
+ def bind(parameters, values, value_by_name = {})
196
+ parameters = parameters.first if parameters.size == 1 && parameters.first.is_a?(Array)
197
+ parameters.each_with_index do |parameter, index|
198
+ value = values[index]
199
+ if parameter.is_a?(Symbol)
200
+ value_by_name[parameter] = value ? loop_value(value) : LoopValue.new(source: "nil", text: nil)
201
+ else
202
+ bind(parameter, value.is_a?(Prism::ArrayNode) ? value.elements : [value].compact, value_by_name)
203
+ end
204
+ end
205
+ value_by_name
206
+ end
207
+
208
+ def static?(node)
209
+ case node
210
+ when Prism::ArrayNode then node.elements.all? { |element| static?(element) }
211
+ when Prism::HashNode
212
+ node.elements.all? do |element|
213
+ element.is_a?(Prism::AssocNode) && static?(element.key) && static?(element.value)
214
+ end
215
+ else STATIC_NODES.any? { |node_class| node.is_a?(node_class) }
216
+ end
217
+ end
218
+
219
+ def loop_value(node)
220
+ case node
221
+ when Prism::SymbolNode then LoopValue.new(source: node.unescaped.to_sym.inspect, text: node.unescaped)
222
+ when Prism::StringNode then LoopValue.new(source: node.unescaped.inspect, text: node.unescaped)
223
+ else LoopValue.new(source: node.slice, text: nil)
224
+ end
225
+ end
226
+
227
+ def substitutions(node, value_by_name, depth = 0, edits = [])
228
+ case node
229
+ when Prism::LocalVariableReadNode
230
+ value = value_by_name[node.name] if node.depth == depth
231
+ edits << [node.location.start_offset, node.location.end_offset, value.source] if value
232
+ return edits
233
+ when Prism::CallNode
234
+ if (node in {name: :to_s, arguments: nil, receiver: Prism::LocalVariableReadNode[depth: ^depth, name:]}) &&
235
+ (text = value_by_name[name]&.text)
236
+ return edits << [node.location.start_offset, node.location.end_offset, text.inspect]
237
+ end
238
+ when Prism::EmbeddedStatementsNode
239
+ if node in {statements: {body: [Prism::LocalVariableReadNode[depth: ^depth, name:]]}}
240
+ text = value_by_name[name]&.text
241
+ if text&.match?(%r{\A[\w ./-]*\z})
242
+ edits << [node.location.start_offset, node.location.end_offset, text]
243
+ return edits
244
+ end
245
+ end
246
+ when Prism::BlockNode, Prism::LambdaNode then depth += 1
247
+ when Prism::DefNode then return edits
248
+ end
249
+ node&.compact_child_nodes&.each { |child| substitutions(child, value_by_name, depth, edits) }
250
+ edits
251
+ end
252
+
253
+ def note(source, line, message)
254
+ message += " (#{source.expansion_description})" if source.expansion_description
255
+ notes << Note.new(path: source.path, line:, message:)
256
+ end
257
+
258
+ def summary(node)
259
+ first_line = node.slice.lines.first.strip
260
+ (first_line.length > 60) ? "#{first_line[0, 57]}..." : first_line
261
+ end
262
+
263
+ def render
264
+ modules = namespace.to_s.split("::")
265
+ depth = modules.size
266
+ body = entries.map do |entry|
267
+ lines = entry.is_a?(Definition) ? entry.lines : entry
268
+ indent(lines, depth + 1).join("\n")
269
+ end
270
+
271
+ ruby = +"# frozen_string_literal: true\n\n"
272
+ modules.each_with_index { |name, index| ruby << "#{" " * index}module #{name}\n" }
273
+ ruby << "#{" " * depth}class Breadcrumbs < Crumbly::Trail\n"
274
+ if helper_methods.any?
275
+ body << "#{" " * (depth + 1)}private"
276
+ body.concat(helper_methods.map { |lines| indent(lines, depth + 1).join("\n") })
277
+ end
278
+ ruby << body.join("\n\n") << "\n" if body.any?
279
+ ruby << "#{" " * depth}end\n"
280
+ depth.downto(1) { |index| ruby << "#{" " * (index - 1)}end\n" }
281
+ ruby
282
+ end
283
+
284
+ def indent(lines, depth)
285
+ lines.map { |line| line.empty? ? line : "#{" " * depth}#{line}" }
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crumbly
4
+ Link = Data.define(:label, :url)
5
+
6
+ Crumb = Data.define(:label, :url, :links, :parent)
7
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crumbly"
4
+
5
+ module Crumbly
6
+ # Lists the `breadcrumb` calls of views whose trail the action now derives, for a person to review: a call
7
+ # is only redundant when the derived trail matches the one its crumb drew.
8
+ module DerivedCallSites
9
+ CallSite = Data.define(:path, :line, :code, :reason) do
10
+ def to_s
11
+ "#{path}:#{line}: #{code} (#{reason})"
12
+ end
13
+ end
14
+
15
+ BREADCRUMB_CALL = /\bbreadcrumb[ (]\s*(?<target>:\w+|@\w+)(?<arguments>[^\n%}]*)/
16
+ VIEW_GLOB = "**/*.{erb,haml,slim,ruby}"
17
+ FORM_CRUMBS = %w[generic_form new edit].freeze
18
+
19
+ def self.call(*directories)
20
+ directories.flat_map do |directory|
21
+ Dir.glob(VIEW_GLOB, base: directory).sort.flat_map { |path| call_sites(File.join(directory, path)) }
22
+ end
23
+ end
24
+
25
+ def self.call_sites(path)
26
+ action = File.basename(path).split(".").first
27
+ controller_name = File.basename(File.dirname(path))
28
+ File.foreach(path).with_index(1).filter_map do |line, number|
29
+ match = BREADCRUMB_CALL.match(line)
30
+ reason = match && reason(match[:target], match[:arguments].strip.delete_suffix(")").strip, action, controller_name)
31
+ CallSite.new(path:, line: number, code: match[0].strip, reason:) if reason
32
+ end
33
+ end
34
+
35
+ def self.reason(target, arguments, action, controller_name)
36
+ record = "@#{controller_name.singularize}"
37
+ if FORM_CRUMBS.include?(target.delete_prefix(":"))
38
+ case action
39
+ when "new" then 'new derives "New" under the collection'
40
+ when "edit" then 'edit derives "Edit" under the record'
41
+ else "only new and edit views derive a form's trail"
42
+ end
43
+ elsif action == "show" && (target == record || (target == ":#{controller_name.singularize}" && arguments == ", #{record}"))
44
+ "show derives the crumb of #{record}"
45
+ elsif action == "index" && target == ":#{controller_name}" && arguments.empty?
46
+ "index derives the #{controller_name} crumb"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crumbly
4
+ class Error < StandardError; end
5
+
6
+ class DuplicateCrumb < Error; end
7
+
8
+ class UnknownCrumb < Error; end
9
+
10
+ class MissingTrail < Error; end
11
+
12
+ class InvalidTrail < Error; end
13
+
14
+ class RelativeUrl < Error; end
15
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/filters"
4
+ require "active_support/core_ext/string/output_safety"
5
+ require "json"
6
+ require "loofah"
7
+
8
+ module Crumbly
9
+ # A schema.org BreadcrumbList, as Google's Rich Results test reads it.
10
+ module JsonLd
11
+ ABSOLUTE_URL_PREFIXES = ["https://", "http://"].freeze
12
+
13
+ class << self
14
+ def call(levels)
15
+ list_items = []
16
+ levels.each do |level|
17
+ # Google requires an item on every ListItem but the last: a level without a page is left out.
18
+ next unless level.url || level.current?
19
+
20
+ list_item = {"@type" => "ListItem", "position" => list_items.size + 1, "name" => plain_text(level.label)}
21
+ if level.url
22
+ return relative_url(level) unless level.url.start_with?(*ABSOLUTE_URL_PREFIXES)
23
+
24
+ list_item["item"] = level.url
25
+ end
26
+ list_items << list_item
27
+ end
28
+ return if list_items.empty?
29
+
30
+ {"@context" => "https://schema.org", "@type" => "BreadcrumbList", "itemListElement" => list_items}
31
+ end
32
+
33
+ private
34
+
35
+ def plain_text(label)
36
+ return label.to_s.squish unless label.html_safe?
37
+
38
+ Loofah.html5_fragment(label).scrub!(:prune).text(encode_special_chars: false).squish
39
+ end
40
+
41
+ def relative_url(level)
42
+ exception = RelativeUrl.new("JSON-LD needs absolute URLs: #{plain_text(level.label).inspect} links to " \
43
+ "#{level.url}, use a _url helper in its crumb")
44
+ raise exception if Crumbly.raise_on_relative_url?
45
+
46
+ ActiveSupport.error_reporter.report(exception, handled: true)
47
+ nil
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crumbly
4
+ Level = Data.define(:label, :url, :links, :current) do
5
+ def current?
6
+ current
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gretel"
4
+ require "crumbly"
5
+
6
+ module Crumbly
7
+ # Compares gretel's trail with Crumbly's for the same crumbs, from a test of an app that still bundles gretel.
8
+ module Oracle
9
+ Difference = Data.define(:target, :arguments, :gretel, :crumbly, :page) do
10
+ def initialize(target:, arguments:, gretel:, crumbly:, page: nil)
11
+ super
12
+ end
13
+
14
+ def to_s
15
+ call = target ? "breadcrumb #{Oracle.format_call(target, arguments)}" : "no breadcrumb call"
16
+ "#{"#{page}, " if page}#{call}\n" \
17
+ " gretel: #{format_trail(gretel)}\n" \
18
+ " crumbly: #{format_trail(crumbly)}"
19
+ end
20
+
21
+ private
22
+
23
+ def format_trail(trail)
24
+ return "(none)" if trail.empty?
25
+
26
+ trail.map { |label, url| url ? "#{label} (#{url})" : label }.join(" > ")
27
+ end
28
+ end
29
+
30
+ RedundantCall = Data.define(:page, :target, :arguments) do
31
+ def to_s
32
+ "#{page}, breadcrumb #{Oracle.format_call(target, arguments)}: the action derives the same trail"
33
+ end
34
+ end
35
+
36
+ # Prepended to the view helpers by `watch`: each page the tests render compares its trail with gretel's.
37
+ module Watch
38
+ def breadcrumb_trail
39
+ return super if @crumbly_trail || !Oracle.watching?
40
+
41
+ levels = super
42
+ Oracle.observe(self, @crumbly_breadcrumb, levels)
43
+ levels
44
+ end
45
+ end
46
+
47
+ @differences = Set.new
48
+ @redundant_calls = Set.new
49
+ @mutex = Mutex.new
50
+ @watching = false
51
+
52
+ class << self
53
+ attr_reader :differences, :redundant_calls
54
+
55
+ # `cases` lists what views pass to `breadcrumb`: `[[:orders], [:order, order], [order]]`.
56
+ def compare(view, trail_class, cases)
57
+ ::Gretel::Crumbs.reload_if_needed
58
+ cases.filter_map do |target, *arguments|
59
+ gretel = gretel_trail(view, target, arguments)
60
+ crumbly = crumbly_trail(view, trail_class, target, arguments)
61
+ Difference.new(target:, arguments:, gretel:, crumbly:) unless gretel == crumbly
62
+ end
63
+ end
64
+
65
+ # From then on, or for the block, each trail a view renders is compared with the one gretel draws for its
66
+ # `breadcrumb` call (nothing, without one). The differences go to `differences`, and the calls whose trail
67
+ # the action derives anyway to `redundant_calls`.
68
+ def watch
69
+ ViewHelpers.prepend(Watch) unless ViewHelpers <= Watch
70
+ watching = @watching
71
+ @watching = true
72
+ return unless block_given?
73
+
74
+ begin
75
+ yield
76
+ ensure
77
+ @watching = watching
78
+ end
79
+ end
80
+
81
+ def watching?
82
+ @watching
83
+ end
84
+
85
+ def observe(view, breadcrumb_call, levels)
86
+ ::Gretel::Crumbs.reload_if_needed
87
+ target, arguments = breadcrumb_call
88
+ page = "#{view.controller.controller_path}##{view.controller.action_name}"
89
+ gretel = target ? gretel_trail_or_error(view, target, arguments) : []
90
+ difference = Difference.new(target:, arguments:, gretel:, crumbly: flatten(levels), page:)
91
+ redundant = target && derived_trail(view) == gretel
92
+ @mutex.synchronize do
93
+ differences << difference unless difference.gretel == difference.crumbly
94
+ redundant_calls << RedundantCall.new(page:, target:, arguments:) if redundant
95
+ end
96
+ end
97
+
98
+ # gretel renders each link of a crumb as a level of its own; Crumbly keeps the extra ones in `links`.
99
+ def gretel_trail(view, target, arguments)
100
+ links = []
101
+ crumb = ::Gretel::Crumb.new(view, target, *arguments)
102
+ while crumb
103
+ links.unshift(*crumb.links)
104
+ crumb = crumb.parent
105
+ end
106
+ if links.none? { |link| link.key == :root } && ::Gretel::Crumbs.crumb_defined?(:root)
107
+ links.unshift(*::Gretel::Crumb.new(view, :root).links)
108
+ end
109
+ links.map { |link| [link.text.to_s, link.url] }
110
+ end
111
+
112
+ def crumbly_trail(view, trail_class, target, arguments)
113
+ flatten(trail_class.resolve(view, target, *arguments))
114
+ end
115
+
116
+ def format_call(target, arguments)
117
+ [target, *arguments].map { |argument| format_argument(argument) }.join(", ")
118
+ end
119
+
120
+ private
121
+
122
+ # A call gretel cannot draw (a crumb renamed during the migration) is a difference, not a failing test.
123
+ def gretel_trail_or_error(view, target, arguments)
124
+ gretel_trail(view, target, arguments)
125
+ rescue => exception
126
+ [["gretel raised #{exception.class}: #{exception.message}", nil]]
127
+ end
128
+
129
+ def flatten(levels)
130
+ levels.flat_map do |level|
131
+ [[level.label.to_s, level.url], *level.links.map { |link| [link.label.to_s, link.url] }]
132
+ end
133
+ end
134
+
135
+ # A derived trail that raises (an action loading no record, say) only means the call has to stay.
136
+ def derived_trail(view)
137
+ flatten(Crumbly.trail_class_for(view.controller.class).derive(view))
138
+ rescue
139
+ nil
140
+ end
141
+
142
+ def format_argument(argument)
143
+ argument.respond_to?(:model_name) ? "#<#{argument.class.name} #{argument.to_param}>" : argument.inspect
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crumbly
4
+ class Railtie < Rails::Railtie
5
+ initializer "crumbly.helpers" do
6
+ ActiveSupport.on_load(:action_controller_base) { include Crumbly::Controller }
7
+ ActiveSupport.on_load(:action_view) { include Crumbly::ViewHelpers }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "crumbly"
4
+
5
+ module Crumbly
6
+ module TestHelper
7
+ def assert_breadcrumbs_valid(**options)
8
+ dead_crumbs = Crumbly.verify!(**options)
9
+ assert_empty dead_crumbs, "Dead crumbs, called by no crumb, view or controller: #{dead_crumbs.join(", ")}"
10
+ rescue InvalidTrail => exception
11
+ flunk exception.message
12
+ end
13
+ end
14
+ end
15
+
16
+ ActiveSupport.on_load(:active_support_test_case) { include Crumbly::TestHelper }