crspec 0.1.2 → 0.1.3
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 +86 -0
- data/README.md +1 -1
- data/lib/crspec/cli.rb +86 -5
- data/lib/crspec/configuration.rb +62 -9
- data/lib/crspec/dsl.rb +66 -2
- data/lib/crspec/example.rb +60 -6
- data/lib/crspec/example_group.rb +363 -63
- data/lib/crspec/execution_context.rb +10 -6
- data/lib/crspec/expectations.rb +62 -2
- data/lib/crspec/file_fixtures.rb +18 -0
- data/lib/crspec/formatters/progress_formatter.rb +37 -14
- data/lib/crspec/matchers.rb +642 -0
- data/lib/crspec/mock/argument_matchers.rb +157 -0
- data/lib/crspec/mock/double.rb +246 -13
- data/lib/crspec/mock/interceptor.rb +32 -9
- data/lib/crspec/mock/space.rb +14 -0
- data/lib/crspec/process_runner.rb +332 -0
- data/lib/crspec/rails/assets_shim.rb +33 -0
- data/lib/crspec/rails/database_isolation.rb +179 -8
- data/lib/crspec/rails/parallel.rb +29 -29
- data/lib/crspec/rails/request_helpers.rb +70 -14
- data/lib/crspec/rails/system_server.rb +7 -0
- data/lib/crspec/rails/warden_shim.rb +18 -0
- data/lib/crspec/runner.rb +143 -29
- data/lib/crspec/shared_examples.rb +70 -0
- data/lib/crspec/status_persistence.rb +46 -0
- data/lib/crspec/transpiler/cli.rb +106 -15
- data/lib/crspec/transpiler/rewriter.rb +179 -25
- data/lib/crspec/version.rb +1 -1
- data/lib/crspec.rb +6 -0
- data/lib/rspec/core.rb +2 -0
- data/lib/rspec/expectations.rb +2 -0
- data/lib/rspec/mocks.rb +2 -0
- data/lib/rspec/rails.rb +2 -0
- data/lib/rspec.rb +2 -0
- metadata +27 -1
|
@@ -18,7 +18,25 @@ module Crspec
|
|
|
18
18
|
include ActiveSupport::Testing::FileFixtures
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# Lightweight response wrapper that mimics the small subset of the
|
|
22
|
+
# Rails test response API that the specs rely on (status, body,
|
|
23
|
+
# headers, media_type, parsed_body, etc.).
|
|
24
|
+
ResponseStruct = Struct.new(:status, :body, :headers) do
|
|
25
|
+
def media_type
|
|
26
|
+
content_type_header = headers["Content-Type"] || headers["content-type"]
|
|
27
|
+
return nil unless content_type_header
|
|
28
|
+
|
|
29
|
+
# Strip any charset or parameters, e.g. "application/json; charset=utf-8"
|
|
30
|
+
content_type_header.split(";").first&.strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parsed_body
|
|
34
|
+
return nil unless body
|
|
35
|
+
JSON.parse(body)
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
22
40
|
|
|
23
41
|
def response
|
|
24
42
|
execution_context[:last_response]
|
|
@@ -32,34 +50,72 @@ module Crspec
|
|
|
32
50
|
end
|
|
33
51
|
|
|
34
52
|
def process_request(method, path, params = {}, headers = {})
|
|
35
|
-
|
|
53
|
+
# Emulate Rails integration test semantics:
|
|
54
|
+
# - For GET/DELETE, params are typically carried in the query string
|
|
55
|
+
# - For POST/PUT/PATCH with JSON, params are in the request body
|
|
56
|
+
# - `format: :json` should affect the requested path / Accept header
|
|
57
|
+
|
|
58
|
+
params = params.dup if params.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
requested_format = nil
|
|
61
|
+
if params.is_a?(Hash) && params.key?(:format)
|
|
62
|
+
requested_format = params.delete(:format)&.to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
path_with_format = path.dup
|
|
66
|
+
if requested_format && !path_with_format.end_with?(".#{requested_format}")
|
|
67
|
+
path_with_format = "#{path_with_format}.#{requested_format}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
query_string = nil
|
|
71
|
+
body_string = ""
|
|
72
|
+
|
|
73
|
+
if params.is_a?(Hash) && !params.empty?
|
|
74
|
+
if %i[get delete].include?(method.to_sym)
|
|
75
|
+
# Attach params as query string for idempotent verbs
|
|
76
|
+
query_string = URI.encode_www_form(params)
|
|
77
|
+
else
|
|
78
|
+
# Default to JSON body for non-GET verbs when params are present
|
|
79
|
+
body_string = params.to_json
|
|
80
|
+
headers = headers.merge("CONTENT_TYPE" => "application/json") unless headers["CONTENT_TYPE"]
|
|
81
|
+
end
|
|
82
|
+
elsif params.is_a?(String)
|
|
83
|
+
body_string = params
|
|
84
|
+
end
|
|
85
|
+
|
|
36
86
|
env = {
|
|
37
87
|
"REQUEST_METHOD" => method.to_s.upcase,
|
|
38
|
-
"PATH_INFO" =>
|
|
88
|
+
"PATH_INFO" => path_with_format,
|
|
89
|
+
"QUERY_STRING" => query_string.to_s,
|
|
39
90
|
"rack.input" => StringIO.new(body_string),
|
|
40
|
-
"CONTENT_TYPE" => headers["CONTENT_TYPE"]
|
|
41
|
-
"HTTP_ACCEPT" => headers["HTTP_ACCEPT"] || "application/json",
|
|
91
|
+
"CONTENT_TYPE" => headers["CONTENT_TYPE"],
|
|
92
|
+
"HTTP_ACCEPT" => headers["HTTP_ACCEPT"] || (requested_format == "json" ? "application/json" : nil),
|
|
42
93
|
"CONTENT_LENGTH" => body_string.bytesize.to_s
|
|
43
|
-
}
|
|
94
|
+
}.compact
|
|
44
95
|
|
|
45
96
|
headers.each do |k, v|
|
|
46
97
|
env["HTTP_#{k.to_s.upcase.tr('-', '_')}"] = v unless k.to_s.start_with?("HTTP_")
|
|
47
98
|
end
|
|
48
99
|
|
|
49
100
|
status = 200
|
|
50
|
-
response_headers = { "Content-Type" => "
|
|
101
|
+
response_headers = { "Content-Type" => "text/plain" }
|
|
51
102
|
response_body = ""
|
|
52
103
|
|
|
53
104
|
begin
|
|
54
105
|
if defined?(::Rails) && ::Rails.application && ::Rails.application.routes.routes.any?
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
response_body = body_obj.respond_to?(:body) ? body_obj.body : body_obj.join
|
|
58
|
-
rescue StandardError
|
|
59
|
-
response_body = params.to_json
|
|
60
|
-
end
|
|
106
|
+
status, response_headers, body_obj = ::Rails.application.call(env)
|
|
107
|
+
response_body = body_obj.respond_to?(:body) ? body_obj.body : body_obj.join
|
|
61
108
|
else
|
|
62
|
-
|
|
109
|
+
# No Rails app mounted; behave like a very small echo server.
|
|
110
|
+
if (env["HTTP_ACCEPT"] || "").to_s.include?("json") ||
|
|
111
|
+
(env["CONTENT_TYPE"] || "").to_s.include?("json") ||
|
|
112
|
+
requested_format == "json"
|
|
113
|
+
response_headers["Content-Type"] = "application/json"
|
|
114
|
+
response_body = params.is_a?(String) ? params : params.to_json
|
|
115
|
+
else
|
|
116
|
+
response_headers["Content-Type"] = "text/plain"
|
|
117
|
+
response_body = params.to_s
|
|
118
|
+
end
|
|
63
119
|
end
|
|
64
120
|
ensure
|
|
65
121
|
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
|
|
@@ -5,7 +5,14 @@ require "etc"
|
|
|
5
5
|
module Crspec
|
|
6
6
|
module Rails
|
|
7
7
|
class SystemServer
|
|
8
|
+
# The effective port is offset by TEST_ENV_NUMBER so concurrent
|
|
9
|
+
# --processes children never collide on the same listener.
|
|
10
|
+
def self.effective_port(port)
|
|
11
|
+
port + ENV.fetch("TEST_ENV_NUMBER", "").to_i
|
|
12
|
+
end
|
|
13
|
+
|
|
8
14
|
def self.start_concurrent_server!(app = nil, port = 9887)
|
|
15
|
+
port = effective_port(port)
|
|
9
16
|
@server_mutex ||= Mutex.new
|
|
10
17
|
@server_mutex.synchronize do
|
|
11
18
|
return if @running
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
begin
|
|
5
|
+
if defined?(Rails) && Rails.env.test? && defined?(Warden::Manager)
|
|
6
|
+
# Ensure there is always a failure app in test so that requests which
|
|
7
|
+
# hit Warden::Manager#call_failure_app do not raise "No Failure App
|
|
8
|
+
# provided" but instead respond with a simple 401/403 style response.
|
|
9
|
+
Rails.application.config.middleware.insert_after Rack::Head, Warden::Manager do |config|
|
|
10
|
+
config.failure_app ||= lambda do |_env|
|
|
11
|
+
[401, { "Content-Type" => "text/plain" }, ["Unauthorized"]]
|
|
12
|
+
end
|
|
13
|
+
end unless Rails.application.config.middleware.any? { |m| m.klass == Warden::Manager }
|
|
14
|
+
end
|
|
15
|
+
rescue StandardError
|
|
16
|
+
# If the app doesn't use Warden/Devise in a conventional fashion we
|
|
17
|
+
# silently skip installing the shim.
|
|
18
|
+
end
|
data/lib/crspec/runner.rb
CHANGED
|
@@ -1,51 +1,89 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "etc"
|
|
4
|
+
begin
|
|
5
|
+
require "async"
|
|
6
|
+
require "async/semaphore"
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# Fiber tier unavailable; threads-only execution.
|
|
9
|
+
end
|
|
4
10
|
require_relative "execution_context"
|
|
5
11
|
require_relative "formatters/progress_formatter"
|
|
12
|
+
require_relative "status_persistence"
|
|
6
13
|
|
|
7
14
|
module Crspec
|
|
8
15
|
class Runner
|
|
9
|
-
attr_reader :concurrency, :passed_examples, :failed_examples, :total_duration, :formatter
|
|
16
|
+
attr_reader :concurrency, :fibers, :passed_examples, :failed_examples, :pending_examples, :total_duration, :formatter, :seed
|
|
10
17
|
|
|
11
|
-
def initialize(concurrency: Etc.nprocessors, formatter: nil
|
|
18
|
+
def initialize(concurrency: Etc.nprocessors, fibers: 1, formatter: nil, fail_fast: false,
|
|
19
|
+
seed: nil, only_failures: false, persistence_path: nil,
|
|
20
|
+
tags: nil, locations: nil)
|
|
12
21
|
@concurrency = concurrency
|
|
22
|
+
@fibers = fibers && fibers > 1 && defined?(Async) ? fibers : 1
|
|
13
23
|
@formatter = formatter || Formatters::ProgressFormatter.new
|
|
14
24
|
@queue = Thread::Queue.new
|
|
15
25
|
@passed_examples = []
|
|
16
26
|
@failed_examples = []
|
|
17
|
-
@
|
|
27
|
+
@pending_examples = []
|
|
18
28
|
@total_duration = 0
|
|
29
|
+
@fail_fast = fail_fast == true ? 1 : fail_fast
|
|
30
|
+
@failure_count = 0
|
|
31
|
+
@failure_mutex = Mutex.new
|
|
32
|
+
@seed = seed
|
|
33
|
+
@only_failures = only_failures
|
|
34
|
+
@tags = tags
|
|
35
|
+
@locations = locations
|
|
36
|
+
@persistence = StatusPersistence.new(
|
|
37
|
+
persistence_path || Crspec.configuration.example_status_persistence_file_path
|
|
38
|
+
)
|
|
19
39
|
end
|
|
20
40
|
|
|
21
41
|
def run(example_groups)
|
|
42
|
+
Rails::AssetsShim.install! if defined?(Rails::AssetsShim)
|
|
22
43
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
44
|
@formatter.start
|
|
24
|
-
example_groups.each
|
|
45
|
+
example_groups.each(&:finalize!)
|
|
46
|
+
|
|
47
|
+
examples = []
|
|
48
|
+
example_groups.each { |group| collect_examples(group, examples) }
|
|
49
|
+
examples = filter_examples(examples)
|
|
50
|
+
previous_statuses = @persistence.load
|
|
51
|
+
examples = order_examples(examples, previous_statuses)
|
|
52
|
+
examples.each { |example| @queue.push(example) }
|
|
53
|
+
@queue.close
|
|
25
54
|
|
|
26
55
|
workers = Array.new(@concurrency) do |worker_idx|
|
|
27
56
|
Thread.new do
|
|
28
57
|
worker_number = worker_idx + 1
|
|
58
|
+
results = { passed: [], failed: [], pending: [] }
|
|
59
|
+
Thread.current[:crspec_results] = results
|
|
29
60
|
Rails::Parallel.setup_worker(worker_number) if defined?(Rails::Parallel) && Rails::Parallel.enabled?
|
|
30
61
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
nil
|
|
62
|
+
if @fibers > 1
|
|
63
|
+
run_worker_fibers(results)
|
|
64
|
+
else
|
|
65
|
+
while (example = @queue.pop)
|
|
66
|
+
execute_example(example, results)
|
|
37
67
|
end
|
|
38
|
-
break unless example
|
|
39
|
-
|
|
40
|
-
execute_example(example)
|
|
41
68
|
end
|
|
42
69
|
ensure
|
|
70
|
+
Rails::DatabaseIsolation.finish_worker if defined?(Rails::DatabaseIsolation)
|
|
43
71
|
Rails::Parallel.teardown_worker(worker_number) if defined?(Rails::Parallel) && Rails::Parallel.enabled?
|
|
44
72
|
end
|
|
45
73
|
end
|
|
46
74
|
|
|
47
|
-
workers.each
|
|
75
|
+
workers.each do |worker|
|
|
76
|
+
worker.join
|
|
77
|
+
results = worker[:crspec_results]
|
|
78
|
+
next unless results
|
|
79
|
+
|
|
80
|
+
@passed_examples.concat(results[:passed])
|
|
81
|
+
@failed_examples.concat(results[:failed])
|
|
82
|
+
@pending_examples.concat(results[:pending])
|
|
83
|
+
end
|
|
48
84
|
@total_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
85
|
+
Mock::Interceptor.cleanup! if defined?(Mock::Interceptor)
|
|
86
|
+
@persistence.save(@passed_examples + @failed_examples)
|
|
49
87
|
@formatter.finish
|
|
50
88
|
self
|
|
51
89
|
end
|
|
@@ -56,12 +94,85 @@ module Crspec
|
|
|
56
94
|
|
|
57
95
|
private
|
|
58
96
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
97
|
+
# Each worker thread runs up to @fibers concurrent example-fibers via
|
|
98
|
+
# the Async reactor. IO-bound examples overlap within a thread. Fiber
|
|
99
|
+
# Storage isolation (ExecutionContext, Mock::Space, DB leases) makes
|
|
100
|
+
# this safe: each example fiber gets its own context, mock space and
|
|
101
|
+
# leased DB connection.
|
|
102
|
+
def run_worker_fibers(results)
|
|
103
|
+
Sync do |top|
|
|
104
|
+
semaphore = Async::Semaphore.new(@fibers, parent: top)
|
|
105
|
+
while (example = @queue.pop)
|
|
106
|
+
semaphore.async do
|
|
107
|
+
execute_example(example, results)
|
|
108
|
+
ensure
|
|
109
|
+
Rails::DatabaseIsolation.finish_worker if defined?(Rails::DatabaseIsolation)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def collect_examples(group, acc)
|
|
116
|
+
acc.concat(group.examples)
|
|
117
|
+
group.children.each { |child| collect_examples(child, acc) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Focus (fit/fdescribe/:focus) narrows to focused examples when any
|
|
121
|
+
# exist; --tag filters on metadata; line-number filters (spec.rb:42)
|
|
122
|
+
# select the example whose definition is closest above the line.
|
|
123
|
+
def filter_examples(examples)
|
|
124
|
+
examples = examples.select(&:focused?) if examples.any?(&:focused?)
|
|
125
|
+
|
|
126
|
+
if @tags && !@tags.empty?
|
|
127
|
+
examples = examples.select do |ex|
|
|
128
|
+
meta = ex.example_group.ancestor_metadata.merge(ex.metadata || {})
|
|
129
|
+
@tags.all? do |key, value|
|
|
130
|
+
value == true ? !!meta[key] : meta[key] == value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if @locations && !@locations.empty?
|
|
136
|
+
examples = examples.select do |ex|
|
|
137
|
+
@locations.any? do |file, line|
|
|
138
|
+
next false unless ex.file_path && File.expand_path(ex.file_path) == File.expand_path(file)
|
|
139
|
+
next true if line.nil?
|
|
140
|
+
|
|
141
|
+
candidates = examples.select { |e| e.file_path && File.expand_path(e.file_path) == File.expand_path(file) }
|
|
142
|
+
best = candidates.select { |e| e.line_number && e.line_number <= line }.max_by(&:line_number)
|
|
143
|
+
best ? ex.equal?(best) : false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
examples
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Slowest-first (using persisted timings) shrinks the critical path of
|
|
152
|
+
# the parallel run; unknown examples go first (assumed potentially slow).
|
|
153
|
+
# --seed applies random ordering before the timing sort is skipped.
|
|
154
|
+
def order_examples(examples, previous_statuses)
|
|
155
|
+
if @only_failures
|
|
156
|
+
examples = examples.select do |ex|
|
|
157
|
+
entry = previous_statuses[ex.persistence_key]
|
|
158
|
+
entry.nil? || entry["status"] == "failed"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if @seed
|
|
163
|
+
rng = Random.new(@seed)
|
|
164
|
+
examples.shuffle(random: rng)
|
|
165
|
+
elsif previous_statuses.empty?
|
|
166
|
+
examples
|
|
167
|
+
else
|
|
168
|
+
examples.sort_by do |ex|
|
|
169
|
+
entry = previous_statuses[ex.persistence_key]
|
|
170
|
+
-(entry ? entry["run_time"].to_f : Float::INFINITY)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
62
173
|
end
|
|
63
174
|
|
|
64
|
-
def execute_example(example)
|
|
175
|
+
def execute_example(example, results)
|
|
65
176
|
ExecutionContext.isolate(example.id, example.metadata) do |_context|
|
|
66
177
|
example.execute!
|
|
67
178
|
ensure
|
|
@@ -76,20 +187,23 @@ module Crspec
|
|
|
76
187
|
end
|
|
77
188
|
end
|
|
78
189
|
|
|
79
|
-
record_result(example)
|
|
190
|
+
record_result(example, results)
|
|
80
191
|
end
|
|
81
192
|
end
|
|
82
193
|
|
|
83
|
-
def record_result(example)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
194
|
+
def record_result(example, results)
|
|
195
|
+
if example.status == :passed
|
|
196
|
+
results[:passed] << example
|
|
197
|
+
@formatter.example_passed(example)
|
|
198
|
+
elsif example.status == :pending
|
|
199
|
+
results[:pending] << example
|
|
200
|
+
@formatter.example_pending(example)
|
|
201
|
+
else
|
|
202
|
+
results[:failed] << example
|
|
203
|
+
@formatter.example_failed(example)
|
|
204
|
+
if @fail_fast
|
|
205
|
+
failures = @failure_mutex.synchronize { @failure_count += 1 }
|
|
206
|
+
@queue.clear if failures >= @fail_fast
|
|
93
207
|
end
|
|
94
208
|
end
|
|
95
209
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crspec
|
|
4
|
+
# Registry of shared example groups / contexts. Registration happens at
|
|
5
|
+
# load time (single-threaded); lookups at group-definition time. Blocks
|
|
6
|
+
# are re-evaluated in the including group, so no state is shared between
|
|
7
|
+
# examples — parallel-safe by construction.
|
|
8
|
+
module SharedRegistry
|
|
9
|
+
class << self
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register(name, block)
|
|
15
|
+
registry[name.to_s] = block
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(name)
|
|
19
|
+
registry[name.to_s] or raise ArgumentError,
|
|
20
|
+
"Could not find shared examples or context #{name.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reset!
|
|
24
|
+
@registry = {}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.shared_examples(name, &block)
|
|
30
|
+
SharedRegistry.register(name, block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.shared_context(name, &block)
|
|
34
|
+
SharedRegistry.register(name, block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
alias shared_examples_for shared_examples
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module SharedDSL
|
|
42
|
+
def shared_examples(name, &block)
|
|
43
|
+
Crspec::SharedRegistry.register(name, block)
|
|
44
|
+
end
|
|
45
|
+
alias shared_examples_for shared_examples
|
|
46
|
+
alias shared_context shared_examples
|
|
47
|
+
|
|
48
|
+
def include_context(name, *args)
|
|
49
|
+
block = Crspec::SharedRegistry.fetch(name)
|
|
50
|
+
if args.empty?
|
|
51
|
+
instance_eval(&block)
|
|
52
|
+
else
|
|
53
|
+
instance_exec(*args, &block)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
alias include_examples include_context
|
|
57
|
+
|
|
58
|
+
def it_behaves_like(name, *args)
|
|
59
|
+
block = Crspec::SharedRegistry.fetch(name)
|
|
60
|
+
child = describe("behaves like #{name}") {}
|
|
61
|
+
if args.empty?
|
|
62
|
+
child.instance_eval(&block)
|
|
63
|
+
else
|
|
64
|
+
child.instance_exec(*args, &block)
|
|
65
|
+
end
|
|
66
|
+
child
|
|
67
|
+
end
|
|
68
|
+
alias it_should_behave_like it_behaves_like
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Crspec
|
|
7
|
+
# Persists per-example status and timing between runs. Backs
|
|
8
|
+
# slowest-first scheduling and --only-failures. Keyed by the example's
|
|
9
|
+
# stable identity (group description chain + example description).
|
|
10
|
+
class StatusPersistence
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load
|
|
16
|
+
return {} unless @path && File.exist?(@path)
|
|
17
|
+
|
|
18
|
+
JSON.parse(File.read(@path))
|
|
19
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
20
|
+
{}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save(examples)
|
|
24
|
+
return unless @path
|
|
25
|
+
|
|
26
|
+
previous = load
|
|
27
|
+
examples.each do |example|
|
|
28
|
+
next if example.status == :pending
|
|
29
|
+
|
|
30
|
+
previous[example.persistence_key] = {
|
|
31
|
+
"status" => example.status.to_s,
|
|
32
|
+
"run_time" => example.execution_time.round(6)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
dir = File.dirname(@path)
|
|
37
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
38
|
+
tmp_path = File.join(dir, ".crspec-status-#{Process.pid}-#{rand(1_000_000)}")
|
|
39
|
+
File.write(tmp_path, JSON.pretty_generate(previous))
|
|
40
|
+
File.rename(tmp_path, @path)
|
|
41
|
+
rescue SystemCallError
|
|
42
|
+
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -15,6 +15,8 @@ module Crspec
|
|
|
15
15
|
@args = args
|
|
16
16
|
@mode = :help
|
|
17
17
|
@paths = []
|
|
18
|
+
@diff = false
|
|
19
|
+
@backup = true
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def run
|
|
@@ -24,8 +26,11 @@ module Crspec
|
|
|
24
26
|
analyze_paths(@paths)
|
|
25
27
|
when :write
|
|
26
28
|
write_paths(@paths)
|
|
29
|
+
when :report
|
|
30
|
+
report_paths(@paths)
|
|
27
31
|
else
|
|
28
|
-
|
|
32
|
+
puts "Usage: crspec-transpile [--analyze|--write|--report] [--diff] [--no-backup] <files or directories>"
|
|
33
|
+
true
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
|
|
@@ -43,6 +48,18 @@ module Crspec
|
|
|
43
48
|
@mode = :write
|
|
44
49
|
end
|
|
45
50
|
|
|
51
|
+
opts.on("--report", "Print a per-file migration report with safety scores") do
|
|
52
|
+
@mode = :report
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
opts.on("--diff", "Dry run: print unified diffs instead of writing") do
|
|
56
|
+
@diff = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
opts.on("--no-backup", "Do not write .bak backups when transpiling") do
|
|
60
|
+
@backup = false
|
|
61
|
+
end
|
|
62
|
+
|
|
46
63
|
opts.on("-h", "--help", "Show help") do
|
|
47
64
|
@mode = :help
|
|
48
65
|
end
|
|
@@ -52,37 +69,46 @@ module Crspec
|
|
|
52
69
|
@paths = leftovers.empty? ? ["spec"] : leftovers
|
|
53
70
|
end
|
|
54
71
|
|
|
72
|
+
# Only *_spec.rb plus known helper files are candidates. The old
|
|
73
|
+
# fallback glob (**/*.rb when no spec files matched) could rewrite an
|
|
74
|
+
# entire application tree; it is gone.
|
|
55
75
|
def find_files(paths)
|
|
56
76
|
files = []
|
|
57
77
|
paths.each do |p|
|
|
58
78
|
if File.directory?(p)
|
|
59
79
|
files.concat(Dir.glob(File.join(p, "**", "*_spec.rb")))
|
|
60
|
-
files.concat(Dir.glob(File.join(p, "**", "
|
|
80
|
+
files.concat(Dir.glob(File.join(p, "**", "{spec,rails}_helper.rb")))
|
|
61
81
|
elsif File.file?(p)
|
|
62
82
|
files << p
|
|
83
|
+
else
|
|
84
|
+
warn "warning: path not found: #{p}"
|
|
63
85
|
end
|
|
64
86
|
end
|
|
65
87
|
files.uniq
|
|
66
88
|
end
|
|
67
89
|
|
|
90
|
+
def log(msg = nil)
|
|
91
|
+
msg = yield if block_given?
|
|
92
|
+
puts msg
|
|
93
|
+
end
|
|
94
|
+
|
|
68
95
|
def analyze_paths(paths)
|
|
69
96
|
files = find_files(paths)
|
|
70
|
-
|
|
97
|
+
total = 0
|
|
71
98
|
|
|
72
99
|
files.each do |file|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
rewriter.transpile
|
|
76
|
-
next if rewriter.warnings.empty?
|
|
100
|
+
rewriter = transpiled(file)
|
|
101
|
+
next if rewriter.lints.empty?
|
|
77
102
|
|
|
78
|
-
|
|
79
|
-
rewriter.
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
log { "File: #{file}" }
|
|
104
|
+
rewriter.lints.each do |lint|
|
|
105
|
+
log { format(" line %-4d %-8s [%s] %s", lint.line, lint.severity, lint.code, lint.message) }
|
|
106
|
+
total += 1
|
|
82
107
|
end
|
|
83
108
|
end
|
|
84
109
|
|
|
85
|
-
|
|
110
|
+
log { "Analysis complete. Total findings: #{total}" }
|
|
111
|
+
true
|
|
86
112
|
end
|
|
87
113
|
|
|
88
114
|
def write_paths(paths)
|
|
@@ -96,12 +122,77 @@ module Crspec
|
|
|
96
122
|
|
|
97
123
|
next unless new_code != content
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
if @diff
|
|
126
|
+
print_diff(file, content, new_code)
|
|
127
|
+
else
|
|
128
|
+
File.write("#{file}.bak", content) if @backup
|
|
129
|
+
File.write(file, new_code)
|
|
130
|
+
log { "Transpiled: #{file}#{@backup ? " (backup: #{file}.bak)" : ""}" }
|
|
131
|
+
end
|
|
101
132
|
count += 1
|
|
102
133
|
end
|
|
103
134
|
|
|
104
|
-
|
|
135
|
+
log { @diff ? "Dry run complete. #{count} file(s) would change." : "Transpilation complete. Updated #{count} file(s)." }
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Per-file concurrency-safety score plus the constructs that need
|
|
140
|
+
# manual work, enabling incremental migration.
|
|
141
|
+
def report_paths(paths)
|
|
142
|
+
files = find_files(paths)
|
|
143
|
+
rows = files.map do |file|
|
|
144
|
+
rewriter = transpiled(file)
|
|
145
|
+
manual = rewriter.lints.reject { |l| l.severity == :info }
|
|
146
|
+
[file, rewriter.safety_score, rewriter.lints.size, manual]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
log { format("%-60s %7s %9s", "File", "Score", "Findings") }
|
|
150
|
+
log { "-" * 78 }
|
|
151
|
+
rows.sort_by { |_, score, _, _| score }.each do |file, score, findings, manual|
|
|
152
|
+
log { format("%-60s %6d%% %9d", file, score, findings) }
|
|
153
|
+
manual.each do |lint|
|
|
154
|
+
log { format(" line %-4d %-8s [%s] %s", lint.line, lint.severity, lint.code, lint.message) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
avg = rows.empty? ? 100 : rows.sum { |_, s, _, _| s } / rows.size
|
|
159
|
+
manual_total = rows.sum { |_, _, _, m| m.size }
|
|
160
|
+
log { "-" * 78 }
|
|
161
|
+
log { "#{rows.size} file(s), average safety score #{avg}%, #{manual_total} construct(s) need manual work." }
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def transpiled(file)
|
|
166
|
+
rewriter = Rewriter.new(File.read(file))
|
|
167
|
+
rewriter.transpile
|
|
168
|
+
rewriter
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def print_diff(file, old_code, new_code)
|
|
172
|
+
log { "--- #{file}" }
|
|
173
|
+
log { "+++ #{file} (transpiled)" }
|
|
174
|
+
old_lines = old_code.lines
|
|
175
|
+
new_lines = new_code.lines
|
|
176
|
+
max = [old_lines.size, new_lines.size].max
|
|
177
|
+
offset = 0
|
|
178
|
+
old_lines.each_with_index do |line, i|
|
|
179
|
+
new_line = new_lines[i + offset]
|
|
180
|
+
next if line == new_line
|
|
181
|
+
|
|
182
|
+
if new_lines[i + offset + 1] == line
|
|
183
|
+
log { "+#{new_lines[i + offset]}" }
|
|
184
|
+
offset += 1
|
|
185
|
+
redo_line = new_lines[i + offset]
|
|
186
|
+
log { " #{redo_line}" } if redo_line == line
|
|
187
|
+
else
|
|
188
|
+
log { "-#{line}" }
|
|
189
|
+
log { "+#{new_line}" } if new_line
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
(old_lines.size + offset...new_lines.size).each do |i|
|
|
193
|
+
log { "+#{new_lines[i]}" }
|
|
194
|
+
end
|
|
195
|
+
max.zero? && nil
|
|
105
196
|
end
|
|
106
197
|
end
|
|
107
198
|
end
|