rails_proof 1.0.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/CHANGELOG.md +54 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1210 -0
- data/Rakefile +3 -0
- data/lib/generators/rails_proof/test/USAGE +8 -0
- data/lib/generators/rails_proof/test/test_generator.rb +542 -0
- data/lib/rails_proof/ai_concern_filter.rb +142 -0
- data/lib/rails_proof/ai_test_executor.rb +263 -0
- data/lib/rails_proof/ai_test_identity.rb +261 -0
- data/lib/rails_proof/ai_test_planner.rb +118 -0
- data/lib/rails_proof/ai_test_validator.rb +65 -0
- data/lib/rails_proof/ai_test_writer.rb +51 -0
- data/lib/rails_proof/controller_inspector.rb +44 -0
- data/lib/rails_proof/controller_test_coverage_plan.rb +79 -0
- data/lib/rails_proof/controller_test_plan.rb +28 -0
- data/lib/rails_proof/controller_test_writer.rb +66 -0
- data/lib/rails_proof/model_inspector.rb +50 -0
- data/lib/rails_proof/model_test_plan.rb +61 -0
- data/lib/rails_proof/open_ai_client.rb +189 -0
- data/lib/rails_proof/railtie.rb +4 -0
- data/lib/rails_proof/review_store.rb +241 -0
- data/lib/rails_proof/target_discovery.rb +145 -0
- data/lib/rails_proof/test_coverage_plan.rb +84 -0
- data/lib/rails_proof/test_inspector.rb +54 -0
- data/lib/rails_proof/test_runner.rb +136 -0
- data/lib/rails_proof/test_writer.rb +87 -0
- data/lib/rails_proof/version.rb +3 -0
- data/lib/rails_proof.rb +13 -0
- data/lib/tasks/rails_proof_tasks.rake +4 -0
- metadata +96 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require "rails_proof/ai_test_identity"
|
|
7
|
+
|
|
8
|
+
module RailsProof
|
|
9
|
+
class ReviewStore
|
|
10
|
+
attr_reader :root
|
|
11
|
+
|
|
12
|
+
def initialize(root:)
|
|
13
|
+
@root = Pathname.new(root)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def save(
|
|
17
|
+
concern:,
|
|
18
|
+
test_output:,
|
|
19
|
+
test_file_path:,
|
|
20
|
+
test_class_name:,
|
|
21
|
+
target_path: nil
|
|
22
|
+
)
|
|
23
|
+
review_directory.mkpath
|
|
24
|
+
|
|
25
|
+
timestamp = Time.now.utc.iso8601
|
|
26
|
+
fingerprint = target_fingerprint(target_path)
|
|
27
|
+
|
|
28
|
+
existing_path = find_existing_review(
|
|
29
|
+
concern: concern,
|
|
30
|
+
target_path: target_path,
|
|
31
|
+
test_file_path: test_file_path,
|
|
32
|
+
target_fingerprint: fingerprint
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if existing_path
|
|
36
|
+
update_existing_review(
|
|
37
|
+
path: existing_path,
|
|
38
|
+
concern: concern,
|
|
39
|
+
test_output: test_output,
|
|
40
|
+
timestamp: timestamp
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return existing_path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
path = review_directory.join(
|
|
47
|
+
review_filename(
|
|
48
|
+
test_class_name: test_class_name,
|
|
49
|
+
name: concern_name(concern)
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
path.write(
|
|
54
|
+
JSON.pretty_generate(
|
|
55
|
+
review_record(
|
|
56
|
+
concern: concern,
|
|
57
|
+
test_output: test_output,
|
|
58
|
+
test_file_path: test_file_path,
|
|
59
|
+
test_class_name: test_class_name,
|
|
60
|
+
target_path: target_path,
|
|
61
|
+
target_fingerprint: fingerprint,
|
|
62
|
+
timestamp: timestamp
|
|
63
|
+
)
|
|
64
|
+
) + "\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def outstanding_findings(
|
|
71
|
+
target_path:,
|
|
72
|
+
test_file_path:
|
|
73
|
+
)
|
|
74
|
+
fingerprint = target_fingerprint(target_path)
|
|
75
|
+
|
|
76
|
+
return [] unless fingerprint
|
|
77
|
+
|
|
78
|
+
review_paths.filter_map do |path|
|
|
79
|
+
record = read_record(path)
|
|
80
|
+
|
|
81
|
+
next unless record
|
|
82
|
+
next unless record["status"] == "needs_review"
|
|
83
|
+
next unless record["target_path"] == target_path
|
|
84
|
+
next unless record["test_file_path"] == test_file_path
|
|
85
|
+
next unless record["target_fingerprint"] == fingerprint
|
|
86
|
+
|
|
87
|
+
record
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def review_directory
|
|
94
|
+
root.join(".rails_proof/review")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def review_paths
|
|
98
|
+
return [] unless review_directory.directory?
|
|
99
|
+
|
|
100
|
+
review_directory.glob("*.json")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def review_filename(test_class_name:, name:)
|
|
104
|
+
timestamp = Time.now.utc.strftime("%Y%m%dT%H%M%S")
|
|
105
|
+
slug = slugify("#{test_class_name}-#{name}")
|
|
106
|
+
token = SecureRandom.hex(4)
|
|
107
|
+
|
|
108
|
+
"#{timestamp}-#{slug}-#{token}.json"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def slugify(value)
|
|
112
|
+
value
|
|
113
|
+
.to_s
|
|
114
|
+
.downcase
|
|
115
|
+
.gsub(/[^a-z0-9]+/, "-")
|
|
116
|
+
.gsub(/\A-+|-+\z/, "")
|
|
117
|
+
.slice(0, 80)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def review_record(
|
|
121
|
+
concern:,
|
|
122
|
+
test_output:,
|
|
123
|
+
test_file_path:,
|
|
124
|
+
test_class_name:,
|
|
125
|
+
target_path:,
|
|
126
|
+
target_fingerprint:,
|
|
127
|
+
timestamp:
|
|
128
|
+
)
|
|
129
|
+
{
|
|
130
|
+
version: 3,
|
|
131
|
+
status: "needs_review",
|
|
132
|
+
created_at: timestamp,
|
|
133
|
+
last_seen_at: timestamp,
|
|
134
|
+
occurrences: 1,
|
|
135
|
+
target_path: target_path,
|
|
136
|
+
target_fingerprint: target_fingerprint,
|
|
137
|
+
test_file_path: test_file_path,
|
|
138
|
+
test_class_name: test_class_name,
|
|
139
|
+
kind: concern_kind(concern),
|
|
140
|
+
name: concern_name(concern),
|
|
141
|
+
reason: concern_reason(concern),
|
|
142
|
+
test_code: concern_test_code(concern),
|
|
143
|
+
test_fingerprint:
|
|
144
|
+
RailsProof::AiTestIdentity.test_fingerprint(
|
|
145
|
+
concern_test_code(concern)
|
|
146
|
+
),
|
|
147
|
+
test_output: test_output
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def update_existing_review(
|
|
152
|
+
path:,
|
|
153
|
+
concern:,
|
|
154
|
+
test_output:,
|
|
155
|
+
timestamp:
|
|
156
|
+
)
|
|
157
|
+
record = read_record(path)
|
|
158
|
+
|
|
159
|
+
return unless record
|
|
160
|
+
|
|
161
|
+
record["last_seen_at"] = timestamp
|
|
162
|
+
record["occurrences"] =
|
|
163
|
+
record.fetch("occurrences", 1).to_i + 1
|
|
164
|
+
record["kind"] = concern_kind(concern)
|
|
165
|
+
record["name"] = concern_name(concern)
|
|
166
|
+
record["reason"] = concern_reason(concern)
|
|
167
|
+
record["test_code"] = concern_test_code(concern)
|
|
168
|
+
record["test_fingerprint"] =
|
|
169
|
+
RailsProof::AiTestIdentity.test_fingerprint(
|
|
170
|
+
concern_test_code(concern)
|
|
171
|
+
)
|
|
172
|
+
record["test_output"] = test_output
|
|
173
|
+
|
|
174
|
+
path.write(
|
|
175
|
+
JSON.pretty_generate(record) + "\n"
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def find_existing_review(
|
|
180
|
+
concern:,
|
|
181
|
+
target_path:,
|
|
182
|
+
test_file_path:,
|
|
183
|
+
target_fingerprint:
|
|
184
|
+
)
|
|
185
|
+
return nil unless target_fingerprint
|
|
186
|
+
|
|
187
|
+
review_paths.find do |path|
|
|
188
|
+
record = read_record(path)
|
|
189
|
+
|
|
190
|
+
next false unless record
|
|
191
|
+
next false unless record["status"] == "needs_review"
|
|
192
|
+
next false unless record["target_path"] == target_path
|
|
193
|
+
next false unless record["test_file_path"] == test_file_path
|
|
194
|
+
next false unless(
|
|
195
|
+
record["target_fingerprint"] == target_fingerprint
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
RailsProof::AiTestIdentity.same?(
|
|
199
|
+
first_name: concern_name(concern),
|
|
200
|
+
first_test_code: concern_test_code(concern),
|
|
201
|
+
second_name: record["name"],
|
|
202
|
+
second_test_code: record["test_code"]
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def concern_name(concern)
|
|
208
|
+
concern[:name] || concern["name"]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def concern_reason(concern)
|
|
212
|
+
concern[:reason] || concern["reason"]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def concern_test_code(concern)
|
|
216
|
+
concern[:test_code] || concern["test_code"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def target_fingerprint(target_path)
|
|
220
|
+
return nil if target_path.blank?
|
|
221
|
+
|
|
222
|
+
path = root.join(target_path)
|
|
223
|
+
|
|
224
|
+
return nil unless path.file?
|
|
225
|
+
|
|
226
|
+
Digest::SHA256.hexdigest(path.read)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def concern_kind(concern)
|
|
230
|
+
kind = concern[:kind] || concern["kind"]
|
|
231
|
+
|
|
232
|
+
kind&.to_s
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def read_record(path)
|
|
236
|
+
JSON.parse(path.read)
|
|
237
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "active_support/core_ext/string/inflections"
|
|
3
|
+
|
|
4
|
+
module RailsProof
|
|
5
|
+
class TargetDiscovery
|
|
6
|
+
Target = Struct.new(
|
|
7
|
+
:type,
|
|
8
|
+
:path,
|
|
9
|
+
:class_name,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
SUPPORTED_DIRECTORIES = {
|
|
14
|
+
"app/models" => :model,
|
|
15
|
+
"app/controllers" => :controller
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :root, :scope
|
|
19
|
+
|
|
20
|
+
def initialize(root:, scope: nil)
|
|
21
|
+
@root = Pathname.new(root)
|
|
22
|
+
@scope = scope
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def targets
|
|
26
|
+
@targets ||= if normalized_scope
|
|
27
|
+
discover_scope(normalized_scope)
|
|
28
|
+
else
|
|
29
|
+
discover_all
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def normalized_scope
|
|
36
|
+
return @normalized_scope if defined?(@normalized_scope)
|
|
37
|
+
|
|
38
|
+
value = scope.to_s.strip
|
|
39
|
+
|
|
40
|
+
@normalized_scope = if value.empty?
|
|
41
|
+
nil
|
|
42
|
+
else
|
|
43
|
+
normalize_path(value)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_path(path)
|
|
48
|
+
pathname = Pathname.new(path)
|
|
49
|
+
|
|
50
|
+
if pathname.absolute?
|
|
51
|
+
raise ArgumentError, "RailsProof scope must be relative to the Rails application"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
normalized = pathname.cleanpath.to_s.delete_prefix("./")
|
|
55
|
+
|
|
56
|
+
if normalized == ".." || normalized.start_with?("../")
|
|
57
|
+
raise ArgumentError, "RailsProof scope must stay inside the Rails application"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
normalized
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def discover_scope(path)
|
|
64
|
+
absolute_path = root.join(path)
|
|
65
|
+
|
|
66
|
+
if absolute_path.file?
|
|
67
|
+
[target_for_file(path)]
|
|
68
|
+
elsif absolute_path.directory?
|
|
69
|
+
discover_directory(path)
|
|
70
|
+
else
|
|
71
|
+
raise ArgumentError, "RailsProof target not found: #{path}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def discover_all
|
|
76
|
+
SUPPORTED_DIRECTORIES.keys.flat_map do |directory|
|
|
77
|
+
next [] unless root.join(directory).directory?
|
|
78
|
+
|
|
79
|
+
discover_directory(directory)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def discover_directory(directory)
|
|
84
|
+
type = type_for_path(directory)
|
|
85
|
+
|
|
86
|
+
unless type
|
|
87
|
+
raise ArgumentError, "Unsupported RailsProof target: #{directory}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
pattern = root.join(directory, "**", "*.rb").to_s
|
|
91
|
+
|
|
92
|
+
Dir.glob(pattern).sort.filter_map do |absolute_path|
|
|
93
|
+
relative_path = Pathname.new(absolute_path)
|
|
94
|
+
.relative_path_from(root)
|
|
95
|
+
.to_s
|
|
96
|
+
|
|
97
|
+
next if ignored_path?(relative_path)
|
|
98
|
+
|
|
99
|
+
target_for_file(relative_path)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def target_for_file(path)
|
|
104
|
+
type = type_for_path(path)
|
|
105
|
+
|
|
106
|
+
unless type && path.end_with?(".rb")
|
|
107
|
+
raise ArgumentError, "Unsupported RailsProof target: #{path}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if ignored_path?(path)
|
|
111
|
+
raise ArgumentError, "RailsProof does not inspect base or concern files: #{path}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Target.new(
|
|
115
|
+
type: type,
|
|
116
|
+
path: path,
|
|
117
|
+
class_name: class_name_for(path, type)
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def type_for_path(path)
|
|
122
|
+
SUPPORTED_DIRECTORIES.each do |directory, type|
|
|
123
|
+
return type if path == directory || path.start_with?("#{directory}/")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def ignored_path?(path)
|
|
130
|
+
path == "app/models/application_record.rb" ||
|
|
131
|
+
path.start_with?("app/models/concerns/") ||
|
|
132
|
+
path == "app/controllers/application_controller.rb" ||
|
|
133
|
+
path.start_with?("app/controllers/concerns/")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def class_name_for(path, type)
|
|
137
|
+
prefix = type == :model ? "app/models/" : "app/controllers/"
|
|
138
|
+
|
|
139
|
+
path
|
|
140
|
+
.delete_prefix(prefix)
|
|
141
|
+
.delete_suffix(".rb")
|
|
142
|
+
.camelize
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module RailsProof
|
|
2
|
+
class TestCoveragePlan
|
|
3
|
+
attr_reader :model_test_plan, :test_inspector
|
|
4
|
+
|
|
5
|
+
def initialize(model_test_plan, test_inspector)
|
|
6
|
+
@model_test_plan = model_test_plan
|
|
7
|
+
@test_inspector = test_inspector
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def covered_concerns
|
|
11
|
+
@covered_concerns ||= model_test_plan.concerns.select do |concern|
|
|
12
|
+
covered?(concern)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def missing_concerns
|
|
17
|
+
@missing_concerns ||= model_test_plan.concerns.reject do |concern|
|
|
18
|
+
covered?(concern)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def covered_count
|
|
23
|
+
covered_concerns.count
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def missing_count
|
|
27
|
+
missing_concerns.count
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def covered?(concern)
|
|
33
|
+
test_names.any? do |test_name|
|
|
34
|
+
matches_concern?(test_name, concern)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_names
|
|
39
|
+
@test_names ||= test_inspector.test_cases.map do |test_case|
|
|
40
|
+
normalize(test_case[:name])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def matches_concern?(test_name, concern)
|
|
45
|
+
case concern[:type]
|
|
46
|
+
when :association
|
|
47
|
+
association_covered?(test_name, concern)
|
|
48
|
+
when :validation
|
|
49
|
+
validation_covered?(test_name, concern)
|
|
50
|
+
else
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def association_covered?(test_name, concern)
|
|
56
|
+
macro = normalize(concern[:macro].to_s)
|
|
57
|
+
name = normalize(concern[:name].to_s)
|
|
58
|
+
|
|
59
|
+
test_name.include?(macro) &&
|
|
60
|
+
test_name.include?(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validation_covered?(test_name, concern)
|
|
64
|
+
attribute = normalize(concern[:attribute].to_s)
|
|
65
|
+
|
|
66
|
+
test_name.include?(attribute) &&
|
|
67
|
+
(
|
|
68
|
+
test_name.include?("presence") ||
|
|
69
|
+
test_name.include?("present") ||
|
|
70
|
+
test_name.include?("required") ||
|
|
71
|
+
test_name.include?("requires")
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize(value)
|
|
76
|
+
value
|
|
77
|
+
.downcase
|
|
78
|
+
.tr("_", " ")
|
|
79
|
+
.gsub(/[^a-z0-9\s]/, " ")
|
|
80
|
+
.split
|
|
81
|
+
.join(" ")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module RailsProof
|
|
2
|
+
class TestInspector
|
|
3
|
+
attr_reader :source
|
|
4
|
+
|
|
5
|
+
def initialize(source)
|
|
6
|
+
@source = source
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def test_cases
|
|
10
|
+
@test_cases ||= rails_style_tests + method_style_tests
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def count
|
|
14
|
+
test_cases.count
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def empty?
|
|
18
|
+
test_cases.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def rails_style_tests
|
|
24
|
+
source.each_line.filter_map do |line|
|
|
25
|
+
match = line.match(
|
|
26
|
+
/^\s*test\s*(?:\(\s*)?(["'])(.+?)\1\s*\)?\s+do\b/
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
next unless match
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
style: :rails,
|
|
33
|
+
name: match[2]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def method_style_tests
|
|
39
|
+
source.each_line.filter_map do |line|
|
|
40
|
+
match = line.match(/^\s*def\s+(test_[a-zA-Z0-9_!?]+)/)
|
|
41
|
+
|
|
42
|
+
next unless match
|
|
43
|
+
|
|
44
|
+
method_name = match[1]
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
style: :method,
|
|
48
|
+
name: method_name.delete_prefix("test_").tr("_", " "),
|
|
49
|
+
method_name: method_name
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
module RailsProof
|
|
5
|
+
class TestRunner
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
Result = Struct.new(
|
|
9
|
+
:passed,
|
|
10
|
+
:output,
|
|
11
|
+
:command,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
) do
|
|
14
|
+
def passed?
|
|
15
|
+
passed
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :root, :test_file_path
|
|
20
|
+
|
|
21
|
+
def initialize(root:, test_file_path:)
|
|
22
|
+
@root = Pathname.new(root).expand_path
|
|
23
|
+
@test_file_path = Pathname.new(test_file_path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
stdout, stderr, status = Open3.capture3(
|
|
28
|
+
{ "RAILS_ENV" => "test" },
|
|
29
|
+
*command,
|
|
30
|
+
chdir: working_directory.to_s
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
output = [stdout, stderr].reject(&:empty?).join
|
|
34
|
+
|
|
35
|
+
raise_unavailable_test_api!(output)
|
|
36
|
+
|
|
37
|
+
Result.new(
|
|
38
|
+
passed: status.success?,
|
|
39
|
+
output: output,
|
|
40
|
+
command: command
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def command
|
|
45
|
+
@command ||= resolve_runner.fetch(:command)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def working_directory
|
|
49
|
+
@working_directory ||= resolve_runner.fetch(:working_directory)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def raise_unavailable_test_api!(output)
|
|
55
|
+
message =
|
|
56
|
+
unavailable_test_api_message(output)
|
|
57
|
+
|
|
58
|
+
return unless message
|
|
59
|
+
|
|
60
|
+
raise Error, message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def unavailable_test_api_message(output)
|
|
64
|
+
if output.match?(
|
|
65
|
+
/NoMethodError: undefined method ['`]stub['`]/
|
|
66
|
+
)
|
|
67
|
+
return "candidate uses .stub, but .stub is unavailable in this test environment"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if output.match?(
|
|
71
|
+
/NameError: uninitialized constant Minitest::Mock/
|
|
72
|
+
)
|
|
73
|
+
return "candidate uses Minitest::Mock, but Minitest::Mock is unavailable in this test environment"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_runner
|
|
80
|
+
@resolve_runner ||= begin
|
|
81
|
+
normal_rails_runner || ancestor_test_runner || raise_runner_error
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normal_rails_runner
|
|
86
|
+
rails = root.join("bin/rails")
|
|
87
|
+
test_helper = root.join("test/test_helper.rb")
|
|
88
|
+
|
|
89
|
+
return unless rails.file? && test_helper.file?
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
working_directory: root,
|
|
93
|
+
command: [
|
|
94
|
+
rails.to_s,
|
|
95
|
+
"test",
|
|
96
|
+
test_file_path.to_s
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ancestor_test_runner
|
|
102
|
+
current = root
|
|
103
|
+
|
|
104
|
+
loop do
|
|
105
|
+
runner = current.join("bin/test")
|
|
106
|
+
test_helper = current.join("test/test_helper.rb")
|
|
107
|
+
|
|
108
|
+
if runner.file? && test_helper.file?
|
|
109
|
+
absolute_test_path = root.join(test_file_path).expand_path
|
|
110
|
+
relative_test_path =
|
|
111
|
+
absolute_test_path.relative_path_from(current).to_s
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
working_directory: current,
|
|
115
|
+
command: [
|
|
116
|
+
runner.to_s,
|
|
117
|
+
relative_test_path
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
parent = current.parent
|
|
123
|
+
break if parent == current
|
|
124
|
+
|
|
125
|
+
current = parent
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def raise_runner_error
|
|
132
|
+
raise Error,
|
|
133
|
+
"RailsProof could not find a Rails test runner for #{root}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|