deflake 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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Deflake
6
+ # Command-line entry point
7
+ class CLI
8
+ REPORTS = { "text" => TextReport, "json" => JsonReport }.freeze
9
+
10
+ def initialize(argv, out: $stdout, err: $stderr)
11
+ @argv = argv.dup
12
+ @out = out
13
+ @err = err
14
+ @options = { runs: 10, jobs: 1, format: "text", timeout: nil }
15
+ end
16
+
17
+ # Returns the process exit code
18
+ def run
19
+ dispatch
20
+ rescue OptionParser::ParseError, Error => e
21
+ complain(e.message, usage: e.is_a?(OptionParser::ParseError))
22
+ rescue Interrupt
23
+ interrupted
24
+ rescue StandardError => e
25
+ # 1 would mean flaky tests were found, so anything unexpected is a 2 as well
26
+ complain("unexpected error, please report it: #{e.class}: #{e.message}\n #{e.backtrace&.first(5)&.join("\n ")}")
27
+ end
28
+
29
+ private
30
+
31
+ def dispatch
32
+ case parse_command
33
+ when :version then @out.puts "deflake #{VERSION}"
34
+ when :help then @out.puts parser
35
+ else return run_tests
36
+ end
37
+ 0
38
+ end
39
+
40
+ # The test processes are stopped on the way out
41
+ def interrupted
42
+ @err.puts "deflake: interrupted"
43
+ 130
44
+ end
45
+
46
+ def complain(message, usage: false)
47
+ @err.puts "deflake: #{message}"
48
+ @err.puts parser if usage
49
+ 2
50
+ end
51
+
52
+ def parse_command
53
+ command = nil
54
+ define_options
55
+ parser.on("-v", "--version", "Print version and exit") { command = :version }
56
+ parser.on("-h", "--help", "Show this help and exit") { command = :help }
57
+ parser.parse!(@argv)
58
+ command || check_paths
59
+ end
60
+
61
+ def check_paths
62
+ raise OptionParser::MissingArgument, "TEST_PATH" if @argv.empty? || @argv.first.empty?
63
+ raise OptionParser::NeedlessArgument, "#{@argv.drop(1).join(" ")} (one TEST_PATH at a time)" if @argv.size > 1
64
+
65
+ :run
66
+ end
67
+
68
+ def define_options
69
+ count(:runs, "-n", "--runs N", "How many times to run the file (default: 10)")
70
+ count(:jobs, "-j", "--jobs N", "How many test processes to run at once (default: 1)")
71
+ count(:timeout, "-t", "--timeout SECONDS", "Stop a test process that runs longer (default: no limit)")
72
+ parser.on("-f", "--format FORMAT", REPORTS.keys, "Report format: text or json (default: text)") do |format|
73
+ @options[:format] = format
74
+ end
75
+ end
76
+
77
+ # Decimal, not Integer: Integer reads 010 as 8
78
+ def count(option, *switches)
79
+ parser.on(*switches[0, 2], OptionParser::DecimalInteger, switches[2]) { |n| @options[option] = positive(n) }
80
+ end
81
+
82
+ def positive(number)
83
+ raise OptionParser::InvalidArgument, "#{number} (must be 1 or more)" unless number.positive?
84
+
85
+ number
86
+ end
87
+
88
+ def run_tests
89
+ path, line = target
90
+ raise Error, "no such file: #{path}" unless File.file?(path)
91
+
92
+ Execution.timeout = @options[:timeout]
93
+ investigation = Investigation.new(path, line: line, runs: @options[:runs], jobs: @options[:jobs])
94
+ diagnoses = investigation.call
95
+ report(diagnoses, line, investigation)
96
+ end
97
+
98
+ # A file whose name itself ends in ":12" is that file, not line 12 of another. The path is
99
+ # taken as UTF-8 whatever the locale, as names in the report are
100
+ def target
101
+ argument = ResultFile.clean(@argv.first)
102
+ return [argument, nil] if File.file?(argument)
103
+
104
+ path, line = argument.match(/\A(.+?)(?::(\d+))?\z/m).captures
105
+ [path, line&.to_i]
106
+ end
107
+
108
+ # A test on the line that never started is an error: something stopped every run before it,
109
+ # or the file's own filters leave it out
110
+ def report(diagnoses, line, investigation)
111
+ not_run = investigation.not_run
112
+ if not_run.any?
113
+ @err.puts "deflake: #{not_run.join(", ")} never ran in a run of the whole file " \
114
+ "(a filter of the file leaves it out, or something stops every run before it)"
115
+ end
116
+ # As with a plain run, tests that all skipped themselves are not an error
117
+ @err.puts "deflake: #{skipped(line)}, nothing to report" if diagnoses.empty? && not_run.empty?
118
+ print_report(diagnoses, not_run: not_run, shared: investigation.shared_names)
119
+ diagnoses.all?(&:clean?) && not_run.empty? ? 0 : 1
120
+ end
121
+
122
+ # A reader that stops early, such as head, is not an error of deflake's
123
+ def print_report(diagnoses, **)
124
+ REPORTS.fetch(@options[:format]).new(@out).print(diagnoses, **)
125
+ rescue Errno::EPIPE
126
+ nil
127
+ end
128
+
129
+ def skipped(line)
130
+ line ? "the test at line #{line} skipped itself in every run" : "every test skipped itself"
131
+ end
132
+
133
+ def parser
134
+ @parser ||= OptionParser.new("Usage: deflake TEST_PATH[:LINE] [options]")
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # Tests that make another test fail when they run before it, and a command
5
+ # that shows the failure. ids is empty when bisect could not reproduce the failure,
6
+ # or when the test failed with that seed with no other test before it (alone)
7
+ Culprits = Data.define(:ids, :command, :names, :alone, :hidden, :group_wide) do
8
+ # names: readable test names, the ids themselves by default.
9
+ # group_wide: culprits that other tests of their group stand in for, as when the
10
+ # state comes from a hook of the group, such as before(:context), and not from the test.
11
+ # hidden: the failure did not happen again, but tests that cannot run by name ran in the file
12
+ def initialize(ids:, command:, names: ids, alone: false, hidden: false, group_wide: [])
13
+ super
14
+ end
15
+
16
+ def found? = ids.any?
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # Everything deflake found out about one test.
5
+ # isolated_runs and culprits are nil when there was nothing to investigate,
6
+ # and isolated_runs also when the failure could not run alone: errors outside of tests,
7
+ # or a test whose runs alone all broke before it started
8
+ Diagnosis = Data.define(:name, :id, :file_runs, :isolated_runs, :culprits) do
9
+ # :stable, :failed (could not run alone), :fails_on_its_own, :order_dependent, or :random:
10
+ # it passed every time alone, and the failure did not happen again with the same seed and order,
11
+ # with every test of the file that ran before it
12
+ def verdict
13
+ return :stable if file_runs.failures.zero?
14
+ return :failed if isolated_runs.nil?
15
+ return :fails_on_its_own if fails_alone?
16
+
17
+ culprits.nil? || culprits.found? || culprits.hidden ? :order_dependent : :random
18
+ end
19
+
20
+ # It failed in a run alone, or with no test before it in a bisect
21
+ def fails_alone?
22
+ isolated_runs.failures.positive? || culprits&.alone || false
23
+ end
24
+
25
+ # Nothing failed and nothing leaked
26
+ def clean?
27
+ verdict == :stable && file_runs.leaks.empty?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require_relative "outcome"
5
+
6
+ module Deflake
7
+ # Runs a test file once in a fresh process and returns one row per test that started, in run order
8
+ module Execution
9
+ # Id of the row that stands for errors outside of any test, such as in an after(:context) hook
10
+ OUTSIDE = "(outside of tests)"
11
+ # Runs the tests of a process one at a time
12
+ SERIAL = { "PARALLEL_WORKERS" => "1", "MT_CPU" => "1" }.freeze
13
+ # Raised when a test process wrote no results at all; output is what it printed,
14
+ # ending how the process ended, such as "timed out after 5 seconds"
15
+ class NoResults < Error
16
+ attr_reader :output, :ending
17
+
18
+ # success: whether the process itself exited with success
19
+ def initialize(target, output, ending = nil, success: false)
20
+ @output = output
21
+ @ending = ending
22
+ @success = success
23
+ super("#{target} produced no test results#{" (the test process #{ending})" if ending}:\n#{output}")
24
+ end
25
+
26
+ def success? = @success
27
+ end
28
+
29
+ class << self
30
+ # Seconds a test process may run before it is killed, nil for no limit
31
+ attr_accessor :timeout
32
+
33
+ # only: what to run instead of the whole file; env: extra environment variables for the test process
34
+ def call(adapter, path, seed:, only: nil, env: {})
35
+ complete(run(adapter, path, seed, only, env), only || path).rows
36
+ end
37
+
38
+ # The ids of the whole file's tests in the order they ran, and whether tests that cannot run
39
+ # by name, such as those in a class without a name, ran too
40
+ def order(adapter, path, seed:)
41
+ process = complete(run(adapter, path, seed, nil, {}), path)
42
+ [process.rows.map { |row| row["id"] }, process.unnamed.any?]
43
+ end
44
+
45
+ # Tries again when the test process wrote no results, as a flaky boot does now and then
46
+ def retrying(attempts = 3)
47
+ yield
48
+ rescue NoResults
49
+ attempts -= 1
50
+ retry if attempts.positive?
51
+ raise
52
+ end
53
+
54
+ # Runs the whole file and adds an OUTSIDE row telling whether the process failed on its own.
55
+ # A process that wrote no results at all is one failed OUTSIDE row carrying its output
56
+ def whole_file(adapter, path, seed:, env: {})
57
+ process = run(adapter, path, seed, nil, env)
58
+ return [no_results_row(seed, process)] unless process.rows
59
+
60
+ process.rows + [outside_row(process, seed)]
61
+ end
62
+
63
+ # Runs only some tests. As in whole_file, a process that wrote no results is a failed OUTSIDE
64
+ # row, and so is one where none of these tests started: only a failure that cannot run by
65
+ # name, such as a class-level hook, may have run
66
+ def some(adapter, path, seed:, only:, env: {})
67
+ process = run(adapter, path, seed, only, env)
68
+ process.rows.nil? || process.rows.empty? ? [no_results_row(seed, process)] : process.rows
69
+ end
70
+
71
+ def no_results_row(seed, process)
72
+ message = "the test process #{ending(process.status)} before any test finished"
73
+ failures = outside_failures(process)
74
+ error = failures ? "#{message}:\n#{failures}" : with_tail(message, process.output)
75
+ { "id" => OUTSIDE, "name" => "errors outside of tests", "passed" => false, "seed" => seed,
76
+ "error" => error, "output" => process.output, "ending" => ending(process.status), "leaks" => [] }
77
+ end
78
+
79
+ def ending(status) = Child.ending(status)
80
+
81
+ def with_tail(message, output) = Tail.with(message, output)
82
+
83
+ private
84
+
85
+ def complete(process, target)
86
+ return process if process.rows
87
+
88
+ raise NoResults.new(ResultFile.clean(Array(target).join(" ")), process.output, ending(process.status),
89
+ success: process.status.success?)
90
+ end
91
+
92
+ def run(adapter, path, seed, only, env)
93
+ Dir.mktmpdir do |dir|
94
+ output = File.join(dir, "results.jsonl")
95
+ log = File.join(dir, "output.log")
96
+ # Rails' parallelize and Minitest's parallelize_me! would run tests at once, in no fixed
97
+ # order, and the leaks of one would show up in the others. Variables a test process of an
98
+ # outer deflake got are not passed on
99
+ env = ResultFile::KEYS.to_h { |key| [key, nil] }
100
+ .merge(env, adapter.env(only), ResultFile::ENV_KEY => output, **SERIAL)
101
+ status = Child.run(env, adapter.command(path, seed: seed, only: only), log, timeout)
102
+ finish(File.exist?(output) ? ResultFile.read(output) : nil, seed, status, ResultFile.clean(Tail.read(log)))
103
+ end
104
+ end
105
+
106
+ # A test that started and never finished took its process down with it, so it counts as failed.
107
+ # A failure that cannot be run alone by name, such as a test in a class without a name or a
108
+ # class-level hook, is told about as an error outside of tests. A process that wrote nothing,
109
+ # or failed before any test, has no rows
110
+ def finish(rows, seed, status, output)
111
+ return Outcome.new(nil, [], [], status, output) unless rows
112
+
113
+ outside, rows = rows.partition { |row| row.key?("outside") }
114
+ unnamed, rows = rows.partition { |row| row.key?("unnamed") }
115
+ outcome = Outcome.new(tests(rows, seed, status, output), unnamed, outside, status, output)
116
+ outcome.rows = nil unless outcome.rows.any? || outcome.unnamed_failures.any? || status.success?
117
+ outcome
118
+ end
119
+
120
+ def tests(rows, seed, status, output)
121
+ started, finished = without_repeats(rows).partition { |row| row.key?("started") }
122
+ finished + lost(started, finished, seed, status, output)
123
+ end
124
+
125
+ # One result each time a test starts: RSpec may report an example again, without starting it,
126
+ # when its group breaks afterwards
127
+ def without_repeats(rows)
128
+ running = Hash.new(0)
129
+ rows.select do |row|
130
+ next running[row["started"]] += 1 if row.key?("started")
131
+ next true unless row.key?("id")
132
+
133
+ running[row["id"]].positive?.tap { |started| running[row["id"]] -= 1 if started }
134
+ end
135
+ end
136
+
137
+ def lost(started, finished, seed, status, output)
138
+ done = finished.to_h { |row| [row["id"], true] }
139
+ message = "the test process #{ending(status)} during this test"
140
+ started.reject { |row| done[row["started"]] }.uniq { |row| row["started"] }.map do |row|
141
+ # What the output shows before the crash changes with the order of the tests
142
+ { "id" => row["started"], "name" => row["name"], "passed" => false, "seed" => seed,
143
+ "error" => with_tail(message, output), "group" => message,
144
+ "timed_out" => status.is_a?(TimedOut), "leaks" => [] }
145
+ end
146
+ end
147
+
148
+ # A process that failed while every test passed broke somewhere else, for example in a hook.
149
+ # Errors outside of tests that the test framework told about count even when a test failed.
150
+ # ran: whether any test ran, even one that cannot run by name
151
+ def outside_row(process, seed)
152
+ error = outside_failures(process)
153
+ passed = error.nil? && (process.status.success? || process.failed_test?)
154
+ { "id" => OUTSIDE, "name" => "errors outside of tests", "passed" => passed, "seed" => seed,
155
+ "error" => (passed ? nil : error || outside_tail(process)), "leaks" => process.leaks, "ran" => process.ran? }
156
+ end
157
+
158
+ def outside_tail(process)
159
+ with_tail("the test process #{ending(process.status)} although no test failed", process.output)
160
+ end
161
+
162
+ # Sorted, as the order they happened in changes with the seed
163
+ def outside_failures(process)
164
+ failures = process.unnamed_failures.map { |row| "#{row["unnamed"]}: #{row["error"]}" }.uniq.sort
165
+ failures.unshift("a failure deflake cannot run alone by name:") if failures.any?
166
+ text = (process.outside.filter_map { |row| row["outside"] }.uniq.sort + failures).join("\n")
167
+ text unless text.empty?
168
+ end
169
+ end
170
+
171
+ TimedOut = Child::TimedOut
172
+ end
173
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ # Runs a test file many times, then reruns every failing test on its own
5
+ # to tell a flaky test from one broken by its neighbours, and bisects the latter
6
+ class Investigation
7
+ # How many failing seeds bisect tries before it gives up
8
+ SEEDS = 3
9
+
10
+ # line: look only at the test on this line, but still run the whole file
11
+ def initialize(path, runs:, line: nil, jobs: 1, adapter: Adapters.for(path))
12
+ @path = path
13
+ @runs = runs
14
+ @line = line
15
+ @jobs = jobs
16
+ @adapter = adapter
17
+ @names = {}
18
+ end
19
+
20
+ # Ids of the tests on the line that never started in any run, as when an earlier test
21
+ # always takes the process down
22
+ attr_reader :not_run
23
+
24
+ # Returns an array of Diagnosis, sorted by test name. It is empty when every test skipped itself.
25
+ # With a line, errors outside of tests still show up: they may be why the test never ran
26
+ def call
27
+ # Before the runs, so a line without a test is reported right away
28
+ focus = @line && @adapter.focus(@path, @line)
29
+ summaries = focused(focus)
30
+ summaries.values.sort_by { |summary| [summary.name, summary.id] }.map { |summary| diagnose(summary) }
31
+ end
32
+
33
+ # Names that more than one test of the file has, so a report tells them apart by their ids
34
+ def shared_names
35
+ @names.values.tally.select { |_, count| count > 1 }.keys
36
+ end
37
+
38
+ private
39
+
40
+ # Summaries of the whole file, only of the tests in focus and errors outside of tests when there is one
41
+ def focused(focus)
42
+ file = runner
43
+ summaries = file.call
44
+ # Of every test that ran, including those that skipped themselves
45
+ @names = file.names
46
+ @not_run = focus ? focus - file.seen : []
47
+ return summaries unless focus
48
+
49
+ summaries.select { |id, _| focus.include?(id) || id == Execution::OUTSIDE }
50
+ end
51
+
52
+ def runner(only: nil)
53
+ Runner.new(@path, runs: @runs, only: only, jobs: @jobs, adapter: @adapter)
54
+ end
55
+
56
+ def diagnose(summary)
57
+ diagnosis = Diagnosis.new(name: summary.name, id: summary.id, file_runs: summary, isolated_runs: nil,
58
+ culprits: nil)
59
+ # Errors outside of tests cannot run on their own
60
+ return diagnosis if summary.failures.zero? || summary.id == Execution::OUTSIDE
61
+
62
+ diagnosis = diagnosis.with(isolated_runs: isolated(summary))
63
+ seeds = bisect_seeds(diagnosis)
64
+ seeds.empty? ? diagnosis : diagnosis.with(culprits: bisect(summary, seeds))
65
+ end
66
+
67
+ # Failing seeds worth a bisect. A test that fails on its own may also fail after other tests,
68
+ # with an error it never had alone: its seeds are bisected too
69
+ def bisect_seeds(diagnosis)
70
+ errors = diagnosis.file_runs.errors
71
+ case diagnosis.verdict
72
+ when :order_dependent then seeds_of(errors)
73
+ when :fails_on_its_own then seeds_of(errors.except(*diagnosis.isolated_runs.errors.keys))
74
+ else []
75
+ end
76
+ end
77
+
78
+ # The first seed of every error first, as a single odd failure may be the first one
79
+ def seeds_of(errors)
80
+ firsts = errors.values.map(&:first)
81
+ (firsts + errors.values.flatten).uniq.first(SEEDS)
82
+ end
83
+
84
+ # When no run alone reached the test, nil: the test failed, and whether it fails on its own
85
+ # is not known. A test that skipped itself every time it ran alone, and fails only after
86
+ # other tests, is order dependent
87
+ def isolated(summary)
88
+ alone = runner(only: summary.id)
89
+ alone.call
90
+ alone.all[summary.id]
91
+ rescue Execution::NoResults
92
+ nil
93
+ end
94
+
95
+ # Tries the seeds one by one until bisect finds culprits, or finds the test failing alone
96
+ def bisect(summary, seeds)
97
+ culprits = nil
98
+ seeds.each do |seed|
99
+ culprits = @adapter.bisect(@path, summary.id, seed: seed)
100
+ break if culprits.found? || culprits.alone
101
+ end
102
+ culprits.with(names: culprits.ids.map { |id| label(id, culprits.group_wide.include?(id)) })
103
+ end
104
+
105
+ # Tests with the same name are told apart by their ids
106
+ def label(id, group_wide)
107
+ name = @names.fetch(id, id)
108
+ name = "#{name} (#{id})" if @names.count { |_, other| other == name } > 1
109
+ group_wide ? "#{name} or any other test of its group (a hook of the group?)" : name
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Deflake
6
+ # Prints diagnoses as JSON for CI and other tools. The format may change before 1.0
7
+ class JsonReport
8
+ def initialize(out)
9
+ @out = out
10
+ end
11
+
12
+ # not_run: ids of tests on the line that never started, each one an object with the verdict "not_run"
13
+ def print(diagnoses, not_run: [], **)
14
+ objects = diagnoses.map { |diagnosis| as_json(diagnosis) } + not_run.map { |id| never_ran(id) }
15
+ # Names and paths in another encoding, as under LANG=C, would stop the generator
16
+ @out.puts JSON.pretty_generate(ResultFile.clean(objects))
17
+ end
18
+
19
+ private
20
+
21
+ def as_json(diagnosis)
22
+ runs = diagnosis.file_runs
23
+ { name: diagnosis.name, id: diagnosis.id, verdict: diagnosis.verdict,
24
+ runs: counts(runs), isolated_runs: diagnosis.isolated_runs && counts(diagnosis.isolated_runs),
25
+ errors: runs.errors.map { |message, seeds| { message: message, seeds: seeds } },
26
+ leaks: runs.leaks.map { |message, count| { message: message, runs: count } },
27
+ culprits: diagnosis.culprits&.to_h }
28
+ end
29
+
30
+ def never_ran(id)
31
+ { name: id, id: id, verdict: :not_run, runs: counts(Summary.new([], id: id)), isolated_runs: nil,
32
+ errors: [], leaks: [], culprits: nil }
33
+ end
34
+
35
+ def counts(summary)
36
+ # Not rounded: one failure in thousands of runs is not 0
37
+ { total: summary.total, failures: summary.failures, failure_rate: summary.failure_rate,
38
+ skipped: summary.skipped }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # Keys left in Rails.cache, when it is a MemoryStore
6
+ class Cache
7
+ # How many of the new keys a message lists
8
+ SHOWN = 3
9
+
10
+ def snapshot
11
+ return unless defined?(::Rails) && ::Rails.respond_to?(:cache)
12
+ return unless defined?(::ActiveSupport::Cache::MemoryStore)
13
+
14
+ store = ::Rails.cache
15
+ store.instance_variable_get(:@data).keys if store.is_a?(::ActiveSupport::Cache::MemoryStore)
16
+ end
17
+
18
+ def diff(before, after)
19
+ added = after - before
20
+ return [] if added.empty?
21
+
22
+ # Long numbers, such as times and versions, change from run to run
23
+ listed = added.first(SHOWN).map { |key| key.inspect.gsub(/\d{6,}/, "N") }.join(", ")
24
+ listed += ", ..." if added.size > SHOWN
25
+ ["Rails.cache has #{added.size} new #{added.size == 1 ? "key" : "keys"}: #{listed}"]
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deflake
4
+ module Leaks
5
+ # Time.now frozen or moved away from the real clock: travel_to, freeze_time or Timecop left on
6
+ class Clock
7
+ # Seconds Time.now may be off without being a leak: the real clock moves while it is read
8
+ TOLERANCE = 1
9
+ # How long to watch Time.now to tell whether it moves
10
+ TICK = 0.0001
11
+ # The wait for the clock to move must end whatever happens
12
+ CHECKS = 100_000
13
+ # Taken when deflake loads, before the tests: Timecop's mock_process_clock replaces
14
+ # Process.clock_gettime, and the real clock would move with the mocked one
15
+ CLOCK = Process.method(:clock_gettime)
16
+
17
+ # [offset from the real clock in seconds, whether Time.now stands still]
18
+ def snapshot
19
+ first = now
20
+ start = real
21
+ CHECKS.times { break if real - start >= TICK }
22
+ [first.to_f - start, now == first]
23
+ end
24
+
25
+ # Where the clock ends up counts: a test that brings back a clock another test moved
26
+ # leaves nothing behind, and one that moves it says how far from the real clock it is
27
+ def diff(before, after)
28
+ (offset_before, frozen_before), (offset_after, frozen_after) = before, after
29
+ return [] if frozen_before
30
+ if frozen_after
31
+ # Without the offset, which grows with every second the clock stands still
32
+ return ["Time.now is frozen (freeze_time or travel_to without travel_back?)"]
33
+ end
34
+ return [] if (offset_after - offset_before).abs < TOLERANCE || offset_after.abs < TOLERANCE
35
+
36
+ ["Time.now is#{shift(offset_after)} (travel_to without travel_back?)"]
37
+ end
38
+
39
+ private
40
+
41
+ def now = ::Time.now
42
+ def real = CLOCK.call(Process::CLOCK_REALTIME)
43
+
44
+ def shift(seconds)
45
+ return "" if seconds.abs < TOLERANCE
46
+
47
+ " #{duration(seconds.abs)} #{seconds.positive? ? "ahead of" : "behind"} the real clock"
48
+ end
49
+
50
+ # In whole seconds, so a day measured a moment early is still a day
51
+ def duration(seconds)
52
+ seconds = seconds.round
53
+ [[86_400, "day"], [3600, "hour"], [60, "minute"], [1, "second"]].each do |size, unit|
54
+ count = seconds.fdiv(size).round
55
+ return "#{count} #{unit}#{"s" unless count == 1}" if count.positive? && seconds >= size
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end