test-map 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ce3e91a8ab0098bedae3516d60ec4591513a5bd31507b143373627e3bd96ad3
4
- data.tar.gz: 736396db3f44850d11fdeb3a0a0bca2bed9ecdb5f551439eed75aad59613b79c
3
+ metadata.gz: 502e7e183acdd6b0f2576ad014d920a1c00b4233f08cdfaf57243ed4814b1c1b
4
+ data.tar.gz: 4d50c652f8ffa46c485ab2da15c5ddd5da022cd856aac394375679f07ea083b8
5
5
  SHA512:
6
- metadata.gz: d80393696fdf9dfad334535bd560ddf4e93731d99ba77993a31c3243d8902f7cf30001cf56c2fe37b4ffa6ba5ad450329dd466ef2aa4119316e3344c0ce0c834
7
- data.tar.gz: b0a20a539dec75bf6c27e47dc50c47dabd70cf68096fa83730950ede937c1445ddd8ed763814bf02b4040c2bcd47e32431379c9c78cde4e7ebc03ce2b7c912dc
6
+ metadata.gz: 60480addcb32f9528c65895488190f4a7c01623b437e9818ff81e85f081b4e884e8fc3afb5684b4105b2eb52010ae1f1e182e2fbae0574a690c2404b86af3314
7
+ data.tar.gz: 61ce5a7bb1bd602f018e6e64b141bdfddbf52bbd61c72794796823d04b8b0a225f1748a6d66afe1d0540bfd85e31d34ed3cc3dd9c368ad543a2ecacfe0f898a5
data/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
6
6
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
7
7
  and this project adheres to [Semantic Versioning](http://semver.org/).
8
8
 
9
+ ## 0.4.0 - 2026-03-07
10
+
11
+ Extend reporting and cache handling for minitest and rspec. Add caches at
12
+ processing and profiling events. Optimize execution by reducing excessive
13
+ calls and calculations.
14
+
9
15
  ## 0.3.0 - 2026-03-01
10
16
 
11
17
  Introduce test cache and remove changes runner. Removed `test:changes` Rake
data/README.md CHANGED
@@ -17,6 +17,55 @@ Add test-map to your Gemfile.
17
17
  $ bundle add test-map
18
18
  ```
19
19
 
20
+ Require test-map in your test helper or spec helper.
21
+
22
+ ```ruby
23
+ # filename: test/test_helper.rb
24
+ require 'test_map'
25
+ ```
26
+
27
+ ## Example Run
28
+
29
+ Running the testsuite, test-map creates a mapping of tests to their code files,
30
+ as well as a test-file result cache. Running the testsuite again, all
31
+ successfully run tests are cached and skipped. Chaning a file, **only tests
32
+ that need to be run are executed**.
33
+
34
+ ```sh
35
+ # Running the testsuite for the first time, all tests are executed and mapped
36
+ > be rake
37
+ Run options: --seed 10112
38
+
39
+ # Running:
40
+
41
+ .......................................................
42
+
43
+ Finished in 0.042190s, 1303.6402 runs/s, 1730.2861 assertions/s.
44
+ 55 runs, 73 assertions, 0 failures, 0 errors, 0 skips, 0 cached
45
+
46
+ # Running again without changes, all tests are cached and skipped
47
+ > be rake
48
+ Run options: --seed 40581
49
+
50
+ # Running:
51
+
52
+ CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
53
+
54
+ Finished in 0.007902s, 6960.1126 runs/s, 0.0000 assertions/s.
55
+ 55 runs, 0 assertions, 0 failures, 0 errors, 0 skips, 55 cached
56
+
57
+ # Change a file and rerun the testsuite
58
+ > be rake
59
+ Run options: --seed 47682
60
+
61
+ # Running:
62
+
63
+ CCCCCCCCCC...CCCCCCCCCCCC.....CCCCCCCCCCCCCCCCCCCCCCCCC
64
+
65
+ Finished in 0.014029s, 3920.3764 runs/s, 570.2366 assertions/s.
66
+ 55 runs, 8 assertions, 0 failures, 0 errors, 0 skips, 47 cached
67
+ ```
68
+
20
69
  ### Minitest
21
70
 
22
71
  Include test-map in your test helper.
@@ -12,13 +12,16 @@ module TestMap
12
12
  @cache_file = cache_file
13
13
  @map_file = map_file
14
14
  @root = root
15
+ @global_files_changed = nil
16
+ @current_checksums = {}
17
+ @file_exists_cache = {}
15
18
  end
16
19
 
17
20
  def fresh?(test_file)
18
21
  return false unless cached_checksums
19
22
  return false if global_files_changed?
20
23
 
21
- files_to_check = [test_file] + source_files_for(test_file)
24
+ files_to_check = [test_file].concat(source_files_for(test_file))
22
25
  files_to_check.all? { |f| file_exist?(f) && current_checksum(f) == cached_checksums[f] }
23
26
  end
24
27
 
@@ -37,7 +40,9 @@ module TestMap
37
40
  end
38
41
 
39
42
  def global_files_changed?
40
- GLOBAL_FILES.any? do |f|
43
+ return @global_files_changed unless @global_files_changed.nil?
44
+
45
+ @global_files_changed = GLOBAL_FILES.any? do |f|
41
46
  file_exist?(f) && current_checksum(f) != cached_checksums[f]
42
47
  end
43
48
  end
@@ -60,10 +65,14 @@ module TestMap
60
65
  end
61
66
 
62
67
  def current_checksum(file)
63
- Digest::SHA256.file(File.join(@root, file)).hexdigest
68
+ @current_checksums[file] ||= Digest::SHA256.file(File.join(@root, file)).hexdigest
64
69
  end
65
70
 
66
- def file_exist?(file) = File.exist?(File.join(@root, file))
71
+ def file_exist?(file)
72
+ return @file_exists_cache[file] if @file_exists_cache.key?(file)
73
+
74
+ @file_exists_cache[file] = File.exist?(File.join(@root, file))
75
+ end
67
76
 
68
77
  def collect_tracked_files(results)
69
78
  sources = results.keys
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestMap
4
+ # Publish/subscribe event bus. No overhead when no subscribers.
5
+ #
6
+ # Topics use `type.action` format, e.g. `cache.create`, `map.create`.
7
+ #
8
+ # Block form measures elapsed time and passes it to subscribers:
9
+ # Event.publish('cache.create') { write_cache }
10
+ #
11
+ # Fire-and-forget form for simple notifications:
12
+ # Event.publish('cache.found', test_file:)
13
+ module Event
14
+ @subscribers = {}
15
+
16
+ class << self
17
+ def subscribe(topic, &block)
18
+ (@subscribers[topic] ||= []) << block
19
+ end
20
+
21
+ def publish(topic, **payload, &block)
22
+ listeners = @subscribers[topic]
23
+ return notify(listeners, topic, **payload) unless block
24
+ return yield unless listeners
25
+
26
+ publish_with_timing(listeners, topic, **payload, &block)
27
+ end
28
+
29
+ def reset
30
+ @subscribers = {}
31
+ end
32
+
33
+ private
34
+
35
+ def notify(listeners, topic, **payload)
36
+ listeners&.each { |cb| cb.call(topic, **payload) }
37
+ end
38
+
39
+ def publish_with_timing(listeners, topic, **payload)
40
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ result = yield
42
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
43
+ listeners.each { |cb| cb.call(topic, elapsed:, **payload) }
44
+ result
45
+ end
46
+ end
47
+ end
48
+ end
@@ -26,8 +26,9 @@ module TestMap
26
26
  def results
27
27
  raise NotTracedError.default unless @trace
28
28
 
29
- @files.filter { _1.start_with? Dir.pwd }
30
- .map { _1.sub("#{Dir.pwd}/", '') }
29
+ cwd = "#{Dir.pwd}/"
30
+ @files.filter { _1.start_with? cwd }
31
+ .map { _1.sub(cwd, '') }
31
32
  .then { Filter.call _1 }
32
33
  end
33
34
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestMap
4
+ module Plugins
5
+ module Minitest
6
+ # Reporter that tracks cached test count and appends it to the summary.
7
+ class CacheReporter < ::Minitest::StatisticsReporter
8
+ attr_accessor :cached, :composite
9
+
10
+ def initialize(io = $stdout, options = {}, composite: nil)
11
+ super(io, options)
12
+ self.cached = 0
13
+ self.composite = composite
14
+ end
15
+
16
+ def record(result)
17
+ super
18
+ self.cached += 1 if result.failure.is_a?(TestMap::CachedSkip)
19
+ end
20
+
21
+ def report
22
+ super
23
+ return unless composite
24
+
25
+ summary_reporter = composite.reporters.find { |r| r.is_a?(::Minitest::SummaryReporter) }
26
+ return unless summary_reporter
27
+
28
+ summary_reporter.results.reject! { |r| r.failure.is_a?(TestMap::CachedSkip) }
29
+ summary_reporter.skips -= cached if summary_reporter.skips
30
+ patch_summary(summary_reporter)
31
+ end
32
+
33
+ private
34
+
35
+ def patch_summary(summary_reporter)
36
+ cached_count = cached
37
+ original_summary = summary_reporter.method(:summary)
38
+
39
+ summary_reporter.define_singleton_method(:summary) do
40
+ "#{original_summary.call}, #{cached_count} cached"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ module Minitest # :nodoc:
49
+ def self.plugin_test_map_cache_init(options)
50
+ cache_reporter = TestMap::Plugins::Minitest::CacheReporter.new(options[:io], options, composite: reporter)
51
+ reporter.reporters.unshift(cache_reporter)
52
+ end
53
+ end
@@ -1,5 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module TestMap
4
+ # CachedSkip is raised to skip tests whose files haven't changed.
5
+ # Subclasses Minitest::Skip so Minitest treats it as a skip, but the
6
+ # custom reporter can distinguish it and show `C` instead of `S`.
7
+ class CachedSkip < Minitest::Skip
8
+ def initialize(msg = 'test-map: cached')
9
+ super
10
+ end
11
+
12
+ def result_label
13
+ 'Cached'
14
+ end
15
+ end
16
+ end
17
+
18
+ require_relative 'minitest/cache_reporter'
19
+
3
20
  module TestMap
4
21
  module Plugins
5
22
  # Minitest plugin for TestMap.
@@ -9,6 +26,11 @@ module TestMap
9
26
  TestMap.suite_passed = true
10
27
 
11
28
  ::Minitest.after_run { write_results }
29
+ install_cache_reporter
30
+ end
31
+
32
+ def self.install_cache_reporter
33
+ ::Minitest.extensions << 'test_map_cache'
12
34
  end
13
35
 
14
36
  def self.write_results
@@ -32,22 +54,18 @@ module TestMap
32
54
  end
33
55
  end
34
56
 
35
- def after_setup
57
+ def before_setup
36
58
  test_file = resolve_test_file
37
- if test_file && TestMap.cache.fresh?(test_file)
38
- @_test_map_skipped = true
39
- skip 'test-map: cached'
40
- else
41
- @recorder = FileRecorder.new.tap(&:trace)
42
- end
59
+ raise TestMap::CachedSkip if test_file && TestMap.cache.fresh?(test_file)
43
60
 
61
+ @recorder = FileRecorder.new.tap(&:trace)
44
62
  super
45
63
  end
46
64
 
47
65
  def before_teardown
48
66
  super
49
67
 
50
- return if @_test_map_skipped || !@recorder
68
+ return unless @recorder
51
69
 
52
70
  @recorder.stop
53
71
  TestMap.reporter.add @recorder.results
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core/formatters/progress_formatter'
4
+
5
+ module TestMap
6
+ module Plugins
7
+ module RSpec
8
+ # Formatter that shows `C` for cached tests instead of `*` (pending).
9
+ # Extends ProgressFormatter so it can serve as the default formatter.
10
+ # Adjusts the summary to show cached count and filters cached tests
11
+ # from the pending output.
12
+ class CacheFormatter < ::RSpec::Core::Formatters::ProgressFormatter
13
+ CACHED_MESSAGE = 'test-map: cached'
14
+
15
+ ::RSpec::Core::Formatters.register self,
16
+ :example_passed, :example_pending, :example_failed,
17
+ :start_dump, :dump_summary, :dump_pending
18
+
19
+ def initialize(output)
20
+ super
21
+ @cached_count = 0
22
+ end
23
+
24
+ def example_pending(notification)
25
+ if cached?(notification.example)
26
+ @cached_count += 1
27
+ output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap('c', :cyan)
28
+ else
29
+ super
30
+ end
31
+ end
32
+
33
+ def dump_pending(notification)
34
+ examples = notification.pending_examples
35
+ cached = examples.select { |ex| cached?(ex) }
36
+ return if examples.size == cached.size
37
+
38
+ cached.each { |ex| examples.delete(ex) }
39
+ super
40
+ cached.each { |ex| examples.push(ex) }
41
+ end
42
+
43
+ def dump_summary(summary)
44
+ if @cached_count.positive?
45
+ cached_count = @cached_count
46
+ original_totals_line = summary.method(:totals_line)
47
+ summary.define_singleton_method(:totals_line) do
48
+ "#{original_totals_line.call}, #{cached_count} cached"
49
+ end
50
+ end
51
+ super
52
+ end
53
+
54
+ private
55
+
56
+ def cached?(example)
57
+ example.execution_result.pending_message == CACHED_MESSAGE
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,14 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'rspec/cache_formatter'
4
+
3
5
  TestMap.logger.info 'Loading RSpec plugin'
4
6
  TestMap.suite_passed = true
5
7
 
8
+ module TestMap
9
+ module Plugins
10
+ # RSpec integration for TestMap.
11
+ module RSpec
12
+ def self.write_results
13
+ out_file = "#{Dir.pwd}/#{Config.config[:out_file]}"
14
+ reporter_results = TestMap.reporter.results
15
+
16
+ # All tests were cache-skipped, existing files are still valid
17
+ return if reporter_results.empty?
18
+
19
+ full_results = merge_results(out_file, reporter_results)
20
+ File.write(out_file, full_results.to_yaml)
21
+ TestMap.cache.write(full_results) if TestMap.suite_passed
22
+ end
23
+
24
+ # Merge with existing map to preserve mappings for cache-skipped tests
25
+ def self.merge_results(out_file, reporter_results)
26
+ if File.exist?(out_file)
27
+ TestMap.reporter.merge(reporter_results, YAML.safe_load_file(out_file))
28
+ else
29
+ reporter_results
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
6
36
  RSpec.configure do |config|
7
- config.around(:example) do |example|
8
- test_file = example.metadata[:file_path].sub("#{Dir.pwd}/", '').sub(%r{^\./}, '')
37
+ config.default_formatter = TestMap::Plugins::RSpec::CacheFormatter
9
38
 
10
- if TestMap.cache.fresh?(test_file)
11
- skip 'test-map: cached'
39
+ config.before(:context) do
40
+ test_file = self.class.metadata[:file_path].sub("#{Dir.pwd}/", '').sub(%r{^\./}, '')
41
+ skip TestMap::Plugins::RSpec::CacheFormatter::CACHED_MESSAGE if TestMap.cache.fresh?(test_file)
42
+ end
43
+
44
+ config.around(:example) do |example|
45
+ if example.metadata[:skip]
46
+ example.run
12
47
  else
13
48
  recorder = TestMap::FileRecorder.new
14
49
  recorder.trace { example.run }
@@ -18,21 +53,5 @@ RSpec.configure do |config|
18
53
  end
19
54
  end
20
55
 
21
- config.after(:suite) do
22
- out_file = "#{Dir.pwd}/#{TestMap::Config.config[:out_file]}"
23
- reporter_results = TestMap.reporter.results
24
-
25
- # All tests were cache-skipped, existing files are still valid
26
- next if reporter_results.empty?
27
-
28
- # Merge with existing map to preserve mappings for cache-skipped tests
29
- full_results = if File.exist?(out_file)
30
- TestMap.reporter.merge(reporter_results, YAML.safe_load_file(out_file))
31
- else
32
- reporter_results
33
- end
34
-
35
- File.write(out_file, full_results.to_yaml)
36
- TestMap.cache.write(full_results) if TestMap.suite_passed
37
- end
56
+ config.after(:suite) { TestMap::Plugins::RSpec.write_results }
38
57
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TestMap
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/test_map.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'test_map/file_recorder'
9
9
  require_relative 'test_map/natural_mapping'
10
10
  require_relative 'test_map/mapping'
11
11
  require_relative 'test_map/cache'
12
+ require_relative 'test_map/event'
12
13
 
13
14
  # TestMap records associated files to test execution.
14
15
  module TestMap
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: test-map
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Lipautz
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-01 00:00:00.000000000 Z
11
+ date: 2026-03-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Track files that are covered by test files to execute only the necessary
@@ -28,12 +28,15 @@ files:
28
28
  - lib/test_map/cache.rb
29
29
  - lib/test_map/config.rb
30
30
  - lib/test_map/errors.rb
31
+ - lib/test_map/event.rb
31
32
  - lib/test_map/file_recorder.rb
32
33
  - lib/test_map/filter.rb
33
34
  - lib/test_map/mapping.rb
34
35
  - lib/test_map/natural_mapping.rb
35
36
  - lib/test_map/plugins/minitest.rb
37
+ - lib/test_map/plugins/minitest/cache_reporter.rb
36
38
  - lib/test_map/plugins/rspec.rb
39
+ - lib/test_map/plugins/rspec/cache_formatter.rb
37
40
  - lib/test_map/report.rb
38
41
  - lib/test_map/version.rb
39
42
  homepage: https://github.com/unused/test-map