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