foundries 0.1.3 → 0.1.5
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 +4 -4
- data/CHANGELOG.md +13 -10
- data/README.md +45 -0
- data/lib/foundries/recording/aggregator.rb +119 -0
- data/lib/foundries/recording/collector.rb +90 -0
- data/lib/foundries/recording/node.rb +69 -0
- data/lib/foundries/recording/rake_task.rb +43 -0
- data/lib/foundries/recording/reporter.rb +114 -0
- data/lib/foundries/recording.rb +71 -0
- data/lib/foundries/similarity/comparator.rb +1 -1
- data/lib/foundries/similarity/structure_tree.rb +1 -1
- data/lib/foundries/snapshot/adapters/postgres_adapter.rb +4 -0
- data/lib/foundries/snapshot/adapters/sqlite_adapter.rb +5 -1
- data/lib/foundries/snapshot/store.rb +35 -1
- data/lib/foundries/version.rb +2 -2
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05ac2ed1c8c00feab7b078f4e3dcd9b5fd8c24fdb6c1f7278aab3c4df6f85898
|
|
4
|
+
data.tar.gz: 174371e6e22eb9b6764dd4d70fb90988bc276e4a0e32c032b45254d924261f80
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f79398b03c229960553bb6d2283254f5fa832166e497811ba7fc488db50a499288ee987a379f18e92cdc3a237bae6dcc41aefb5c6ddb1f3ecc861b7f354b62cb
|
|
7
|
+
data.tar.gz: 8870ca65270252f4f7f29392e456a230c167aa170a7d6bacc54d6a10452e3398a42fefc3767d283faac944e0620c800d9c58dd1170bbeff4f932fdf282fcc48e
|
data/CHANGELOG.md
CHANGED
|
@@ -5,22 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
|
7
7
|
|
|
8
|
-
## [0.1.
|
|
8
|
+
## [0.1.5] - 2026-08-12
|
|
9
9
|
|
|
10
|
-
###
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Development dependencies are locked (1129826)
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
11
15
|
|
|
12
|
-
-
|
|
16
|
+
- Snapshot no longer caches a partial tree when a preset writes to a table that already held rows (2e9f863)
|
|
17
|
+
- Lint under standard 1.56.0 (52be9eb)
|
|
18
|
+
- Releases write a SHA512 checksum again (813a2ae)
|
|
13
19
|
|
|
14
|
-
## [0.1.
|
|
20
|
+
## [0.1.4] - 2026-03-16
|
|
15
21
|
|
|
16
22
|
### Added
|
|
17
23
|
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
- find_or_create pattern on Blueprint (377632f)
|
|
21
|
-
- Tests for inherited registries, parent-scoped find, collection_find_by kwargs, find_or_create, ascending_find, and parent_present? (2d92945)
|
|
24
|
+
- Recording module to analyze FactoryBot usage and suggest preset candidates (a294324)
|
|
25
|
+
- parallel_tests support for Recording with per-worker output and merge rake task (8e59deb)
|
|
22
26
|
|
|
23
27
|
### Changed
|
|
24
28
|
|
|
25
|
-
-
|
|
26
|
-
- Fix find parent scoping and collection_find_by kwargs handling (3ebfc3e)
|
|
29
|
+
- README now covers Recording feature and parallel_tests support (69fa45b)
|
data/README.md
CHANGED
|
@@ -224,6 +224,51 @@ When two presets share identical structure or one is structurally contained with
|
|
|
224
224
|
|
|
225
225
|
Each unique pair is warned once per process. The detection normalizes trees by deduplicating sibling nodes (keeping the richest subtree), collapsing pass-through chains, and sorting alphabetically. This means presets that build the same *shape* of data are detected regardless of the specific names or attribute values used.
|
|
226
226
|
|
|
227
|
+
## Factory Usage Recording
|
|
228
|
+
|
|
229
|
+
If you're migrating an existing test suite to Foundries, the recording feature can help you discover which factory call patterns appear most often — and would make good preset candidates.
|
|
230
|
+
|
|
231
|
+
Add to your `spec_helper.rb`:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
require "foundries/recording"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Then run your suite with the environment variable:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
FOUNDRIES_RECORD=1 bundle exec rspec
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
After the suite finishes, a summary is printed to stdout:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
[Foundries] Recording complete. 482 tests, 3841 factory creates.
|
|
247
|
+
[Foundries] Top preset candidates:
|
|
248
|
+
1. team > [project > [task], user] (34 tests, score: 102)
|
|
249
|
+
2. team > [user] (28 tests, score: 56)
|
|
250
|
+
3. team > [project > [task > [comment]], user] (12 tests, score: 60)
|
|
251
|
+
[Foundries] Full report: tmp/foundries/recording.json
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The full JSON report at `tmp/foundries/recording.json` includes per-test breakdowns and all candidates with the test names that use each pattern.
|
|
255
|
+
|
|
256
|
+
### Parallel tests
|
|
257
|
+
|
|
258
|
+
Recording works with [parallel_tests](https://github.com/grosser/parallel_tests). Each worker writes its own file, then a rake task merges the results:
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
FOUNDRIES_RECORD=1 bundle exec parallel_rspec
|
|
262
|
+
rake foundries:recording:merge
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
To use the rake task, add to your `Rakefile`:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
require "foundries/recording/rake_task"
|
|
269
|
+
Foundries::Recording::RakeTask.install
|
|
270
|
+
```
|
|
271
|
+
|
|
227
272
|
## Requirements
|
|
228
273
|
|
|
229
274
|
- Ruby >= 4.0
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "node"
|
|
4
|
+
|
|
5
|
+
module Foundries
|
|
6
|
+
module Recording
|
|
7
|
+
class Aggregator
|
|
8
|
+
def initialize(results)
|
|
9
|
+
@results = results
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def candidates
|
|
13
|
+
return [] if @results.empty?
|
|
14
|
+
|
|
15
|
+
normalized = normalize_results
|
|
16
|
+
full_tree_candidates = group_by_full_tree(normalized)
|
|
17
|
+
subtree_candidates = find_common_subtrees(normalized)
|
|
18
|
+
merged = merge_candidates(full_tree_candidates, subtree_candidates)
|
|
19
|
+
filtered = merged.reject { |c| c[:tree_size] <= 1 }
|
|
20
|
+
filtered.sort_by { |c| -c[:score] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_results
|
|
26
|
+
@results.transform_values(&:normalize)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def group_by_full_tree(normalized)
|
|
30
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
31
|
+
|
|
32
|
+
normalized.each do |test_id, root|
|
|
33
|
+
structure = root_structure(root)
|
|
34
|
+
groups[structure] << test_id
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
groups.map do |structure, tests|
|
|
38
|
+
sample_root = normalized[tests.first]
|
|
39
|
+
tree_size = sample_root.children.sum(&:tree_size)
|
|
40
|
+
{
|
|
41
|
+
structure: structure,
|
|
42
|
+
frequency: tests.size,
|
|
43
|
+
tree_size: tree_size,
|
|
44
|
+
score: tests.size * tree_size,
|
|
45
|
+
tests: tests.map(&:to_s)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def root_structure(root)
|
|
51
|
+
root.children.sort_by(&:signature).map(&:signature).join(", ")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def find_common_subtrees(normalized)
|
|
55
|
+
subtree_tests = Hash.new { |h, k| h[k] = Set.new }
|
|
56
|
+
subtree_nodes = {}
|
|
57
|
+
|
|
58
|
+
normalized.each do |test_id, root|
|
|
59
|
+
root.children.each do |child|
|
|
60
|
+
extract_nontrivial_subtrees(child).each do |subtree|
|
|
61
|
+
sig = subtree.signature
|
|
62
|
+
subtree_tests[sig] << test_id
|
|
63
|
+
subtree_nodes[sig] ||= subtree
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
subtree_tests.map do |sig, tests|
|
|
69
|
+
subtree = subtree_nodes[sig]
|
|
70
|
+
{
|
|
71
|
+
structure: sig,
|
|
72
|
+
frequency: tests.size,
|
|
73
|
+
tree_size: subtree.tree_size,
|
|
74
|
+
score: tests.size * subtree.tree_size,
|
|
75
|
+
tests: tests.map(&:to_s)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_nontrivial_subtrees(node)
|
|
81
|
+
subtrees = []
|
|
82
|
+
# A non-trivial subtree is a node that has children
|
|
83
|
+
subtrees << node unless node.children.empty?
|
|
84
|
+
node.children.each do |child|
|
|
85
|
+
subtrees.concat(extract_nontrivial_subtrees(child))
|
|
86
|
+
end
|
|
87
|
+
subtrees
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def merge_candidates(full_tree, subtree)
|
|
91
|
+
by_structure = {}
|
|
92
|
+
|
|
93
|
+
full_tree.each do |candidate|
|
|
94
|
+
by_structure[candidate[:structure]] = candidate
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
subtree.each do |candidate|
|
|
98
|
+
if by_structure.key?(candidate[:structure])
|
|
99
|
+
existing = by_structure[candidate[:structure]]
|
|
100
|
+
merged_tests = (existing[:tests] + candidate[:tests]).uniq
|
|
101
|
+
frequency = merged_tests.size
|
|
102
|
+
tree_size = candidate[:tree_size]
|
|
103
|
+
by_structure[candidate[:structure]] = {
|
|
104
|
+
structure: candidate[:structure],
|
|
105
|
+
frequency: frequency,
|
|
106
|
+
tree_size: tree_size,
|
|
107
|
+
score: frequency * tree_size,
|
|
108
|
+
tests: merged_tests
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
by_structure[candidate[:structure]] = candidate
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
by_structure.values
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require_relative "node"
|
|
5
|
+
|
|
6
|
+
module Foundries
|
|
7
|
+
module Recording
|
|
8
|
+
class Collector
|
|
9
|
+
def initialize
|
|
10
|
+
@results = {}
|
|
11
|
+
@total_creates = 0
|
|
12
|
+
@subscriber = nil
|
|
13
|
+
@stack = nil
|
|
14
|
+
@current_root = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def start_test(test_id)
|
|
18
|
+
@current_root = MutableNode.new(:__root__, [])
|
|
19
|
+
@stack = [@current_root]
|
|
20
|
+
|
|
21
|
+
@subscriber = ActiveSupport::Notifications.subscribe(
|
|
22
|
+
"factory_bot.run_factory",
|
|
23
|
+
Subscriber.new(self)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stop_test(test_id)
|
|
28
|
+
ActiveSupport::Notifications.unsubscribe(@subscriber)
|
|
29
|
+
@subscriber = nil
|
|
30
|
+
|
|
31
|
+
@results[test_id] = freeze_tree(@current_root)
|
|
32
|
+
@stack = nil
|
|
33
|
+
@current_root = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :results
|
|
37
|
+
|
|
38
|
+
attr_reader :total_creates
|
|
39
|
+
|
|
40
|
+
# @api private — called by the Subscriber
|
|
41
|
+
def handle_start(payload)
|
|
42
|
+
return unless payload[:strategy] == :create
|
|
43
|
+
|
|
44
|
+
traits = payload[:traits] || []
|
|
45
|
+
mutable = MutableNode.new(payload[:name], traits)
|
|
46
|
+
@stack.last.children << mutable
|
|
47
|
+
@stack.push(mutable)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @api private — called by the Subscriber
|
|
51
|
+
def handle_finish(payload)
|
|
52
|
+
return unless payload[:strategy] == :create
|
|
53
|
+
|
|
54
|
+
@stack.pop
|
|
55
|
+
@total_creates += 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def freeze_tree(mutable)
|
|
61
|
+
frozen_children = mutable.children.map { |child| freeze_tree(child) }
|
|
62
|
+
Node.new(
|
|
63
|
+
factory: mutable.factory,
|
|
64
|
+
traits: mutable.traits,
|
|
65
|
+
children: frozen_children
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
MutableNode = Struct.new(:factory, :traits) do
|
|
70
|
+
def children
|
|
71
|
+
@children ||= []
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class Subscriber
|
|
76
|
+
def initialize(collector)
|
|
77
|
+
@collector = collector
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def start(_name, _id, payload)
|
|
81
|
+
@collector.handle_start(payload)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def finish(_name, _id, payload)
|
|
85
|
+
@collector.handle_finish(payload)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foundries
|
|
4
|
+
module Recording
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :factory, :traits, :children
|
|
7
|
+
|
|
8
|
+
def initialize(factory:, traits: [], children: [])
|
|
9
|
+
@factory = factory.to_sym
|
|
10
|
+
@traits = traits.map(&:to_sym).sort.freeze
|
|
11
|
+
@children = children.freeze
|
|
12
|
+
@self_signature = compute_self_signature
|
|
13
|
+
@signature = compute_signature
|
|
14
|
+
@tree_size = 1 + children.sum(&:tree_size)
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :signature, :self_signature, :tree_size
|
|
19
|
+
|
|
20
|
+
def normalize
|
|
21
|
+
normalized_children = children.map(&:normalize)
|
|
22
|
+
deduped = normalized_children
|
|
23
|
+
.group_by(&:self_signature)
|
|
24
|
+
.map { |_sig, group| group.max_by(&:tree_size) }
|
|
25
|
+
.sort_by(&:signature)
|
|
26
|
+
self.class.new(factory: factory, traits: traits, children: deduped)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
factory: factory.to_s,
|
|
32
|
+
traits: traits.map(&:to_s),
|
|
33
|
+
children: children.map(&:to_h)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ==(other)
|
|
38
|
+
other.is_a?(self.class) && signature == other.signature
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
alias_method :eql?, :==
|
|
42
|
+
|
|
43
|
+
def hash
|
|
44
|
+
signature.hash
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
signature
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def compute_self_signature
|
|
54
|
+
base = factory.to_s
|
|
55
|
+
base = "#{base}[#{traits.map { |t| ":#{t}" }.join(", ")}]" unless traits.empty?
|
|
56
|
+
base
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compute_signature
|
|
60
|
+
base = @self_signature
|
|
61
|
+
unless children.empty?
|
|
62
|
+
sorted_children = children.sort_by(&:signature)
|
|
63
|
+
base = "#{base} > [#{sorted_children.map(&:signature).join(", ")}]"
|
|
64
|
+
end
|
|
65
|
+
base
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
require_relative "../recording"
|
|
5
|
+
|
|
6
|
+
module Foundries
|
|
7
|
+
module Recording
|
|
8
|
+
class RakeTask
|
|
9
|
+
include Rake::DSL
|
|
10
|
+
|
|
11
|
+
def self.install(output_path: Recording::DEFAULT_OUTPUT_PATH)
|
|
12
|
+
new.install_tasks(output_path: output_path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def install_tasks(output_path:)
|
|
16
|
+
namespace :foundries do
|
|
17
|
+
namespace :recording do
|
|
18
|
+
desc "Merge per-worker recording files into a single report"
|
|
19
|
+
task :merge do
|
|
20
|
+
dir = File.dirname(output_path)
|
|
21
|
+
base = File.basename(output_path, File.extname(output_path))
|
|
22
|
+
ext = File.extname(output_path)
|
|
23
|
+
pattern = File.join(dir, "#{base}-*#{ext}")
|
|
24
|
+
files = Dir.glob(pattern).sort
|
|
25
|
+
|
|
26
|
+
if files.empty?
|
|
27
|
+
$stdout.puts "[Foundries] No worker recording files found matching #{pattern}"
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Foundries::Recording::RakeTask.merge(files, output_path: output_path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.merge(files, output_path:)
|
|
38
|
+
summary = Reporter.merge_files(files, output_path: output_path)
|
|
39
|
+
$stdout.puts summary
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "aggregator"
|
|
7
|
+
require_relative "node"
|
|
8
|
+
|
|
9
|
+
module Foundries
|
|
10
|
+
module Recording
|
|
11
|
+
class Reporter
|
|
12
|
+
MAX_STDOUT_CANDIDATES = 10
|
|
13
|
+
|
|
14
|
+
def self.merge_files(paths, output_path:)
|
|
15
|
+
combined_per_test = {}
|
|
16
|
+
total_creates = 0
|
|
17
|
+
|
|
18
|
+
paths.each do |path|
|
|
19
|
+
data = JSON.parse(File.read(path))
|
|
20
|
+
total_creates += data["total_factory_creates"]
|
|
21
|
+
data["per_test"].each do |test_id, test_data|
|
|
22
|
+
combined_per_test[test_id] = test_data
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
total_tests = combined_per_test.size
|
|
27
|
+
|
|
28
|
+
results = combined_per_test.transform_values do |test_data|
|
|
29
|
+
children = (test_data["tree"] || []).map { |h| node_from_hash(h) }
|
|
30
|
+
Node.new(factory: :__root__, children: children)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
reporter = new(results: results, total_creates: total_creates, total_tests: total_tests)
|
|
34
|
+
reporter.write_json(output_path)
|
|
35
|
+
reporter.summary(json_path: output_path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.node_from_hash(hash)
|
|
39
|
+
children = (hash["children"] || []).map { |c| node_from_hash(c) }
|
|
40
|
+
Node.new(
|
|
41
|
+
factory: hash["factory"].to_sym,
|
|
42
|
+
traits: (hash["traits"] || []).map(&:to_sym),
|
|
43
|
+
children: children
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private_class_method :node_from_hash
|
|
48
|
+
|
|
49
|
+
def initialize(results:, total_creates:, total_tests:)
|
|
50
|
+
@results = results
|
|
51
|
+
@total_creates = total_creates
|
|
52
|
+
@total_tests = total_tests
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def candidates
|
|
56
|
+
@candidates ||= Aggregator.new(@results).candidates
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write_json(path)
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
61
|
+
File.write(path, JSON.pretty_generate(json_data))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def summary(json_path: nil)
|
|
65
|
+
lines = []
|
|
66
|
+
lines << "[Foundries] Recording complete. #{@total_tests} tests, #{@total_creates} factory creates."
|
|
67
|
+
|
|
68
|
+
if candidates.empty?
|
|
69
|
+
lines << "[Foundries] No preset candidates found."
|
|
70
|
+
else
|
|
71
|
+
lines << "[Foundries] Top preset candidates:"
|
|
72
|
+
candidates.first(MAX_STDOUT_CANDIDATES).each_with_index do |candidate, index|
|
|
73
|
+
lines << " #{index + 1}. #{candidate[:structure]} (#{candidate[:frequency]} tests, score: #{candidate[:score]})"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
lines << "[Foundries] Full report: #{json_path}" if json_path
|
|
78
|
+
|
|
79
|
+
lines.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def json_data
|
|
85
|
+
{
|
|
86
|
+
recorded_at: Time.now.utc.iso8601,
|
|
87
|
+
total_tests: @total_tests,
|
|
88
|
+
total_factory_creates: @total_creates,
|
|
89
|
+
candidates: candidates.map { |c| stringify_candidate(c) },
|
|
90
|
+
per_test: build_per_test
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stringify_candidate(candidate)
|
|
95
|
+
{
|
|
96
|
+
structure: candidate[:structure],
|
|
97
|
+
frequency: candidate[:frequency],
|
|
98
|
+
tree_size: candidate[:tree_size],
|
|
99
|
+
score: candidate[:score],
|
|
100
|
+
tests: candidate[:tests]
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_per_test
|
|
105
|
+
@results.each_with_object({}) do |(test_id, root), hash|
|
|
106
|
+
hash[test_id.to_s] = {
|
|
107
|
+
factories_created: root.children.size,
|
|
108
|
+
tree: root.children.map(&:to_h)
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "recording/collector"
|
|
4
|
+
require_relative "recording/reporter"
|
|
5
|
+
|
|
6
|
+
module Foundries
|
|
7
|
+
module Recording
|
|
8
|
+
DEFAULT_OUTPUT_PATH = "tmp/foundries/recording.json"
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_writer :enabled, :output_path
|
|
12
|
+
|
|
13
|
+
def enabled?
|
|
14
|
+
return @enabled unless @enabled.nil?
|
|
15
|
+
ENV["FOUNDRIES_RECORD"] == "1"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def collector
|
|
19
|
+
@collector ||= Collector.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def output_path
|
|
23
|
+
@output_path || DEFAULT_OUTPUT_PATH
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def worker_output_path
|
|
27
|
+
worker_id = ENV["TEST_ENV_NUMBER"]
|
|
28
|
+
return output_path if worker_id.nil?
|
|
29
|
+
|
|
30
|
+
suffix = worker_id.empty? ? Process.pid.to_s : worker_id
|
|
31
|
+
base = output_path
|
|
32
|
+
ext = File.extname(base)
|
|
33
|
+
"#{base.chomp(ext)}-#{suffix}#{ext}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
@collector = nil
|
|
38
|
+
@enabled = nil
|
|
39
|
+
@output_path = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def report!
|
|
43
|
+
total_tests = collector.results.size
|
|
44
|
+
reporter = Reporter.new(
|
|
45
|
+
results: collector.results,
|
|
46
|
+
total_creates: collector.total_creates,
|
|
47
|
+
total_tests: total_tests
|
|
48
|
+
)
|
|
49
|
+
path = worker_output_path
|
|
50
|
+
reporter.write_json(path)
|
|
51
|
+
$stdout.puts reporter.summary(json_path: path)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if Foundries::Recording.enabled? && defined?(RSpec)
|
|
58
|
+
RSpec.configure do |config|
|
|
59
|
+
config.before(:each) do |example|
|
|
60
|
+
Foundries::Recording.collector.start_test(example.full_description)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
config.after(:each) do |example|
|
|
64
|
+
Foundries::Recording.collector.stop_test(example.full_description)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
config.after(:suite) do
|
|
68
|
+
Foundries::Recording.report!
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -16,6 +16,10 @@ module Foundries
|
|
|
16
16
|
@connection.select_value("SELECT NOT EXISTS (SELECT 1 FROM #{quoted(table_name)})")
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def count(table_name)
|
|
20
|
+
@connection.select_value("SELECT COUNT(*) FROM #{quoted(table_name)}")
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def capture(table_name, io)
|
|
20
24
|
raw = @connection.raw_connection
|
|
21
25
|
raw.copy_data("COPY #{quoted(table_name)} TO STDOUT") do
|
|
@@ -13,7 +13,11 @@ module Foundries
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def empty?(table_name)
|
|
16
|
-
|
|
16
|
+
count(table_name) == 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def count(table_name)
|
|
20
|
+
@connection.select_value("SELECT COUNT(*) FROM #{quoted(table_name)}")
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def capture(table_name, io)
|
|
@@ -25,11 +25,34 @@ module Foundries
|
|
|
25
25
|
|
|
26
26
|
# Record which tables are empty before the preset block runs.
|
|
27
27
|
# Only these tables will be captured after the block completes.
|
|
28
|
+
#
|
|
29
|
+
# The already-populated tables are counted too, so #capture can tell
|
|
30
|
+
# whether the preset wrote into one of them. A preset that only updates
|
|
31
|
+
# existing rows leaves the count unchanged and slips past this check;
|
|
32
|
+
# presets insert, so the count is a good enough signal.
|
|
28
33
|
def record_empty_tables
|
|
29
|
-
@capturable_tables =
|
|
34
|
+
@capturable_tables = []
|
|
35
|
+
@preexisting_counts = {}
|
|
36
|
+
|
|
37
|
+
@adapter.table_names.each do |table|
|
|
38
|
+
if @adapter.empty?(table)
|
|
39
|
+
@capturable_tables << table
|
|
40
|
+
else
|
|
41
|
+
@preexisting_counts[table] = @adapter.count(table)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
30
44
|
end
|
|
31
45
|
|
|
32
46
|
def capture
|
|
47
|
+
spoiled = spoiled_tables
|
|
48
|
+
unless spoiled.empty?
|
|
49
|
+
warn "[Foundries] Not caching preset :#{@preset_name} — it wrote to " \
|
|
50
|
+
"#{spoiled.join(", ")}, which already held rows when it ran. " \
|
|
51
|
+
"Only tables the preset fills from empty can be snapshotted, so " \
|
|
52
|
+
"caching this would restore an incomplete tree."
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
33
56
|
tables = @capturable_tables || @adapter.table_names
|
|
34
57
|
|
|
35
58
|
tmp_dir = Pathname.new("#{cache_dir}.#{$$}.tmp")
|
|
@@ -64,6 +87,17 @@ module Foundries
|
|
|
64
87
|
|
|
65
88
|
private
|
|
66
89
|
|
|
90
|
+
# Pre-populated tables whose row count moved while the preset ran. Their
|
|
91
|
+
# new rows can't be told apart from the ones that were already there, so
|
|
92
|
+
# the snapshot would silently omit them.
|
|
93
|
+
def spoiled_tables
|
|
94
|
+
return [] unless @preexisting_counts
|
|
95
|
+
|
|
96
|
+
@preexisting_counts.filter_map do |table, count|
|
|
97
|
+
table unless @adapter.count(table) == count
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
67
101
|
def cache_dir
|
|
68
102
|
@cache_dir ||= Pathname.new(@storage_path).join(@preset_name)
|
|
69
103
|
end
|
data/lib/foundries/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: foundries
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- John Dowd
|
|
@@ -54,6 +54,12 @@ files:
|
|
|
54
54
|
- lib/foundries.rb
|
|
55
55
|
- lib/foundries/base.rb
|
|
56
56
|
- lib/foundries/blueprint.rb
|
|
57
|
+
- lib/foundries/recording.rb
|
|
58
|
+
- lib/foundries/recording/aggregator.rb
|
|
59
|
+
- lib/foundries/recording/collector.rb
|
|
60
|
+
- lib/foundries/recording/node.rb
|
|
61
|
+
- lib/foundries/recording/rake_task.rb
|
|
62
|
+
- lib/foundries/recording/reporter.rb
|
|
57
63
|
- lib/foundries/similarity.rb
|
|
58
64
|
- lib/foundries/similarity/comparator.rb
|
|
59
65
|
- lib/foundries/similarity/recorder.rb
|
|
@@ -87,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
87
93
|
- !ruby/object:Gem::Version
|
|
88
94
|
version: '0'
|
|
89
95
|
requirements: []
|
|
90
|
-
rubygems_version: 4.0.
|
|
96
|
+
rubygems_version: 4.0.16
|
|
91
97
|
specification_version: 4
|
|
92
98
|
summary: Declarative trees of related data using factory_bot
|
|
93
99
|
test_files: []
|