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 +7 -0
- data/lib/minitest/tdd_guard_plugin.rb +14 -0
- data/lib/tdd_guard_minitest/autorun.rb +76 -0
- data/lib/tdd_guard_minitest/reporter.rb +224 -0
- metadata +87 -0
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: []
|