tdd-guard-minitest 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7094ecd33f1d7f2ea05de6193fe1ad6bf7bb38bc8e990b6670c4844e0df29892
4
+ data.tar.gz: d7fd5b92ee9ed72fd5f82efe2ba7496637389e0080f4b1e279a8963ebdc6fa53
5
+ SHA512:
6
+ metadata.gz: 8063e1b3dca4a7a27caaddb1d2ee3078b8216847097772348f0f607e95d4985eb521d8352e8efdad6055e789429b6e570f589a3d411dd90402f10c774d39bef7
7
+ data.tar.gz: 4852220c5f486143695c117b342f7161572af18babfdeedd8b5be341134473edd015c914232e4f0c4681e9070cc56ef48fa17ef8a9da7b112608c7f93cbebe66
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tdd_guard_minitest/reporter"
4
+
5
+ module Minitest
6
+ def self.plugin_tdd_guard_init(options)
7
+ # Guard against double initialization. In Minitest 5, load_plugins
8
+ # may register "tdd_guard" in extensions even when autorun.rb has
9
+ # already done so, causing init_plugins to call this method twice.
10
+ return if reporter.reporters.any? { |r| r.is_a?(TddGuardMinitest::Reporter) }
11
+
12
+ reporter << TddGuardMinitest::Reporter.new(options[:io], options)
13
+ end
14
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point used to guarantee that the tdd-guard-minitest reporter is in
4
+ # place before the user's test file is loaded.
5
+ #
6
+ # Three hooks are registered here, ordered by when they *fire* (LIFO):
7
+ #
8
+ # 1. Load-error at_exit (registered last, fires first):
9
+ # Intercepts unhandled exceptions raised while loading the user's test
10
+ # file (typically a LoadError from a missing require).
11
+ #
12
+ # 2. Minitest's own at_exit hooks (registered by require "minitest/autorun"):
13
+ # Outer hook runs tests and calls reporter.report.
14
+ # Inner hook (registered during the outer hook) runs after_run blocks.
15
+ #
16
+ # 3. Post-after_run at_exit (registered first, fires last):
17
+ # If any wrapped after_run blocks raised, patches test.json with the
18
+ # captured unhandledErrors.
19
+ #
20
+ # Between hooks 1 and 3, Minitest.after_run is patched so that each
21
+ # user-registered block is wrapped in a begin/rescue that captures
22
+ # exceptions into TddGuardMinitest.unhandled_errors before re-raising.
23
+ #
24
+ # Usage:
25
+ #
26
+ # ruby -rtdd_guard_minitest/autorun path/to/test.rb
27
+ #
28
+ # or from test/test_helper.rb:
29
+ #
30
+ # require "tdd_guard_minitest/autorun"
31
+
32
+ require "tdd_guard_minitest/reporter"
33
+
34
+ # Hook 3 (fires last): patch test.json with any captured after_run errors.
35
+ # Registered before Minitest's at_exit so it fires after all Minitest hooks.
36
+ at_exit do
37
+ errors = TddGuardMinitest.unhandled_errors
38
+ next if errors.empty?
39
+
40
+ TddGuardMinitest::Reporter.append_unhandled_errors(errors)
41
+ end
42
+
43
+ require "minitest/autorun"
44
+
45
+ # Ensure the plugin is registered even when Minitest does not call
46
+ # load_plugins automatically (Minitest 6+). In Minitest 5, load_plugins
47
+ # is called during Minitest.run and discovers the plugin via
48
+ # Gem.find_files; this explicit registration is harmless in that case
49
+ # because init_plugins skips duplicate names.
50
+ require "minitest/tdd_guard_plugin"
51
+ Minitest.extensions << "tdd_guard" unless Minitest.extensions.include?("tdd_guard")
52
+
53
+ # Wrap Minitest.after_run so that each block registered by user code or
54
+ # plugins is intercepted. Captured exceptions are stored for the post-
55
+ # after_run at_exit hook above while still re-raising to preserve
56
+ # Minitest's default behavior.
57
+ original_after_run = Minitest.method(:after_run)
58
+ Minitest.define_singleton_method(:after_run) do |&block|
59
+ original_after_run.call do
60
+ begin
61
+ block.call
62
+ rescue Exception => e # rubocop:disable Lint/RescueException
63
+ TddGuardMinitest.unhandled_errors << e unless e.is_a?(SystemExit)
64
+ raise
65
+ end
66
+ end
67
+ end
68
+
69
+ # Hook 1 (fires first): capture load errors before Minitest runs.
70
+ at_exit do
71
+ exc = $!
72
+ next if exc.nil?
73
+ next if exc.is_a?(SystemExit)
74
+
75
+ TddGuardMinitest::Reporter.handle_load_error(exc)
76
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "minitest"
6
+
7
+ module TddGuardMinitest
8
+ @unhandled_errors = []
9
+
10
+ class << self
11
+ attr_reader :unhandled_errors
12
+ end
13
+
14
+ # Minitest reporter that captures test results for TDD Guard validation.
15
+ # Mirrors the RSpec reporter's single-class architecture.
16
+ class Reporter < Minitest::StatisticsReporter
17
+ DEFAULT_DATA_DIR = ".claude/tdd-guard/data"
18
+
19
+ def initialize(io = $stdout, options = {})
20
+ super
21
+ @test_results = []
22
+ @expected_count = 0
23
+ @storage_dir = determine_storage_dir
24
+ end
25
+
26
+ def start
27
+ super
28
+ @expected_count = compute_expected_count
29
+ end
30
+
31
+ # Writes a synthetic failed-test JSON for an exception raised before
32
+ # Minitest had a chance to run (typically a LoadError from a missing
33
+ # require at the top of a test file). Called from the autorun entry
34
+ # point's at_exit hook when $! is set.
35
+ #
36
+ # Injects a synthetic entry into @test_results and writes the JSON
37
+ # through the normal report path. Skips if test.json already exists
38
+ # to avoid clobbering real results.
39
+ def self.handle_load_error(exception)
40
+ new(StringIO.new).handle_load_error(exception)
41
+ end
42
+
43
+ # Reads the existing test.json, merges in the unhandledErrors field,
44
+ # and re-writes it. Called from the post-after_run at_exit hook in
45
+ # autorun.rb after Minitest.after_run blocks have completed.
46
+ def self.append_unhandled_errors(errors)
47
+ new(StringIO.new).append_unhandled_errors(errors)
48
+ end
49
+
50
+ def append_unhandled_errors(errors)
51
+ json_path = File.join(@storage_dir, "test.json")
52
+ return unless File.exist?(json_path)
53
+
54
+ data = JSON.parse(File.read(json_path))
55
+ data["unhandledErrors"] = errors.map { |e| build_unhandled_error(e) }
56
+ File.write(json_path, JSON.pretty_generate(data))
57
+ end
58
+
59
+ def handle_load_error(exception)
60
+ return if File.exist?(File.join(@storage_dir, "test.json"))
61
+
62
+ add_load_error(exception)
63
+ report
64
+ end
65
+
66
+ def record(result)
67
+ state = if result.skipped?
68
+ "skipped"
69
+ elsif result.passed?
70
+ "passed"
71
+ else
72
+ "failed"
73
+ end
74
+
75
+ file_path = extract_file_path(result)
76
+ test = {
77
+ "name" => result.name,
78
+ "fullName" => "#{file_path}::#{result.klass}##{result.name}",
79
+ "state" => state
80
+ }
81
+
82
+ if state == "failed" && result.failures.any?
83
+ test["errors"] = result.failures.map { |failure| build_error(failure) }
84
+ end
85
+
86
+ @test_results << test
87
+ end
88
+
89
+ def report
90
+ modules_map = {}
91
+ @test_results.each do |test|
92
+ module_path = test["fullName"].split("::").first
93
+ modules_map[module_path] ||= { "moduleId" => module_path, "tests" => [] }
94
+ modules_map[module_path]["tests"] << test
95
+ end
96
+
97
+ has_failures = @test_results.any? { |t| t["state"] == "failed" }
98
+ reason = if has_failures
99
+ "failed"
100
+ elsif @expected_count > 0 && @test_results.length < @expected_count
101
+ "interrupted"
102
+ else
103
+ "passed"
104
+ end
105
+ result = {
106
+ "testModules" => modules_map.values,
107
+ "reason" => reason
108
+ }
109
+
110
+ FileUtils.mkdir_p(@storage_dir)
111
+ File.write(File.join(@storage_dir, "test.json"), JSON.pretty_generate(result))
112
+ end
113
+
114
+ def passed?
115
+ @test_results.none? { |t| t["state"] == "failed" }
116
+ end
117
+
118
+ private
119
+
120
+ def compute_expected_count
121
+ filter = options[:filter]
122
+ Minitest::Runnable.runnables.sum do |klass|
123
+ if filter
124
+ klass.methods_matching(filter).size
125
+ else
126
+ klass.runnable_methods.size
127
+ end
128
+ end
129
+ end
130
+
131
+ def build_unhandled_error(exception)
132
+ name = exception.class.name || "(anonymous error class)"
133
+ error = { "name" => name, "message" => exception.message }
134
+ stack = extract_relevant_stack(exception.backtrace)
135
+ error["stack"] = stack if stack
136
+ error
137
+ end
138
+
139
+ def build_error(failure)
140
+ if failure.is_a?(Minitest::UnexpectedError)
141
+ exception = failure.error
142
+ error = { "message" => exception.message }
143
+ stack = extract_relevant_stack(exception.backtrace)
144
+ else
145
+ error = { "message" => failure.message }
146
+ stack = extract_relevant_stack(failure.backtrace)
147
+ end
148
+ error["stack"] = stack if stack
149
+ error
150
+ end
151
+
152
+ def extract_relevant_stack(backtrace)
153
+ return nil unless backtrace
154
+
155
+ frame = backtrace.find do |line|
156
+ (line.include?("test/") || line.include?("spec/")) && !line.include?("/gems/")
157
+ end
158
+ return nil unless frame
159
+
160
+ frame.sub(%r{^.*/(?=test/|spec/)}, "").sub(%r{^\./}, "")
161
+ end
162
+
163
+ def extract_file_path(result)
164
+ source = result.source_location
165
+ return "unknown" unless source
166
+
167
+ path = source.first
168
+ # Convert absolute path to relative path from cwd
169
+ cwd = "#{Dir.pwd}/"
170
+ path = path.delete_prefix(cwd) if path.start_with?(cwd)
171
+ path.sub(%r{^\./}, "")
172
+ end
173
+
174
+ def determine_storage_dir
175
+ project_root = ENV["TDD_GUARD_PROJECT_ROOT"]
176
+ return DEFAULT_DATA_DIR unless project_root && !project_root.empty?
177
+ return DEFAULT_DATA_DIR unless absolute_path?(project_root)
178
+ return DEFAULT_DATA_DIR unless cwd_within?(project_root)
179
+
180
+ File.join(project_root, DEFAULT_DATA_DIR)
181
+ end
182
+
183
+ def absolute_path?(path)
184
+ File.absolute_path?(path)
185
+ end
186
+
187
+ def cwd_within?(root)
188
+ expanded = File.expand_path(root)
189
+ cwd = Dir.pwd
190
+ cwd == expanded || cwd.start_with?("#{expanded}/")
191
+ end
192
+
193
+ # Injects a synthetic failed test entry derived from an exception raised
194
+ # before Minitest could run.
195
+ def add_load_error(exception)
196
+ frame = first_user_frame(exception.backtrace)
197
+ file_path = frame ? frame.split(":", 2).first.to_s.sub(%r{^\./}, "") : "unknown"
198
+ name = "#{exception.class}: #{exception.message.lines.first.to_s.strip}"
199
+ message = build_load_error_message(exception, frame)
200
+
201
+ @test_results << {
202
+ "name" => name,
203
+ "fullName" => "#{file_path}::#{name}",
204
+ "state" => "failed",
205
+ "errors" => [{ "message" => message }]
206
+ }
207
+ end
208
+
209
+ def first_user_frame(backtrace)
210
+ return nil unless backtrace
211
+
212
+ backtrace.find do |line|
213
+ (line.include?("_test.rb") || line.include?("_spec.rb")) && !line.include?("/gems/")
214
+ end
215
+ end
216
+
217
+ def build_load_error_message(exception, frame)
218
+ header = "#{exception.class}: #{exception.message}"
219
+ return header unless frame
220
+
221
+ "#{header}\n #{frame.sub(%r{^\./}, '')}"
222
+ end
223
+ end
224
+ end
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tdd-guard-minitest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hiro-Chiba
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: climate_control
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ description: Minitest reporter that captures test results for TDD Guard validation.
56
+ email:
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/minitest/tdd_guard_plugin.rb
62
+ - lib/tdd_guard_minitest/autorun.rb
63
+ - lib/tdd_guard_minitest/reporter.rb
64
+ homepage: https://github.com/nizos/tdd-guard
65
+ licenses:
66
+ - MIT
67
+ metadata: {}
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.3.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.5.22
84
+ signing_key:
85
+ specification_version: 4
86
+ summary: Minitest reporter for TDD Guard - enforces Test-Driven Development principles
87
+ test_files: []