tldr 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,167 @@
1
+ class TLDR
2
+ module Reporters
3
+ class Default < Base
4
+ def initialize(config, out = $stdout, err = $stderr)
5
+ super
6
+ @icons = @config.no_emoji ? IconProvider::Base.new : IconProvider::Emoji.new
7
+ end
8
+
9
+ def before_suite tests
10
+ @suite_start_time = Process.clock_gettime Process::CLOCK_MONOTONIC, :microsecond
11
+ @out.print <<~MSG
12
+ Command: #{tldr_command} #{@config.to_full_args}
13
+
14
+ #{@icons.run} Running:
15
+
16
+ MSG
17
+ end
18
+
19
+ def after_test result
20
+ @out.print case result.type
21
+ when :success then @icons.success
22
+ when :skip then @icons.skip
23
+ when :failure then @icons.failure
24
+ when :error then @icons.error
25
+ end
26
+ end
27
+
28
+ def time_diff start, stop = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
29
+ ((stop - start) / 1000.0).round
30
+ end
31
+
32
+ def after_tldr planned_tests, wip_tests, test_results
33
+ stop_time = Process.clock_gettime Process::CLOCK_MONOTONIC, :microsecond
34
+
35
+ @out.print @icons.tldr
36
+ @err.print "\n\n"
37
+ wrap_in_horizontal_rule do
38
+ @err.print [
39
+ "too long; didn't run!",
40
+ "#{@icons.run} Completed #{test_results.size} of #{planned_tests.size} tests (#{((test_results.size.to_f / planned_tests.size) * 100).round}%) before running out of time.",
41
+ (<<~WIP.chomp if wip_tests.any?),
42
+ #{@icons.wip} #{plural wip_tests.size, "test was", "tests were"} cancelled in progress:
43
+ #{wip_tests.map { |wip_test| " #{time_diff(wip_test.start_time, stop_time)}ms - #{describe(wip_test.test)}" }.join("\n")}
44
+ WIP
45
+ (<<~SLOW.chomp if test_results.any?),
46
+ #{@icons.slow} Your #{[10, test_results.size].min} slowest completed tests:
47
+ #{test_results.sort_by(&:runtime).last(10).reverse.map { |result| " #{result.runtime}ms - #{describe(result.test)}" }.join("\n")}
48
+ SLOW
49
+ describe_tests_that_didnt_finish(planned_tests, test_results)
50
+ ].compact.join("\n\n")
51
+ end
52
+
53
+ after_suite test_results
54
+ end
55
+
56
+ def after_fail_fast planned_tests, wip_tests, test_results, last_result
57
+ unrun_tests = planned_tests - test_results.map(&:test) - wip_tests.map(&:test)
58
+
59
+ @err.print "\n\n"
60
+ wrap_in_horizontal_rule do
61
+ @err.print [
62
+ "Failing fast after #{describe(last_result.test, last_result.relevant_location)} #{last_result.error? ? "errored" : "failed"}.",
63
+ ("#{@icons.wip} #{plural wip_tests.size, "test was", "tests were"} cancelled in progress." if wip_tests.any?),
64
+ ("#{@icons.not_run} #{plural unrun_tests.size, "test was", "tests were"} not run at all." if unrun_tests.any?),
65
+ describe_tests_that_didnt_finish(planned_tests, test_results)
66
+ ].compact.join("\n\n")
67
+ end
68
+
69
+ after_suite test_results
70
+ end
71
+
72
+ def after_suite test_results
73
+ duration = time_diff @suite_start_time
74
+ test_results = test_results.sort_by { |result| [result.test.location.file, result.test.location.line] }
75
+
76
+ @out.print "\n\n"
77
+ @err.print summarize_failures(test_results).join("\n\n")
78
+
79
+ @out.print "\n\n"
80
+ @out.print summarize_skips(test_results).join("\n")
81
+
82
+ @out.print "\n\n"
83
+ @out.print "Finished in #{duration}ms."
84
+
85
+ @out.print "\n\n"
86
+ class_count = test_results.uniq { |result| result.test.class }.size
87
+ test_count = test_results.size
88
+ @out.print [
89
+ plural(class_count, "test class", "test classes"),
90
+ plural(test_count, "test method"),
91
+ plural(test_results.count(&:failure?), "failure"),
92
+ plural(test_results.count(&:error?), "error"),
93
+ plural(test_results.count(&:skip?), "skip")
94
+ ].join(", ")
95
+
96
+ @out.print "\n"
97
+ end
98
+
99
+ private
100
+
101
+ def summarize_failures results
102
+ failures = results.select { |result| result.failing? }
103
+ return failures if failures.empty?
104
+
105
+ ["Failing tests:"] + failures.map.with_index { |result, i| summarize_result result, i }
106
+ end
107
+
108
+ def summarize_result result, index
109
+ [
110
+ "#{index + 1}) #{describe(result.test, result.relevant_location)} #{result.failure? ? "failed" : "errored"}:",
111
+ result.error.message.chomp,
112
+ "\n Re-run this test:",
113
+ " #{tldr_command} #{@config.to_single_path_args(result.test.location.locator)}",
114
+ (TLDR.filter_backtrace(result.error.backtrace).join("\n") if @config.verbose)
115
+ ].compact.reject(&:empty?).join("\n").strip
116
+ end
117
+
118
+ def summarize_skips results
119
+ skips = results.select { |result| result.skip? }
120
+ return skips if skips.empty?
121
+
122
+ ["Skipped tests:\n"] + skips.map { |result| " - #{describe(result.test)}" }
123
+ end
124
+
125
+ def describe test, location = test.location
126
+ "#{test.klass}##{test.method} [#{location.locator}]"
127
+ end
128
+
129
+ def plural count, singular, plural = "#{singular}s"
130
+ "#{count} #{(count == 1) ? singular : plural}"
131
+ end
132
+
133
+ def wrap_in_horizontal_rule
134
+ rule = @icons.alarm + "=" * 20 + " ABORTED RUN " + "=" * 20 + @icons.alarm
135
+ @err.print "#{rule}\n\n"
136
+ yield
137
+ @err.print "\n\n#{rule}\n\n"
138
+ end
139
+
140
+ def describe_tests_that_didnt_finish planned_tests, test_results
141
+ unrun = planned_tests - test_results.map(&:test)
142
+ return if unrun.empty?
143
+
144
+ unrun_locators = consolidate unrun
145
+ failed = test_results.select(&:failing?).map(&:test)
146
+ failed_locators = consolidate failed, exclude: unrun_locators
147
+ suggested_locators = unrun_locators + [
148
+ ("--comment \"Also include #{plural failed.size, "test"} that failed:\"" if failed_locators.any?)
149
+ ] + failed_locators
150
+ <<~MSG
151
+ #{@icons.rock_on} Run the #{plural unrun.size, "test"} that didn't finish:
152
+ #{tldr_command} #{@config.to_full_args exclude: [:paths]} #{suggested_locators.join(" \\\n ")}
153
+ MSG
154
+ end
155
+
156
+ def consolidate tests, exclude: []
157
+ tests.group_by(&:file).map { |_, tests|
158
+ "\"#{tests.first.location.relative}:#{tests.map(&:line).uniq.sort.join(":")}\""
159
+ }.uniq - exclude
160
+ end
161
+
162
+ def tldr_command
163
+ "#{"bundle exec " if defined?(Bundler)}tldr"
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,93 @@
1
+ module IconProvider
2
+ class Base
3
+ def success
4
+ "."
5
+ end
6
+
7
+ def failure
8
+ "F"
9
+ end
10
+
11
+ def error
12
+ "E"
13
+ end
14
+
15
+ def skip
16
+ "S"
17
+ end
18
+
19
+ def tldr
20
+ "!"
21
+ end
22
+
23
+ def run
24
+ ""
25
+ end
26
+
27
+ def wip
28
+ ""
29
+ end
30
+
31
+ def slow
32
+ ""
33
+ end
34
+
35
+ def not_run
36
+ ""
37
+ end
38
+
39
+ def alarm
40
+ ""
41
+ end
42
+
43
+ def rock_on
44
+ ""
45
+ end
46
+ end
47
+
48
+ class Emoji < Base
49
+ def success
50
+ "😁"
51
+ end
52
+
53
+ def failure
54
+ "😡"
55
+ end
56
+
57
+ def error
58
+ "🤬"
59
+ end
60
+
61
+ def skip
62
+ "🫥"
63
+ end
64
+
65
+ def tldr
66
+ "🥵"
67
+ end
68
+
69
+ def run
70
+ "🏃"
71
+ end
72
+
73
+ def wip
74
+ "🙅"
75
+ end
76
+
77
+ def slow
78
+ "🐢"
79
+ end
80
+
81
+ def not_run
82
+ "🙈"
83
+ end
84
+
85
+ def alarm
86
+ "🚨"
87
+ end
88
+
89
+ def rock_on
90
+ "🤘"
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,4 @@
1
+ require "tldr/reporters/icon_provider"
2
+
3
+ require "tldr/reporters/base"
4
+ require "tldr/reporters/default"
@@ -0,0 +1,113 @@
1
+ require "irb"
2
+ require "concurrent"
3
+
4
+ class TLDR
5
+ class Runner
6
+ def initialize
7
+ @wip = Concurrent::Array.new
8
+ @results = Concurrent::Array.new
9
+ @run_aborted = Concurrent::AtomicBoolean.new false
10
+ end
11
+
12
+ def run config, plan
13
+ @wip.clear
14
+ @results.clear
15
+ reporter = config.reporter.new config
16
+ reporter.before_suite plan.tests
17
+
18
+ time_bomb = Thread.new {
19
+ explode = proc do
20
+ next if ENV["CI"] && !$stderr.tty?
21
+ next if @run_aborted.true?
22
+ @run_aborted.make_true
23
+ reporter.after_tldr plan.tests, @wip.dup, @results.dup
24
+ exit! 3
25
+ end
26
+
27
+ sleep 1.8
28
+ # Don't hard-kill the runner if user is debugging, it'll
29
+ # screw up their terminal slash be a bad time
30
+ if IRB.CurrentContext
31
+ IRB.conf[:AT_EXIT] << explode
32
+ else
33
+ explode.call
34
+ end
35
+ }
36
+
37
+ results = parallelize(plan.tests, config.parallel) { |test|
38
+ e = nil
39
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
40
+ wip_test = WIPTest.new test, start_time
41
+ @wip << wip_test
42
+ runtime = time_it(start_time) do
43
+ instance = test.klass.new
44
+ instance.setup if instance.respond_to? :setup
45
+ instance.send(test.method)
46
+ instance.teardown if instance.respond_to? :teardown
47
+ rescue Skip, Failure, StandardError => e
48
+ end
49
+ TestResult.new(test, e, runtime).tap do |result|
50
+ next if @run_aborted.true?
51
+ @results << result
52
+ @wip.delete wip_test
53
+ reporter.after_test result
54
+ fail_fast reporter, plan, result if result.failing? && config.fail_fast
55
+ end
56
+ }.tap do
57
+ time_bomb.kill
58
+ end
59
+
60
+ unless @run_aborted.true?
61
+ reporter.after_suite results
62
+ exit exit_code results
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def parallelize tests, parallel, &blk
69
+ return tests.map(&blk) if tests.size < 2 || !parallel
70
+ tldr_pool = Concurrent::ThreadPoolExecutor.new(
71
+ name: "tldr",
72
+ auto_terminate: true
73
+ )
74
+
75
+ tests.map { |test|
76
+ Concurrent::Promises.future_on(tldr_pool) {
77
+ blk.call test
78
+ }
79
+ }.flat_map(&:value)
80
+ end
81
+
82
+ def fail_fast reporter, plan, fast_failed_result
83
+ unless @run_aborted.true?
84
+ @run_aborted.make_true
85
+ abort = proc do
86
+ reporter.after_fail_fast plan.tests, @wip.dup, @results.dup, fast_failed_result
87
+ exit! exit_code([fast_failed_result])
88
+ end
89
+
90
+ if IRB.CurrentContext
91
+ IRB.conf[:AT_EXIT] << abort
92
+ else
93
+ abort.call
94
+ end
95
+ end
96
+ end
97
+
98
+ def time_it(start)
99
+ yield
100
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) - start) / 1000.0).round
101
+ end
102
+
103
+ def exit_code results
104
+ if results.any? { |result| result.error? }
105
+ 2
106
+ elsif results.any? { |result| result.failure? }
107
+ 1
108
+ else
109
+ 0
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,7 @@
1
+ class TLDR
2
+ module Skippable
3
+ def skip message = ""
4
+ raise Skip, message
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ class TLDR
2
+ class SorbetCompatibility
3
+ def self.unwrap_method method
4
+ return method unless defined? ::T::Private::Methods
5
+
6
+ T::Private::Methods.signature_for_method(method).method || method
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,217 @@
1
+ require "concurrent"
2
+
3
+ class TLDR
4
+ CONFLAGS = {
5
+ seed: "--seed",
6
+ no_helper: "--no-helper",
7
+ verbose: "--verbose",
8
+ reporter: "--reporter",
9
+ helper: "--helper",
10
+ load_paths: "--load-path",
11
+ parallel: "--[no-]parallel",
12
+ names: "--name",
13
+ fail_fast: "--fail-fast",
14
+ no_emoji: "--no-emoji",
15
+ prepend_tests: "--prepend",
16
+ no_prepend: "--no-prepend",
17
+ exclude_paths: "--exclude-path",
18
+ exclude_names: "--exclude-name",
19
+ base_path: "--base-path",
20
+ no_dotfile: "--no-dotfile",
21
+ paths: nil
22
+ }.freeze
23
+
24
+ PATH_FLAGS = [:paths, :helper, :load_paths, :prepend_tests, :exclude_paths].freeze
25
+ MOST_RECENTLY_MODIFIED_TAG = "MOST_RECENTLY_MODIFIED".freeze
26
+
27
+ Config = Struct.new :paths, :seed, :no_helper, :verbose, :reporter,
28
+ :helper, :load_paths, :parallel, :names, :fail_fast, :no_emoji,
29
+ :prepend_tests, :no_prepend, :exclude_paths, :exclude_names, :base_path,
30
+ :no_dotfile,
31
+ :seed_set_intentionally, :cli_mode, keyword_init: true do
32
+ def initialize(**args)
33
+ change_working_directory_because_i_am_bad_and_i_should_feel_bad!(args[:base_path])
34
+ args = merge_dotfile_args(args) unless args[:no_dotfile]
35
+
36
+ super(**merge_defaults(args))
37
+ end
38
+
39
+ # Must be set when the Config is first initialized
40
+ undef_method :cli_mode=, :no_dotfile=, :base_path=
41
+
42
+ def self.build_defaults(cli_mode = false)
43
+ common = {
44
+ seed: rand(10_000),
45
+ no_helper: false,
46
+ verbose: false,
47
+ reporter: Reporters::Default,
48
+ parallel: true,
49
+ names: [],
50
+ fail_fast: false,
51
+ no_emoji: false,
52
+ no_prepend: false,
53
+ exclude_paths: [],
54
+ exclude_names: [],
55
+ base_path: nil
56
+ }
57
+
58
+ if cli_mode
59
+ common.merge(
60
+ paths: Dir["test/**/*_test.rb", "test/**/test_*.rb"],
61
+ helper: "test/helper.rb",
62
+ load_paths: ["test"],
63
+ prepend_tests: [MOST_RECENTLY_MODIFIED_TAG]
64
+ )
65
+ else
66
+ common.merge(
67
+ paths: [],
68
+ helper: nil,
69
+ load_paths: [],
70
+ prepend_tests: []
71
+ )
72
+ end
73
+ end
74
+
75
+ def merge_defaults(user_args)
76
+ merged_args = user_args.dup
77
+ defaults = Config.build_defaults(merged_args[:cli_mode])
78
+ merged_args[:seed_set_intentionally] = !merged_args[:seed].nil?
79
+
80
+ # Special cases
81
+ if merged_args[:parallel].nil?
82
+ # Disable parallelization if seed is set
83
+ merged_args[:parallel] = merged_args[:seed].nil?
84
+ end
85
+
86
+ # Arrays
87
+ [:paths, :load_paths, :names, :prepend_tests, :exclude_paths, :exclude_names].each do |key|
88
+ merged_args[key] = defaults[key] if merged_args[key].nil? || merged_args[key].empty?
89
+ end
90
+
91
+ # Booleans
92
+ [:no_helper, :verbose, :fail_fast, :no_emoji, :no_prepend].each do |key|
93
+ merged_args[key] = defaults[key] if merged_args[key].nil?
94
+ end
95
+
96
+ # Values
97
+ [:seed, :reporter, :helper].each do |key|
98
+ merged_args[key] ||= defaults[key]
99
+ end
100
+
101
+ merged_args
102
+ end
103
+
104
+ # We needed this hook (to be called by the planner), because we can't know
105
+ # the default prepend location until we have all the resolved test paths,
106
+ # so we have to mutate it after the fact.
107
+ def update_after_gathering_tests! tests
108
+ return unless prepend_tests.include?(MOST_RECENTLY_MODIFIED_TAG)
109
+
110
+ self.prepend_tests = prepend_tests.map { |path|
111
+ if path == MOST_RECENTLY_MODIFIED_TAG
112
+ most_recently_modified_test_file tests
113
+ else
114
+ path
115
+ end
116
+ }.compact
117
+ end
118
+
119
+ def to_full_args(exclude: [])
120
+ to_cli_argv(CONFLAGS.keys - exclude).join(" ")
121
+ end
122
+
123
+ def to_single_path_args(path)
124
+ argv = to_cli_argv(CONFLAGS.keys - [
125
+ :seed, :parallel, :names, :fail_fast, :paths, :prepend_tests,
126
+ :no_prepend, :exclude_paths
127
+ ])
128
+
129
+ (argv + [stringify(:paths, path)]).join(" ")
130
+ end
131
+
132
+ private
133
+
134
+ def to_cli_argv(options = CONFLAGS.keys)
135
+ defaults = Config.build_defaults(cli_mode)
136
+ options.map { |key|
137
+ flag = CONFLAGS[key]
138
+
139
+ # Special cases
140
+ if key == :prepend_tests
141
+ if prepend_tests.map { |s| stringify(key, s) }.sort == paths.map { |s| stringify(:paths, s) }.sort
142
+ # Don't print prepended tests if they're the same as the test paths
143
+ next
144
+ elsif no_prepend
145
+ # Don't print prepended tests if they're disabled
146
+ next
147
+ end
148
+ elsif key == :helper && no_helper
149
+ # Don't print the helper if it's disabled
150
+ next
151
+ elsif key == :parallel
152
+ val = if !seed_set_intentionally && !parallel
153
+ "--no-parallel"
154
+ elsif !seed.nil? && seed_set_intentionally && parallel
155
+ "--parallel"
156
+ end
157
+ next val
158
+ end
159
+
160
+ if defaults[key] == self[key]
161
+ next
162
+ elsif self[key].is_a?(Array)
163
+ self[key].map { |value| [flag, stringify(key, value)] }
164
+ elsif self[key].is_a?(TrueClass) || self[key].is_a?(FalseClass)
165
+ flag if self[key]
166
+ elsif self[key].is_a?(Class)
167
+ [flag, self[key].name]
168
+ elsif !self[key].nil?
169
+ [flag, stringify(key, self[key])]
170
+ end
171
+ }.flatten.compact
172
+ end
173
+
174
+ def stringify key, val
175
+ if PATH_FLAGS.include?(key) && val.start_with?(Dir.pwd)
176
+ val = val[Dir.pwd.length + 1..]
177
+ end
178
+
179
+ if val.nil? || val.is_a?(Integer)
180
+ val
181
+ else
182
+ "\"#{val}\""
183
+ end
184
+ end
185
+
186
+ def most_recently_modified_test_file(tests)
187
+ return if tests.empty?
188
+
189
+ tests.max_by { |test| File.mtime(test.file) }.file
190
+ end
191
+
192
+ # If the user sets a custom base path, we need to change the working directory
193
+ # ASAP, even before globbing to find default paths of tests. If there is
194
+ # a way to change all of our Dir.glob calls to be relative to base_path
195
+ # without a loss in accuracy, would love to not have to use Dir.chdir!
196
+ def change_working_directory_because_i_am_bad_and_i_should_feel_bad!(base_path)
197
+ Dir.chdir(base_path) unless base_path.nil?
198
+ end
199
+
200
+ def merge_dotfile_args(args)
201
+ return args if args[:no_dotfile] || !File.exist?(".tldr.yml")
202
+ require "yaml"
203
+
204
+ dotfile_args = YAML.load_file(".tldr.yml").transform_keys { |k| k.to_sym }
205
+ # Since we don't have shell expansion, we have to glob any paths ourselves
206
+ if dotfile_args.key?(:paths)
207
+ dotfile_args[:paths] = dotfile_args[:paths].flat_map { |path| Dir[path] }
208
+ end
209
+ # The argv parser normally does this:
210
+ if dotfile_args.key?(:reporter)
211
+ dotfile_args[:reporter] = Kernel.const_get(dotfile_args[:reporter])
212
+ end
213
+
214
+ dotfile_args.merge(args)
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,15 @@
1
+ class TLDR
2
+ Location = Struct.new :file, :line do
3
+ def relative
4
+ if file.start_with?(Dir.pwd)
5
+ file[Dir.pwd.length + 1..]
6
+ else
7
+ file
8
+ end
9
+ end
10
+
11
+ def locator
12
+ "#{relative}:#{line}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ class TLDR
2
+ Plan = Struct.new :tests
3
+ end
@@ -0,0 +1,23 @@
1
+ class TLDR
2
+ Test = Struct.new :klass, :method, :file, :line do
3
+ attr_reader :location
4
+
5
+ def initialize(*args)
6
+ super
7
+ @location = Location.new(file, line)
8
+ end
9
+
10
+ # Memoizing at call time, because re-parsing isn't free and isn't usually necessary
11
+ def end_line
12
+ @end_line ||= begin
13
+ test_method = SorbetCompatibility.unwrap_method klass.instance_method(method)
14
+ RubyVM::AbstractSyntaxTree.of(test_method).last_lineno
15
+ end
16
+ end
17
+
18
+ # Test exact match starting line condition first to save us a potential re-parsing to look up end_line
19
+ def covers_line? l
20
+ line == l || (l >= line && l <= end_line)
21
+ end
22
+ end
23
+ end