spec_ai 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/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/exe/spec-ai +9 -0
- data/lib/generators/spec_ai/install/install_generator.rb +16 -0
- data/lib/generators/spec_ai/install/templates/spec_ai.yml +23 -0
- data/lib/spec_ai/analyzers/existing_specs.rb +29 -0
- data/lib/spec_ai/analyzers/factories.rb +47 -0
- data/lib/spec_ai/analyzers/model.rb +169 -0
- data/lib/spec_ai/analyzers/routes.rb +23 -0
- data/lib/spec_ai/analyzers/schema.rb +53 -0
- data/lib/spec_ai/cli.rb +124 -0
- data/lib/spec_ai/configuration.rb +104 -0
- data/lib/spec_ai/context/builder.rb +44 -0
- data/lib/spec_ai/context/snapshot.rb +108 -0
- data/lib/spec_ai/crews/generate_crew.rb +152 -0
- data/lib/spec_ai/crews/repair_crew.rb +97 -0
- data/lib/spec_ai/crews/silence.rb +21 -0
- data/lib/spec_ai/crews/toolset.rb +19 -0
- data/lib/spec_ai/generators/factory_generator.rb +78 -0
- data/lib/spec_ai/generators/rspec_generator.rb +301 -0
- data/lib/spec_ai/generators/rspec_install.rb +71 -0
- data/lib/spec_ai/inflector.rb +63 -0
- data/lib/spec_ai/knowledge/playbook.rb +11 -0
- data/lib/spec_ai/knowledge/rails_testing.md +42 -0
- data/lib/spec_ai/pipeline/fix.rb +77 -0
- data/lib/spec_ai/pipeline/generate.rb +116 -0
- data/lib/spec_ai/pipeline/result.rb +21 -0
- data/lib/spec_ai/railtie.rb +11 -0
- data/lib/spec_ai/repair/prune.rb +49 -0
- data/lib/spec_ai/runners/report.rb +144 -0
- data/lib/spec_ai/runners/rspec_runner.rb +99 -0
- data/lib/spec_ai/sandbox.rb +63 -0
- data/lib/spec_ai/setup/ensure.rb +159 -0
- data/lib/spec_ai/snapshot_plan.rb +166 -0
- data/lib/spec_ai/target.rb +52 -0
- data/lib/spec_ai/test_plan.rb +342 -0
- data/lib/spec_ai/tools/list_related_files.rb +33 -0
- data/lib/spec_ai/tools/read_app_file.rb +24 -0
- data/lib/spec_ai/tools/read_existing_spec.rb +28 -0
- data/lib/spec_ai/tools/read_factory.rb +28 -0
- data/lib/spec_ai/tools/read_schema_table.rb +26 -0
- data/lib/spec_ai/ui.rb +469 -0
- data/lib/spec_ai/version.rb +5 -0
- data/lib/spec_ai.rb +57 -0
- data/lib/tasks/spec_ai.rake +19 -0
- metadata +185 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Generators
|
|
5
|
+
class FactoryGenerator
|
|
6
|
+
SKIP = %w[
|
|
7
|
+
id created_at updated_at encrypted_password
|
|
8
|
+
reset_password_token reset_password_sent_at remember_created_at
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(snapshot, sandbox: Sandbox.new)
|
|
12
|
+
@snapshot = snapshot
|
|
13
|
+
@sandbox = sandbox
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def relative_path
|
|
17
|
+
"spec/factories/#{@snapshot.target.table_name}.rb"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write
|
|
21
|
+
@sandbox.write(relative_path, render)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render
|
|
25
|
+
name = @snapshot.target.factory_name
|
|
26
|
+
assignments = assignment_lines
|
|
27
|
+
body = assignments.empty? ? " # add attributes for #{name}\n" : assignments.join("\n")
|
|
28
|
+
<<~RUBY
|
|
29
|
+
# frozen_string_literal: true
|
|
30
|
+
|
|
31
|
+
FactoryBot.define do
|
|
32
|
+
factory :#{name} do
|
|
33
|
+
#{body}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
RUBY
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def assignment_lines
|
|
42
|
+
lines = []
|
|
43
|
+
attributes.each do |name|
|
|
44
|
+
line = assignment_for(name)
|
|
45
|
+
lines << " #{line}" if line
|
|
46
|
+
end
|
|
47
|
+
lines
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def attributes
|
|
51
|
+
@snapshot.known_attributes.reject { |name| SKIP.include?(name) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def assignment_for(name)
|
|
55
|
+
case name
|
|
56
|
+
when "email"
|
|
57
|
+
%(sequence(:email) { |n| "user\#{n}@example.com" })
|
|
58
|
+
when "password"
|
|
59
|
+
%(password { "password123" })
|
|
60
|
+
when "password_confirmation"
|
|
61
|
+
%(password_confirmation { "password123" })
|
|
62
|
+
when "name"
|
|
63
|
+
%(name { "Test #{@snapshot.target.class_name}" })
|
|
64
|
+
else
|
|
65
|
+
%(#{name} { #{value_for(name)} })
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def value_for(name)
|
|
70
|
+
excerpt = @snapshot.schema[:excerpt].to_s
|
|
71
|
+
return "true" if excerpt.match?(/t\.boolean\s+"#{Regexp.escape(name)}"/)
|
|
72
|
+
return "1" if excerpt.match?(/t\.(integer|bigint)\s+"#{Regexp.escape(name)}"/)
|
|
73
|
+
|
|
74
|
+
%("#{name}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Generators
|
|
5
|
+
class RspecGenerator
|
|
6
|
+
HELPER_RE = /require\s+["']spec_helper["']/.freeze
|
|
7
|
+
|
|
8
|
+
def self.patch_helper!(relative, sandbox:)
|
|
9
|
+
return false unless sandbox.exist?("spec/rails_helper.rb") && sandbox.exist?(relative)
|
|
10
|
+
|
|
11
|
+
contents = sandbox.read(relative)
|
|
12
|
+
updated = upgrade_helper_source(contents)
|
|
13
|
+
return false if updated == contents
|
|
14
|
+
|
|
15
|
+
sandbox.write(relative, updated)
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.upgrade_helper_source(contents)
|
|
20
|
+
contents.to_s.sub(HELPER_RE, 'require "rails_helper"')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
CATEGORY_TITLES = {
|
|
24
|
+
"validation" => "validations",
|
|
25
|
+
"association" => "associations",
|
|
26
|
+
"happy_path" => "happy path",
|
|
27
|
+
"edge_case" => "edge cases",
|
|
28
|
+
"error_handling" => "error handling"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(plan, snapshot, force: false, sandbox: Sandbox.new)
|
|
32
|
+
@plan = plan
|
|
33
|
+
@snapshot = snapshot
|
|
34
|
+
@force = force
|
|
35
|
+
@sandbox = sandbox
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write
|
|
39
|
+
relative = @snapshot.target.spec_path
|
|
40
|
+
|
|
41
|
+
if @sandbox.exist?(relative) && !@force
|
|
42
|
+
existing = upgrade_helper(@sandbox.read(relative))
|
|
43
|
+
filtered = @plan.without_existing(example_names(existing))
|
|
44
|
+
contents = filtered.nil? ? existing : merge(existing, render_groups(filtered))
|
|
45
|
+
@sandbox.write(relative, upgrade_helper(contents))
|
|
46
|
+
else
|
|
47
|
+
@sandbox.write(relative, render_file(@plan))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render(plan = @plan)
|
|
52
|
+
render_file(plan)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def render_file(plan)
|
|
58
|
+
<<~RUBY
|
|
59
|
+
# frozen_string_literal: true
|
|
60
|
+
|
|
61
|
+
#{helper_require}
|
|
62
|
+
|
|
63
|
+
RSpec.describe #{plan.target}, type: :model do
|
|
64
|
+
#{indent(render_groups(plan).rstrip, 2)}
|
|
65
|
+
end
|
|
66
|
+
RUBY
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_groups(plan)
|
|
70
|
+
plan.scenarios.group_by(&:category).map do |category, scenarios|
|
|
71
|
+
title = CATEGORY_TITLES.fetch(category, category.to_s.tr("_", " "))
|
|
72
|
+
body = scenarios.map { |scenario| render_example(scenario) }.join("\n\n")
|
|
73
|
+
<<~RUBY.rstrip
|
|
74
|
+
describe #{title.inspect} do
|
|
75
|
+
#{indent(body, 2)}
|
|
76
|
+
end
|
|
77
|
+
RUBY
|
|
78
|
+
end.join("\n\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_example(scenario)
|
|
82
|
+
body = example_body(scenario)
|
|
83
|
+
<<~RUBY.rstrip
|
|
84
|
+
it #{scenario.name.inspect} do
|
|
85
|
+
#{indent(body, 2)}
|
|
86
|
+
end
|
|
87
|
+
RUBY
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def example_body(scenario)
|
|
91
|
+
case scenario.category
|
|
92
|
+
when "association"
|
|
93
|
+
association_body(scenario)
|
|
94
|
+
when "validation"
|
|
95
|
+
validation_body(scenario)
|
|
96
|
+
when "happy_path"
|
|
97
|
+
happy_path_body(scenario)
|
|
98
|
+
else
|
|
99
|
+
generic_body(scenario)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def association_body(scenario)
|
|
104
|
+
name, macro = association_for(scenario)
|
|
105
|
+
return generic_body(scenario) unless name
|
|
106
|
+
|
|
107
|
+
lines = [
|
|
108
|
+
"association = described_class.reflect_on_association(#{name.to_sym.inspect})",
|
|
109
|
+
"expect(association).to be_present"
|
|
110
|
+
]
|
|
111
|
+
lines << "expect(association.macro).to eq(#{macro.to_sym.inspect})" if macro && !macro.empty?
|
|
112
|
+
comment(scenario) + lines.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validation_body(scenario)
|
|
116
|
+
attribute = attribute_for(scenario)
|
|
117
|
+
unique = unique?(scenario)
|
|
118
|
+
value = ruby_value(scenario.value.nil? || scenario.value.to_s.empty? ? "nil" : scenario.value)
|
|
119
|
+
expect_valid = valid_expectation?(scenario)
|
|
120
|
+
|
|
121
|
+
lines = []
|
|
122
|
+
lines.concat(comment_lines(scenario))
|
|
123
|
+
if unique && !expect_valid
|
|
124
|
+
dup_value = scenario.value.to_s.empty? ? '"duplicate@example.com"' : ruby_value(scenario.value)
|
|
125
|
+
lines << persist_record(attribute ? "#{attribute}: #{dup_value}" : nil)
|
|
126
|
+
lines << "record = #{build_record(attribute ? "#{attribute}: #{dup_value}" : nil)}"
|
|
127
|
+
else
|
|
128
|
+
attrs = attribute ? "#{attribute}: #{value}" : nil
|
|
129
|
+
lines << "record = #{build_record(expect_valid ? nil : attrs)}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if expect_valid
|
|
133
|
+
lines << "expect(record).to be_valid"
|
|
134
|
+
else
|
|
135
|
+
lines << "expect(record).not_to be_valid"
|
|
136
|
+
lines << "expect(record.errors[:#{attribute}]).to be_present" if attribute
|
|
137
|
+
end
|
|
138
|
+
lines.join("\n")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def happy_path_body(scenario)
|
|
142
|
+
lines = comment_lines(scenario)
|
|
143
|
+
lines << "record = #{build_record(nil)}"
|
|
144
|
+
lines << (valid_expectation?(scenario) ? "expect(record).to be_valid" : "expect(record).not_to be_valid")
|
|
145
|
+
lines.join("\n")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def generic_body(scenario)
|
|
149
|
+
lines = comment_lines(scenario)
|
|
150
|
+
attribute = attribute_for(scenario)
|
|
151
|
+
attrs = attribute ? "#{attribute}: #{ruby_value(scenario.value || 'nil')}" : nil
|
|
152
|
+
lines << "record = #{build_record(valid_expectation?(scenario) ? nil : attrs)}"
|
|
153
|
+
lines << (valid_expectation?(scenario) ? "expect(record).to be_valid" : "expect(record).not_to be_valid")
|
|
154
|
+
lines.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def persist_record(attrs)
|
|
158
|
+
combined = combine_assignments(attrs)
|
|
159
|
+
if @snapshot.has_factory?
|
|
160
|
+
"create(:#{factory_name}#{combined ? ", #{combined}" : ""})"
|
|
161
|
+
else
|
|
162
|
+
"described_class.create(#{combined || ''})"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_record(attrs)
|
|
167
|
+
combined = combine_assignments(attrs)
|
|
168
|
+
if @snapshot.has_factory?
|
|
169
|
+
"build(:#{factory_name}#{combined ? ", #{combined}" : ""})"
|
|
170
|
+
else
|
|
171
|
+
"described_class.new(#{combined || ''})"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def combine_assignments(overrides)
|
|
176
|
+
synced = sync_password_confirmation(overrides)
|
|
177
|
+
defaults = default_assignments
|
|
178
|
+
return synced if @snapshot.has_factory?
|
|
179
|
+
return synced if defaults.empty?
|
|
180
|
+
return defaults if synced.to_s.empty?
|
|
181
|
+
|
|
182
|
+
"#{defaults}, #{synced}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def sync_password_confirmation(attrs)
|
|
186
|
+
text = attrs.to_s
|
|
187
|
+
return attrs if text.empty?
|
|
188
|
+
return attrs unless @snapshot.known_attributes.include?("password_confirmation")
|
|
189
|
+
return attrs if text.match?(/password_confirmation:/)
|
|
190
|
+
return attrs unless text.match?(/\bpassword:/)
|
|
191
|
+
|
|
192
|
+
match = text.match(/password:\s*("[^"]*"|'[^']*'|nil)/)
|
|
193
|
+
return attrs unless match
|
|
194
|
+
return attrs if match[1] == "nil"
|
|
195
|
+
|
|
196
|
+
"#{text}, password_confirmation: #{match[1]}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def default_assignments
|
|
200
|
+
parts = []
|
|
201
|
+
known = @snapshot.known_attributes
|
|
202
|
+
parts << 'email: "user@example.com"' if known.include?("email")
|
|
203
|
+
if known.include?("password")
|
|
204
|
+
parts << 'password: "password123"'
|
|
205
|
+
parts << 'password_confirmation: "password123"' if known.include?("password_confirmation")
|
|
206
|
+
end
|
|
207
|
+
parts.join(", ")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def factory_name
|
|
211
|
+
@snapshot.factories[:name] || @snapshot.target.factory_name
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def helper_require
|
|
215
|
+
rails_helper? ? 'require "rails_helper"' : 'require "spec_helper"'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def upgrade_helper(contents)
|
|
219
|
+
return contents unless rails_helper?
|
|
220
|
+
|
|
221
|
+
self.class.upgrade_helper_source(contents)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def rails_helper?
|
|
225
|
+
@sandbox.exist?("spec/rails_helper.rb")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def attribute_for(scenario)
|
|
229
|
+
return scenario.attribute unless scenario.attribute.to_s.empty?
|
|
230
|
+
|
|
231
|
+
text = "#{scenario.name} #{Array(scenario.setup).join(' ')}"
|
|
232
|
+
attrs = Array(@snapshot.model[:validations]).flat_map { |item| Array(item[:attributes]) }
|
|
233
|
+
attrs.find { |attr| text.match?(/\b#{Regexp.escape(attr)}\b/i) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def association_for(scenario)
|
|
237
|
+
known = Array(@snapshot.model[:associations])
|
|
238
|
+
name = scenario.association.to_s
|
|
239
|
+
if name != ""
|
|
240
|
+
match = known.find { |item| (item[:name] || item["name"]).to_s == name }
|
|
241
|
+
return [match[:name] || match["name"], match[:macro] || match["macro"]] if match
|
|
242
|
+
|
|
243
|
+
return [nil, nil]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
text = "#{scenario.name} #{Array(scenario.setup).join(' ')}"
|
|
247
|
+
known.each do |association|
|
|
248
|
+
assoc_name = association[:name].to_s
|
|
249
|
+
macro = association[:macro].to_s
|
|
250
|
+
return [assoc_name, macro] if text.match?(/\b#{Regexp.escape(assoc_name)}\b/i) || text.match?(/#{macro.tr('_', ' ')}/i)
|
|
251
|
+
end
|
|
252
|
+
[nil, nil]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def unique?(scenario)
|
|
256
|
+
haystack = "#{scenario.name} #{scenario.expected_behavior} #{Array(scenario.setup).join(' ')}"
|
|
257
|
+
haystack.match?(/unique|uniqueness|duplicate/i)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def valid_expectation?(scenario)
|
|
261
|
+
scenario.expected_behavior.to_s.match?(/\bvalid\b/i) && !scenario.expected_behavior.to_s.match?(/invalid|not[_ ]valid/i)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def ruby_value(raw)
|
|
265
|
+
string = raw.to_s
|
|
266
|
+
return "nil" if string.empty? || string == "nil"
|
|
267
|
+
return "true" if string == "true"
|
|
268
|
+
return "false" if string == "false"
|
|
269
|
+
return string if string.match?(/\A-?\d+\z/)
|
|
270
|
+
return string if string.start_with?(":", '"', "'")
|
|
271
|
+
|
|
272
|
+
string.inspect
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def comment(scenario)
|
|
276
|
+
lines = comment_lines(scenario)
|
|
277
|
+
lines.empty? ? "" : "#{lines.join("\n")}\n"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def comment_lines(scenario)
|
|
281
|
+
Array(scenario.setup).reject(&:empty?).map { |item| "# setup: #{item}" }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def example_names(content)
|
|
285
|
+
content.scan(/it\s+["']([^"']+)["']/).flatten
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def merge(existing, addition)
|
|
289
|
+
stripped = existing.rstrip
|
|
290
|
+
return "#{stripped}\n\n#{indent(addition, 2)}\n" unless stripped.end_with?("end")
|
|
291
|
+
|
|
292
|
+
"#{stripped.sub(/end\z/, "")}#{indent(addition, 2)}\nend\n"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def indent(text, spaces)
|
|
296
|
+
prefix = " " * spaces
|
|
297
|
+
text.to_s.lines.map { |line| line.strip.empty? ? "\n" : "#{prefix}#{line.chomp}\n" }.join
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Generators
|
|
5
|
+
class RspecInstall
|
|
6
|
+
def initialize(sandbox: Sandbox.new)
|
|
7
|
+
@sandbox = sandbox
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def write
|
|
11
|
+
created = []
|
|
12
|
+
created << write_helper("spec/spec_helper.rb", spec_helper_source)
|
|
13
|
+
created << write_helper("spec/rails_helper.rb", rails_helper_source)
|
|
14
|
+
created.compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def write_helper(relative, contents)
|
|
20
|
+
return if @sandbox.exist?(relative)
|
|
21
|
+
|
|
22
|
+
@sandbox.write(relative, contents)
|
|
23
|
+
relative
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def spec_helper_source
|
|
27
|
+
<<~RUBY
|
|
28
|
+
# frozen_string_literal: true
|
|
29
|
+
|
|
30
|
+
RSpec.configure do |config|
|
|
31
|
+
config.expect_with :rspec do |expectations|
|
|
32
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
config.mock_with :rspec do |mocks|
|
|
36
|
+
mocks.verify_partial_doubles = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
config.shared_context_metadata_behavior = :apply_to_host_groups
|
|
40
|
+
end
|
|
41
|
+
RUBY
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rails_helper_source
|
|
45
|
+
<<~RUBY
|
|
46
|
+
# frozen_string_literal: true
|
|
47
|
+
|
|
48
|
+
require "spec_helper"
|
|
49
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
50
|
+
require_relative "../config/environment"
|
|
51
|
+
abort("The Rails environment is running in production mode!") if Rails.env.production?
|
|
52
|
+
require "rspec/rails"
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
ActiveRecord::Migration.maintain_test_schema!
|
|
56
|
+
rescue ActiveRecord::PendingMigrationError => error
|
|
57
|
+
abort error.to_s.strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
RSpec.configure do |config|
|
|
61
|
+
config.fixture_paths = [Rails.root.join("spec/fixtures")] if config.respond_to?(:fixture_paths=)
|
|
62
|
+
config.use_transactional_fixtures = true
|
|
63
|
+
config.infer_spec_type_from_file_location!
|
|
64
|
+
config.filter_rails_from_backtrace!
|
|
65
|
+
config.include FactoryBot::Syntax::Methods if defined?(FactoryBot)
|
|
66
|
+
end
|
|
67
|
+
RUBY
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Inflector
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def underscore(camel_cased)
|
|
8
|
+
camel_cased.to_s
|
|
9
|
+
.gsub("::", "/")
|
|
10
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
11
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
12
|
+
.tr("-", "_")
|
|
13
|
+
.downcase
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def demodulize(name)
|
|
17
|
+
name.to_s.split("::").last
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pluralize(word)
|
|
21
|
+
word = word.to_s
|
|
22
|
+
return word[0..-2] + "ies" if word.end_with?("y") && !word.end_with?("ay", "ey", "oy", "uy")
|
|
23
|
+
return word + "es" if word.end_with?("s", "x", "z", "ch", "sh")
|
|
24
|
+
|
|
25
|
+
"#{word}s"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def singularize(word)
|
|
29
|
+
word = word.to_s
|
|
30
|
+
return word[0..-4] + "y" if word.end_with?("ies") && word.length > 4
|
|
31
|
+
return word[0..-3] if word.end_with?("ses", "xes", "zes", "ches", "shes")
|
|
32
|
+
return word[0..-2] if word.end_with?("s") && !word.end_with?("ss")
|
|
33
|
+
|
|
34
|
+
word
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table_name(class_name)
|
|
38
|
+
parts = underscore(class_name).split("/")
|
|
39
|
+
parts[-1] = pluralize(parts[-1])
|
|
40
|
+
parts.join("_")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def model_path(class_name)
|
|
44
|
+
"app/models/#{underscore(class_name)}.rb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def spec_path(class_name)
|
|
48
|
+
"spec/models/#{underscore(class_name)}_spec.rb"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def factory_name(class_name)
|
|
52
|
+
underscore(demodulize(class_name))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def camelize(path_or_name)
|
|
56
|
+
path_or_name.to_s
|
|
57
|
+
.sub(/\.rb\z/, "")
|
|
58
|
+
.split("/")
|
|
59
|
+
.map { |part| part.split("_").map(&:capitalize).join }
|
|
60
|
+
.join("::")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Rails / RSpec model testing playbook for Spec AI.
|
|
2
|
+
|
|
3
|
+
Write scenarios that a Ruby generator can turn into RSpec. Do not emit Ruby code.
|
|
4
|
+
|
|
5
|
+
Prefer:
|
|
6
|
+
|
|
7
|
+
- type: :model specs
|
|
8
|
+
- FactoryBot `build` / `create` when a factory exists; otherwise `described_class.new`
|
|
9
|
+
- One example per behavior
|
|
10
|
+
- Explicit attributes for validations (`attribute`, `value`)
|
|
11
|
+
- Explicit association name and macro for association examples
|
|
12
|
+
- expected_behavior of "valid", "invalid", "has_association", or a short phrase
|
|
13
|
+
|
|
14
|
+
Validation coverage:
|
|
15
|
+
|
|
16
|
+
- presence: build with the attribute nil, expect not_to be_valid, expect errors[attribute]
|
|
17
|
+
- uniqueness: create one record, build a duplicate, expect invalid
|
|
18
|
+
- format/length/numericality: include a concrete invalid value in `value`
|
|
19
|
+
|
|
20
|
+
Association coverage:
|
|
21
|
+
|
|
22
|
+
- reflect_on_association(name).macro eq(:has_many|:belongs_to|:has_one|:has_and_belongs_to_many)
|
|
23
|
+
- dependent: :destroy can be an extra scenario if present on the model
|
|
24
|
+
|
|
25
|
+
Happy path:
|
|
26
|
+
|
|
27
|
+
- a valid record built from the factory / defaults should be_valid
|
|
28
|
+
|
|
29
|
+
Do not invent columns, associations, or callbacks that are not in the provided context.
|
|
30
|
+
Do not duplicate examples that already exist in the current spec.
|
|
31
|
+
Do not add association examples when the model has none.
|
|
32
|
+
Include edge_case scenarios when generation.include_edge_cases is true (empty strings, blank email, boolean flags).
|
|
33
|
+
Keep scenario names short and unique.
|
|
34
|
+
|
|
35
|
+
Devise (when the model uses devise :validatable):
|
|
36
|
+
|
|
37
|
+
- FactoryBot should set email, password, and password_confirmation.
|
|
38
|
+
- Devise password rules are presence and minimum length only. Do not require special characters, uppercase, or digits unless the model has a custom format validator.
|
|
39
|
+
- Email format is permissive (`foo@bar`, `user@.com` are valid). Only treat values without `@` as invalid format.
|
|
40
|
+
- Devise strips leading/trailing whitespace on email. Do not expect `" test@example.com"` to be invalid.
|
|
41
|
+
- `password_confirmation` may be nil. Only test a mismatch, not a missing confirmation.
|
|
42
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecAi
|
|
4
|
+
module Pipeline
|
|
5
|
+
class Fix
|
|
6
|
+
def initialize(input, ui: UI.new, sandbox: Sandbox.new)
|
|
7
|
+
@input = input
|
|
8
|
+
@ui = ui
|
|
9
|
+
@sandbox = sandbox
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
@ui.header
|
|
14
|
+
target = Target.new(@input, app_root: @sandbox.root)
|
|
15
|
+
unless @sandbox.exist?(target.spec_path)
|
|
16
|
+
raise TargetNotFound, "no spec file at #{target.spec_path}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
snapshot = Context::Builder.new(target, sandbox: @sandbox).build
|
|
20
|
+
@ui.analyzing(target.class_name)
|
|
21
|
+
@ui.context_collected(snapshot)
|
|
22
|
+
|
|
23
|
+
runner = Runners::RspecRunner.new(app_root: @sandbox.root)
|
|
24
|
+
runner.ensure_available!
|
|
25
|
+
result = @ui.spin_running { runner.run(target.spec_path) }
|
|
26
|
+
@ui.rspec_result(result)
|
|
27
|
+
|
|
28
|
+
if result.passed?
|
|
29
|
+
@ui.already_passing(target.spec_path)
|
|
30
|
+
return Result.new(target: target, spec_path: target.spec_path, plan: nil, run: result)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
spec_path = @sandbox.resolve!(target.spec_path)
|
|
34
|
+
if Generators::RspecGenerator.patch_helper!(target.spec_path, sandbox: @sandbox)
|
|
35
|
+
@ui.writing(relative(spec_path))
|
|
36
|
+
result = @ui.spin_running { runner.run(spec_path) }
|
|
37
|
+
@ui.rspec_result(result)
|
|
38
|
+
if result.passed?
|
|
39
|
+
@ui.already_passing(target.spec_path)
|
|
40
|
+
return Result.new(target: target, spec_path: spec_path, plan: nil, run: result)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attempts = 0
|
|
45
|
+
max = [SpecAi.configuration.max_repair_attempts.to_i, 1].max
|
|
46
|
+
plan = nil
|
|
47
|
+
|
|
48
|
+
while result.repairable? && attempts < max
|
|
49
|
+
attempts += 1
|
|
50
|
+
spec_contents = @sandbox.read(target.spec_path)
|
|
51
|
+
begin
|
|
52
|
+
plan = @ui.spin_repairing(attempts, max) do
|
|
53
|
+
Crews::RepairCrew.new(snapshot, result, spec_contents, previous_plan: plan, sandbox: @sandbox).plan
|
|
54
|
+
end
|
|
55
|
+
plan = plan.ground(snapshot) || plan
|
|
56
|
+
rescue InvalidPlan
|
|
57
|
+
@ui.skip_repair("The repair model did not return a valid test plan. The spec was left as-is with the failures above.")
|
|
58
|
+
break
|
|
59
|
+
end
|
|
60
|
+
spec_path = Generators::RspecGenerator.new(plan, snapshot, force: true, sandbox: @sandbox).write
|
|
61
|
+
@ui.writing(relative(spec_path))
|
|
62
|
+
result = @ui.spin_running { runner.run(spec_path) }
|
|
63
|
+
@ui.rspec_result(result)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@ui.completed(result.passed?, result: result)
|
|
67
|
+
Result.new(target: target, spec_path: spec_path, plan: plan, run: result, repair_attempts: attempts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def relative(path)
|
|
73
|
+
path.to_s.sub(%r{\A#{Regexp.escape(@sandbox.root)}/?}, "")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|