factory_sloth 1.2.0 → 1.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3550aaf57e03aa0da8d68264bab3c4a0a17ed264995859f34cc641e7e376b51b
4
- data.tar.gz: d947f5e3533b24b7740d509b5e9b8f3a832a6c535eb81f859ba7c562127c3e5a
3
+ metadata.gz: c628e26d703d50de9a6f087174d01132942eb557ccbb4519a173116922cb2541
4
+ data.tar.gz: 40c2d6873fb446d5a8dbba7339ebc481e766989ff722531e24d891213bac3434
5
5
  SHA512:
6
- metadata.gz: e9abcbc30c854da1ec58f4ab292ab6ed9a851dcbd11bd00291b2902bdec14ec79c21145033c2a984b7243831dd27b426bf5e33f6e9d22272d673a9ff2f61dd97
7
- data.tar.gz: '03280bc6477e646583c5af49d46e8bacbf611387181c3e0625e007c019170b697151a78fbd1698a859358a512f483a9949f3b6b27006fe92fc85fd10ccb2b149'
6
+ metadata.gz: aa04cdbe7e3bf0fe25b9754f6fa5d356db63c4cf18ef193c5a0b11ae8629c36cd3e39ffda5fa71e47cf1ddef16b0dc4b84564c87cc6e493169150bbe35aeccd3
7
+ data.tar.gz: 8bfc7521d09398337217f0ed04adccda7380f653efd46c2c0d39858082d7d949996142b5bf074eaab36524b3df197a29875b72bd21a8b7a326886303cbd1d9fa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.2.2] - 2023-05-18
4
+
5
+ ### Fixed
6
+
7
+ - No longer changes create to build for records that are persisted later
8
+ - Fixed duplicate entries in `.factory_sloth_done` file
9
+
10
+ ## [1.2.1] - 2023-05-17
11
+
12
+ ### Fixed
13
+
14
+ - Fixed handling of path-based, derived spec metadata
15
+ - Thanks to https://github.com/bquorning
16
+ - Fixed unnecessary spec runs for files without create calls or changes
17
+ - Fixed modification of factory calls that never run (e.g. in skipped examples)
18
+
3
19
  ## [1.2.0] - 2023-05-16
4
20
 
5
21
  ### Added
data/README.md CHANGED
@@ -44,7 +44,7 @@ Processing spec/lib/string_ext_spec.rb ...
44
44
 
45
45
  Processing spec/models/user_spec.rb ...
46
46
  - create in line 3 can be replaced with build
47
- - create in line 4 can be replaced with build
47
+ - create_list in line 4 can be replaced with build_list
48
48
  🟢 3 create calls found, 2 replaced
49
49
 
50
50
  Processing spec/weird_dir/crazy_spec.rb ...
@@ -56,13 +56,23 @@ Scanned 4 files, found 2 unnecessary create calls across 1 files and 1 broken sp
56
56
 
57
57
  The `conflict` case is rare. It only happens if individual examples were green after changing them, but at least one example failed when evaluating the whole file after all changes. This probably means that some other example was red even before making changes, or that something else is wrong with this spec file, e.g. some examples depend on other examples' side effects.
58
58
 
59
- ## Limitations
59
+ ## Limitations / known issues
60
60
 
61
- - only works with RSpec so far
62
- - downgrades create calls that never run (e.g. in skipped examples)
63
- - downgrades create calls that are only checked for an absence of effects
64
- - e.g. `a = create(:a); b = create(:b); expect(Record.filtered).to eq [b]`
65
- - `# sloth:disable` / `# sloth:enable` comments can be used to control this
61
+ `factory_sloth` only works with RSpec so far. It also works best with unit tests such as model specs. It generates **false positives** in cases where create calls are done but only the *absence* of any effect is tested, e.g.:
62
+
63
+ ```ruby
64
+ user = create(:user)
65
+ User.delete_all
66
+ expect(User.count).to eq 0
67
+ ```
68
+
69
+ This test will still pass if `user` is never inserted into the database in the first place, leading `factory_sloth` to believe that `build` suffices here. However, this change makes the test no longer assert the same thing and reduces coverage. Magic comments can be used to prevent `factory_sloth` from making such changes. `factory_sloth` will not touch lines with inline `# sloth:disable` comments, or sections framed in `# sloth:disable` / `# sloth:enable` comments. Another option is to write the test in a different (and arguably more assertive) way, e.g.:
70
+
71
+ ```ruby
72
+ expect { User.delete_all }.to change { User.count }.from(1).to(0)
73
+ ```
74
+
75
+ If you have a good idea about how to detect such cases automatically, let me know :)
66
76
 
67
77
  ## Development
68
78
 
@@ -1,11 +1,12 @@
1
1
  class FactorySloth::CodeMod
2
- attr_reader :create_calls, :changed_create_calls, :original_code, :patched_code
2
+ attr_reader :create_calls, :changed_create_calls, :path, :original_code, :patched_code
3
3
 
4
- def self.call(code)
5
- new(code).tap(&:call)
4
+ def self.call(path, code)
5
+ new(path, code).tap(&:call)
6
6
  end
7
7
 
8
- def initialize(code)
8
+ def initialize(path, code)
9
+ self.path = path
9
10
  self.original_code = code
10
11
  self.patched_code = code
11
12
  end
@@ -18,14 +19,18 @@ class FactorySloth::CodeMod
18
19
  # rspec call. However, this would make it impossible to use `--fail-fast`,
19
20
  # and might make examples fail that are not as idempotent as they should be.
20
21
  self.changed_create_calls =
21
- create_calls.sort_by { |line, col| [-line, -col] }.select do |line, col|
22
- try_patch(line, col, 'build') || try_patch(line, col, 'build_stubbed')
23
- end.sort
22
+ create_calls.sort_by { |call| [-call.line, -call.column] }.select do |call|
23
+ build_result = try_patch(call, 'build')
24
+ next if build_result == ABORT
25
+
26
+ build_result == SUCCESS || try_patch(call, 'build_stubbed') == SUCCESS
27
+ end
24
28
 
25
29
  # validate whole spec after changes, e.g. to detect side-effects
26
- self.ok = FactorySloth::SpecRunner.call(patched_code)
27
- self.changed_create_calls.clear unless ok?
28
- self.patched_code = original_code unless ok?
30
+ self.ok = changed_create_calls.none? ||
31
+ FactorySloth::SpecRunner.call(path, patched_code).success?
32
+ changed_create_calls.clear unless ok?
33
+ patched_code.replace(original_code) unless ok?
29
34
  end
30
35
 
31
36
  def ok?
@@ -46,14 +51,66 @@ class FactorySloth::CodeMod
46
51
 
47
52
  private
48
53
 
49
- attr_writer :create_calls, :changed_create_calls, :ok, :original_code, :patched_code
54
+ attr_writer :create_calls, :changed_create_calls, :ok, :path, :original_code, :patched_code
55
+
56
+ def try_patch(call, base_variant)
57
+ variant = call.name.sub('create', base_variant)
58
+ new_patched_code = patched_code.sub(
59
+ /\A(?:.*\n){#{call.line - 1}}.{#{call.column}}\K#{call.name}/,
60
+ variant
61
+ )
62
+ checked_patched_code = new_patched_code + checks(call.line, variant)
50
63
 
51
- def try_patch(line, col, variant)
52
- new_patched_code =
53
- patched_code.sub(/\A(?:.*\n){#{line - 1}}.{#{col}}\Kcreate/, variant)
54
- if FactorySloth::SpecRunner.call(new_patched_code, line: line)
55
- puts "- create in line #{line} can be replaced with #{variant}"
64
+ result = FactorySloth::SpecRunner.call(path, checked_patched_code, line: call.line)
65
+ if result.success?
66
+ puts "- #{call.name} in line #{call.line} can be replaced with #{variant}"
56
67
  self.patched_code = new_patched_code
68
+ SUCCESS
69
+ elsif result.exitstatus == FACTORY_UNUSED_CODE
70
+ puts "- #{call.name} in line #{call.line} is never executed, skipping"
71
+ ABORT
72
+ elsif result.exitstatus == FACTORY_PERSISTED_LATER_CODE
73
+ ABORT
57
74
  end
58
75
  end
76
+
77
+ ABORT = :ABORT # returned if there is no need to try other variants
78
+ SUCCESS = :SUCCESS
79
+
80
+ FACTORY_UNUSED_CODE = 77
81
+ FACTORY_PERSISTED_LATER_CODE = 78
82
+
83
+ # This adds code that makes a spec run fail and thus prevents changes if:
84
+ # a) the patched factory in the given line is never called
85
+ # b) the built record was persisted later anyway
86
+ # The rationale behind a) is that things like skipped examples should not
87
+ # be broken. The rationale behind b) is that not much DB work would be saved,
88
+ # but diff noise would be increased and ease of editing the example reduced.
89
+ def checks(line, variant)
90
+ <<~RUBY
91
+ ; defined?(FactoryBot) && defined?(RSpec) && RSpec.configure do |config|
92
+ records_by_line = {} # track records initialized through factories per line
93
+
94
+ FactoryBot::Syntax::Methods.class_eval do
95
+ alias ___original_#{variant} #{variant} # e.g. ___original_build build
96
+
97
+ define_method("#{variant}") do |*args, **kwargs, &blk| # e.g. build
98
+ result = ___original_#{variant}(*args, **kwargs, &blk)
99
+ list = records_by_line[caller_locations(1, 1)&.first&.lineno] ||= []
100
+ list.concat([result].flatten) # to work with single, list, and pair
101
+ result
102
+ end
103
+ end
104
+
105
+ config.after(:suite) do
106
+ records = records_by_line[#{line}]
107
+ records&.any? || exit!(#{FACTORY_UNUSED_CODE})
108
+ unless "#{variant}".include?('stub') # factory_bot stub stubs persisted? as true
109
+ records.any? { |r| r.respond_to?(:persisted?) && r.persisted? } &&
110
+ exit!(#{FACTORY_PERSISTED_LATER_CODE})
111
+ end
112
+ end
113
+ end
114
+ RUBY
115
+ end
59
116
  end
@@ -0,0 +1 @@
1
+ FactorySloth::CreateCall = Struct.new(:name, :line, :column, keyword_init: true)
@@ -1,50 +1,51 @@
1
1
  require 'ripper'
2
2
 
3
3
  class FactorySloth::CreateCallFinder < Ripper
4
- attr_reader :locations
4
+ attr_reader :calls
5
5
 
6
6
  def self.call(code:)
7
- new(code).tap(&:parse).locations
7
+ new(code).tap(&:parse).calls
8
8
  end
9
9
 
10
10
  def initialize(code, ...)
11
11
  super
12
12
  @code = code
13
13
  @disabled = false
14
- @locations = []
14
+ @calls = []
15
15
  end
16
16
  private_class_method :new
17
17
 
18
- def store_location(loc)
19
- @locations << loc if loc.instance_of?(Array) && !@disabled
18
+ def store_call(obj)
19
+ @calls << obj if obj.is_a?(FactorySloth::CreateCall) && !@disabled
20
20
  end
21
21
 
22
22
  def on_ident(name, *)
23
- [lineno, column] if %w[create create_list create_pair].include?(name)
23
+ %w[create create_list create_pair].include?(name) &&
24
+ FactorySloth::CreateCall.new(name: name, line: lineno, column: column)
24
25
  end
25
26
 
26
- def on_call(mod, _, loc, *)
27
- store_location(loc) if mod == 'FactoryBot'
27
+ def on_call(mod, _, obj, *)
28
+ store_call(obj) if mod == 'FactoryBot'
28
29
  end
29
30
 
30
- def on_command_call(mod, _, loc, *)
31
- store_location(loc) if mod == 'FactoryBot'
31
+ def on_command_call(mod, _, obj, *)
32
+ store_call(obj) if mod == 'FactoryBot'
32
33
  end
33
34
 
34
35
  def on_comment(text, *)
35
36
  return unless /sloth:(?<directive>disable|enable)/ =~ text
36
37
 
37
38
  directive == 'disable' &&
38
- @locations.reject! { |loc| loc.first == lineno } ||
39
+ @calls.reject! { |obj| obj[1] == lineno } ||
39
40
  (@lines ||= @code.lines)[lineno - 1].match?(/^\s*#/) &&
40
41
  (@disabled = directive != 'enable')
41
42
  end
42
43
 
43
44
  def on_fcall(loc, *)
44
- store_location(loc)
45
+ store_call(loc)
45
46
  end
46
47
 
47
48
  def on_vcall(loc, *)
48
- store_location(loc)
49
+ store_call(loc)
49
50
  end
50
51
  end
@@ -7,6 +7,8 @@ module FactorySloth::DoneTracker
7
7
 
8
8
  def mark_as_done(path)
9
9
  normalized_path = normalize(path)
10
+ return if done?(normalized_path)
11
+
10
12
  done << normalized_path
11
13
  File.open(file, 'a') { |f| f.puts(normalized_path) }
12
14
  end
@@ -21,7 +21,7 @@ module FactorySloth
21
21
 
22
22
  def process(path, dry_run:)
23
23
  code = File.read(path)
24
- result = CodeMod.call(code)
24
+ result = CodeMod.call(path, code)
25
25
  unless dry_run
26
26
  File.write(path, result.patched_code) if result.changed?
27
27
  end
@@ -1,13 +1,22 @@
1
- require 'tempfile'
1
+ require 'open3'
2
+ require 'tmpdir'
2
3
 
3
4
  module FactorySloth::SpecRunner
4
- def self.call(spec_code, line: nil)
5
- tempfile = Tempfile.new
6
- tempfile.write(spec_code)
7
- tempfile.close
8
- path = [tempfile.path, line].compact.map(&:to_s).join(':')
9
- result = !!system("bundle exec rspec #{path} --fail-fast 1>/dev/null 2>&1")
10
- tempfile.unlink
11
- result
5
+ def self.call(spec_path, spec_code, line: nil)
6
+ path = File.join(tmpdir, spec_path)
7
+ FileUtils.mkdir_p(File.dirname(path))
8
+ File.write(path, spec_code)
9
+ path_arg = [path, line].compact.map(&:to_s).join(':')
10
+ command = "bundle exec rspec #{path_arg} --fail-fast --order defined 2>&1"
11
+ _output, status = Open3.capture2(command)
12
+ status
13
+ end
14
+
15
+ def self.tmpdir
16
+ @tmpdir ||= begin
17
+ dir = Dir.mktmpdir('factory_sloth-')
18
+ at_exit { FileUtils.remove_entry(dir) if File.exist?(dir) }
19
+ dir
20
+ end
12
21
  end
13
22
  end
@@ -1,3 +1,3 @@
1
1
  module FactorySloth
2
- VERSION = '1.2.0'
2
+ VERSION = '1.2.2'
3
3
  end
data/lib/factory_sloth.rb CHANGED
@@ -2,6 +2,7 @@ module FactorySloth; end
2
2
 
3
3
  require_relative 'factory_sloth/cli'
4
4
  require_relative 'factory_sloth/code_mod'
5
+ require_relative 'factory_sloth/create_call'
5
6
  require_relative 'factory_sloth/create_call_finder'
6
7
  require_relative 'factory_sloth/done_tracker'
7
8
  require_relative 'factory_sloth/file_processor'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factory_sloth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janosch Müller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-05-16 00:00:00.000000000 Z
11
+ date: 2023-05-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -28,6 +28,7 @@ files:
28
28
  - lib/factory_sloth.rb
29
29
  - lib/factory_sloth/cli.rb
30
30
  - lib/factory_sloth/code_mod.rb
31
+ - lib/factory_sloth/create_call.rb
31
32
  - lib/factory_sloth/create_call_finder.rb
32
33
  - lib/factory_sloth/done_tracker.rb
33
34
  - lib/factory_sloth/file_processor.rb