rspec-sprint 0.1.0 → 0.4.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 +4 -4
- data/lib/rspec_sprint/cli.rb +27 -1
- data/lib/rspec_sprint/comparator.rb +43 -0
- data/lib/rspec_sprint/diagnosis.rb +18 -2
- data/lib/rspec_sprint/doctor.rb +5 -2
- data/lib/rspec_sprint/snapshot_store.rb +115 -0
- data/lib/rspec_sprint/version.rb +1 -1
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fff025e27cecb118a9aca49be7611c62cf40da2dddd17d827320883c6e232acd
|
|
4
|
+
data.tar.gz: 99e64c381ca443afea9d34bb909b9fddc9d857aadb32beec52d677040885f553
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c73e4b56bc7e6a57ad70c8fec7719ed24502ffc121b99fee8d8058c13996296f68d69cf218c1d75e1da4b0078a251dda9e61769aad86354eef14fb6d8c8e0246
|
|
7
|
+
data.tar.gz: 7d44089f6ec20e24796892c8f395b4be545ee1ce87d4b327555b3620548afad35bb5a047a8ceec07ac391887b8ed8758d2f5b4ae1b4940389bd23590d03cb9f1
|
data/lib/rspec_sprint/cli.rb
CHANGED
|
@@ -21,9 +21,35 @@ module RspecSprint
|
|
|
21
21
|
Requires test-prof in your bundle and `require "test_prof"` in your spec
|
|
22
22
|
helper for factory diagnosis (Rule①); without it, the other rules still run.
|
|
23
23
|
DESC
|
|
24
|
+
option :no_snapshot, type: :boolean, default: false,
|
|
25
|
+
desc: "Skip saving a snapshot to .rspec-sprint/"
|
|
26
|
+
option :compare_last, type: :boolean, default: false,
|
|
27
|
+
desc: "Show delta vs most recent previous snapshot"
|
|
24
28
|
def doctor(*rspec_args)
|
|
25
29
|
result = Collector.new(rspec_args: rspec_args).run
|
|
26
|
-
puts Doctor.diagnose(result
|
|
30
|
+
puts Doctor.diagnose(result, no_snapshot: options[:no_snapshot],
|
|
31
|
+
compare_last: options[:compare_last])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc "compare [-- RSPEC_ARGS]", "Run your suite and save results for long-term tracking"
|
|
35
|
+
long_desc <<~DESC
|
|
36
|
+
Runs your RSpec suite once and (with --save-baseline) saves the result as a
|
|
37
|
+
named baseline in .rspec-sprint/baseline.json for long-term comparison.
|
|
38
|
+
|
|
39
|
+
Unlike the rolling snapshots used by `doctor --compare-last`, the baseline
|
|
40
|
+
is a permanent reference point that is never auto-pruned.
|
|
41
|
+
|
|
42
|
+
bundle exec rspec-sprint compare --save-baseline
|
|
43
|
+
bundle exec rspec-sprint compare --save-baseline -- spec/models
|
|
44
|
+
DESC
|
|
45
|
+
option :save_baseline, type: :boolean, default: false,
|
|
46
|
+
desc: "Save results as .rspec-sprint/baseline.json"
|
|
47
|
+
option :no_snapshot, type: :boolean, default: false,
|
|
48
|
+
desc: "Skip saving a rolling snapshot to .rspec-sprint/"
|
|
49
|
+
def compare(*rspec_args)
|
|
50
|
+
result = Collector.new(rspec_args: rspec_args).run
|
|
51
|
+
puts Doctor.diagnose(result, no_snapshot: options[:no_snapshot],
|
|
52
|
+
save_baseline: options[:save_baseline])
|
|
27
53
|
end
|
|
28
54
|
|
|
29
55
|
default_command :doctor
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module RspecSprint
|
|
6
|
+
# Computes and formats the delta between a previous snapshot (raw JSON hash)
|
|
7
|
+
# and the current Normalizer::Snapshot.
|
|
8
|
+
module Comparator
|
|
9
|
+
NO_BASELINE_MSG = "前回のスナップショットがありません。まず `bundle exec rspec-sprint doctor` を実行してベースラインを作成してください。\n" \
|
|
10
|
+
"(No prior snapshot. Run doctor once to create a baseline.)"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# previous_data: Hash parsed from a saved snapshot JSON (may be nil)
|
|
15
|
+
# current: Normalizer::Snapshot
|
|
16
|
+
# Returns a formatted string to append to the doctor report.
|
|
17
|
+
def format_delta(previous_data, current)
|
|
18
|
+
return NO_BASELINE_MSG if previous_data.nil?
|
|
19
|
+
|
|
20
|
+
prev_duration = previous_data[:suite_duration].to_f
|
|
21
|
+
curr_duration = current.suite_duration.to_f
|
|
22
|
+
duration_delta = curr_duration - prev_duration
|
|
23
|
+
duration_pct = prev_duration > 0 ? (duration_delta / prev_duration * 100).round(1) : 0.0
|
|
24
|
+
|
|
25
|
+
prev_factory_pct = prev_duration > 0 ? (previous_data[:factory_time].to_f / prev_duration * 100).round(1) : 0.0
|
|
26
|
+
curr_factory_pct = (current.factory_time_ratio * 100).round(1)
|
|
27
|
+
|
|
28
|
+
prev_ts = format_timestamp(previous_data[:created_at])
|
|
29
|
+
|
|
30
|
+
sign = duration_delta >= 0 ? "+" : ""
|
|
31
|
+
"前回比 #{sign}#{duration_delta.round(1)}s (#{sign}#{duration_pct}%) " \
|
|
32
|
+
"factory time #{prev_factory_pct}% → #{curr_factory_pct}% [#{prev_ts}]"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def format_timestamp(iso8601)
|
|
36
|
+
return "unknown" if iso8601.nil?
|
|
37
|
+
|
|
38
|
+
Time.parse(iso8601).strftime("%Y-%m-%d %H:%M")
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
iso8601.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "normalizer"
|
|
4
4
|
require_relative "ranker"
|
|
5
5
|
require_relative "formatter"
|
|
6
|
+
require_relative "snapshot_store"
|
|
7
|
+
require_relative "comparator"
|
|
6
8
|
|
|
7
9
|
module RspecSprint
|
|
8
10
|
# Pure pipeline: profiler JSON files -> normalized snapshot -> ranked findings
|
|
@@ -11,9 +13,23 @@ module RspecSprint
|
|
|
11
13
|
module Diagnosis
|
|
12
14
|
module_function
|
|
13
15
|
|
|
14
|
-
def from_files(rspec_json:, factory_prof_json: nil)
|
|
16
|
+
def from_files(rspec_json:, factory_prof_json: nil, save_snapshot: false, compare_last: false, save_baseline: false)
|
|
17
|
+
# Load previous BEFORE saving so "last" refers to the prior run, not this one.
|
|
18
|
+
previous = compare_last ? SnapshotStore.load_last : nil
|
|
19
|
+
|
|
15
20
|
snapshot = Normalizer.new(rspec_json: rspec_json, factory_prof_json: factory_prof_json).call
|
|
16
|
-
|
|
21
|
+
SnapshotStore.save(snapshot) if save_snapshot
|
|
22
|
+
SnapshotStore.save_baseline(snapshot) if save_baseline
|
|
23
|
+
|
|
24
|
+
sections = [Formatter.format(Ranker.call(snapshot))]
|
|
25
|
+
sections << Comparator.format_delta(previous, snapshot) if compare_last
|
|
26
|
+
sections << baseline_saved_msg if save_baseline
|
|
27
|
+
sections.join("\n\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def baseline_saved_msg
|
|
31
|
+
"ベースライン保存済み: #{SnapshotStore::BASELINE_PATH}\n" \
|
|
32
|
+
"(Baseline saved — run `rspec-sprint compare` to compare future runs)"
|
|
17
33
|
end
|
|
18
34
|
end
|
|
19
35
|
end
|
data/lib/rspec_sprint/doctor.rb
CHANGED
|
@@ -11,7 +11,7 @@ module RspecSprint
|
|
|
11
11
|
module Doctor
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
-
def diagnose(result)
|
|
14
|
+
def diagnose(result, no_snapshot: false, compare_last: false, save_baseline: false)
|
|
15
15
|
return diagnosis_impossible(result) unless result.rspec_json?
|
|
16
16
|
|
|
17
17
|
sections = []
|
|
@@ -19,7 +19,10 @@ module RspecSprint
|
|
|
19
19
|
sections << factory_prof_missing_hint unless result.factory_prof?
|
|
20
20
|
sections << Diagnosis.from_files(
|
|
21
21
|
rspec_json: result.rspec_json_path,
|
|
22
|
-
factory_prof_json: result.factory_prof? ? result.factory_prof_path : nil
|
|
22
|
+
factory_prof_json: result.factory_prof? ? result.factory_prof_path : nil,
|
|
23
|
+
save_snapshot: !no_snapshot,
|
|
24
|
+
compare_last: compare_last,
|
|
25
|
+
save_baseline: save_baseline
|
|
23
26
|
)
|
|
24
27
|
sections.join("\n\n")
|
|
25
28
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module RspecSprint
|
|
8
|
+
# Persists normalized snapshots to .rspec-sprint/snapshots/ for future
|
|
9
|
+
# comparison (the seed for --compare-last / Test Performance Ledger).
|
|
10
|
+
# Failure-safe: disk errors warn and skip; doctor never crashes because of us.
|
|
11
|
+
class SnapshotStore
|
|
12
|
+
DEFAULT_DIR = ".rspec-sprint/snapshots"
|
|
13
|
+
BASELINE_PATH = ".rspec-sprint/baseline.json"
|
|
14
|
+
MAX_SNAPSHOTS = 3
|
|
15
|
+
|
|
16
|
+
def self.save(snapshot, dir: DEFAULT_DIR)
|
|
17
|
+
new(dir: dir).save(snapshot)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load_last(dir: DEFAULT_DIR)
|
|
21
|
+
new(dir: dir).load_last
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.save_baseline(snapshot, path: BASELINE_PATH)
|
|
25
|
+
new.save_baseline(snapshot, path: path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.load_baseline(path: BASELINE_PATH)
|
|
29
|
+
new.load_baseline(path: path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(dir: DEFAULT_DIR)
|
|
33
|
+
@dir = dir
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def save(snapshot)
|
|
37
|
+
FileUtils.mkdir_p(@dir)
|
|
38
|
+
path = File.join(@dir, "#{timestamp}.json")
|
|
39
|
+
File.write(path, serialize(snapshot))
|
|
40
|
+
prune
|
|
41
|
+
rescue Errno::EACCES, Errno::ENOSPC => e
|
|
42
|
+
warn "rspec-sprint: snapshot not saved (#{e.class.name.split("::").last}: #{e.message})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list
|
|
46
|
+
Dir.glob(File.join(@dir, "*.json")).sort
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the most recent snapshot as a symbol-keyed Hash, or nil if none.
|
|
50
|
+
def load_last
|
|
51
|
+
files = list
|
|
52
|
+
return nil if files.empty?
|
|
53
|
+
|
|
54
|
+
JSON.parse(File.read(files.last), symbolize_names: true)
|
|
55
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Saves snapshot as the named baseline (single file, never pruned).
|
|
60
|
+
def save_baseline(snapshot, path: BASELINE_PATH)
|
|
61
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
62
|
+
File.write(path, serialize(snapshot))
|
|
63
|
+
rescue Errno::EACCES, Errno::ENOSPC => e
|
|
64
|
+
warn "rspec-sprint: baseline not saved (#{e.class.name.split("::").last}: #{e.message})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the saved baseline as a symbol-keyed Hash, or nil if none.
|
|
68
|
+
def load_baseline(path: BASELINE_PATH)
|
|
69
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
70
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def serialize(snapshot)
|
|
77
|
+
JSON.pretty_generate({
|
|
78
|
+
schema_version: 1,
|
|
79
|
+
created_at: Time.now.utc.iso8601,
|
|
80
|
+
suite_duration: snapshot.suite_duration,
|
|
81
|
+
factory_time: snapshot.factory_time,
|
|
82
|
+
example_count: snapshot.example_count,
|
|
83
|
+
failure_count: snapshot.failure_count,
|
|
84
|
+
factories: (snapshot.factories || []).map do |f|
|
|
85
|
+
{ name: f.name, total_count: f.total_count,
|
|
86
|
+
top_level_count: f.top_level_count, total_time: f.total_time }
|
|
87
|
+
end,
|
|
88
|
+
examples: (snapshot.examples || []).map do |e|
|
|
89
|
+
{ full_description: e.full_description,
|
|
90
|
+
file_path: sanitize_path(e.file_path),
|
|
91
|
+
run_time: e.run_time, status: e.status }
|
|
92
|
+
end
|
|
93
|
+
})
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def sanitize_path(path)
|
|
97
|
+
path.to_s.sub(%r{\A\./}, "")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def prune
|
|
101
|
+
files = list
|
|
102
|
+
return if files.length <= MAX_SNAPSHOTS
|
|
103
|
+
|
|
104
|
+
files.first(files.length - MAX_SNAPSHOTS).each do |f|
|
|
105
|
+
File.delete(f)
|
|
106
|
+
rescue Errno::EACCES
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def timestamp
|
|
112
|
+
Time.now.strftime("%Y%m%d%H%M%S%L")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/rspec_sprint/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspec-sprint
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- yasu551
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-06-18 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: test-prof
|
|
@@ -42,6 +43,7 @@ description: |
|
|
|
42
43
|
JSON formatter), interprets the output against opinionated heuristics, and asserts
|
|
43
44
|
the top few things to fix — diagnosis plus concrete prescription, not just numbers.
|
|
44
45
|
It wraps existing profilers; it does not reimplement them.
|
|
46
|
+
email:
|
|
45
47
|
executables:
|
|
46
48
|
- rspec-sprint
|
|
47
49
|
extensions: []
|
|
@@ -53,6 +55,7 @@ files:
|
|
|
53
55
|
- lib/rspec_sprint.rb
|
|
54
56
|
- lib/rspec_sprint/cli.rb
|
|
55
57
|
- lib/rspec_sprint/collector.rb
|
|
58
|
+
- lib/rspec_sprint/comparator.rb
|
|
56
59
|
- lib/rspec_sprint/diagnosis.rb
|
|
57
60
|
- lib/rspec_sprint/doctor.rb
|
|
58
61
|
- lib/rspec_sprint/finding.rb
|
|
@@ -60,6 +63,7 @@ files:
|
|
|
60
63
|
- lib/rspec_sprint/normalizer.rb
|
|
61
64
|
- lib/rspec_sprint/ranker.rb
|
|
62
65
|
- lib/rspec_sprint/rules.rb
|
|
66
|
+
- lib/rspec_sprint/snapshot_store.rb
|
|
63
67
|
- lib/rspec_sprint/version.rb
|
|
64
68
|
homepage: https://github.com/yasu551/rspec-sprint
|
|
65
69
|
licenses:
|
|
@@ -67,6 +71,7 @@ licenses:
|
|
|
67
71
|
metadata:
|
|
68
72
|
source_code_uri: https://github.com/yasu551/rspec-sprint
|
|
69
73
|
changelog_uri: https://github.com/yasu551/rspec-sprint/blob/main/CHANGELOG.md
|
|
74
|
+
post_install_message:
|
|
70
75
|
rdoc_options: []
|
|
71
76
|
require_paths:
|
|
72
77
|
- lib
|
|
@@ -81,7 +86,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
81
86
|
- !ruby/object:Gem::Version
|
|
82
87
|
version: '0'
|
|
83
88
|
requirements: []
|
|
84
|
-
rubygems_version:
|
|
89
|
+
rubygems_version: 3.5.22
|
|
90
|
+
signing_key:
|
|
85
91
|
specification_version: 4
|
|
86
92
|
summary: Diagnose RSpec/Rails test-suite slowness and assert the top repo-specific
|
|
87
93
|
fixes.
|