crspec 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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +180 -0
- data/Rakefile +13 -0
- data/demo.rb +126 -0
- data/exe/crspec +9 -0
- data/exe/crspec-transpile +8 -0
- data/lib/crspec/cli.rb +82 -0
- data/lib/crspec/dsl.rb +39 -0
- data/lib/crspec/example.rb +53 -0
- data/lib/crspec/example_group.rb +154 -0
- data/lib/crspec/execution_context.rb +62 -0
- data/lib/crspec/expectations.rb +44 -0
- data/lib/crspec/formatters/progress_formatter.rb +50 -0
- data/lib/crspec/matchers.rb +203 -0
- data/lib/crspec/mock/double.rb +285 -0
- data/lib/crspec/mock/interceptor.rb +35 -0
- data/lib/crspec/mock/space.rb +81 -0
- data/lib/crspec/rails/database_isolation.rb +29 -0
- data/lib/crspec/rails/parallel.rb +98 -0
- data/lib/crspec/rails/system_server.rb +35 -0
- data/lib/crspec/runner.rb +97 -0
- data/lib/crspec/transpiler/cli.rb +108 -0
- data/lib/crspec/transpiler/rewriter.rb +70 -0
- data/lib/crspec/version.rb +5 -0
- data/lib/crspec.rb +23 -0
- data/samples/user_spec.rb +31 -0
- data/sig/crspec.rbs +4 -0
- data/test/crspec/execution_context_test.rb +61 -0
- data/test/crspec/mock_test.rb +55 -0
- data/test/crspec/rails_test.rb +62 -0
- data/test/crspec/runner_test.rb +68 -0
- data/test/crspec/transpiler_test.rb +41 -0
- data/test/test_helper.rb +6 -0
- metadata +96 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "interceptor"
|
|
4
|
+
|
|
5
|
+
module Crspec
|
|
6
|
+
module Mock
|
|
7
|
+
class Space
|
|
8
|
+
STORAGE_KEY = :crspec_mock_space
|
|
9
|
+
|
|
10
|
+
def self.current
|
|
11
|
+
Fiber[STORAGE_KEY] ||= new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.verify_and_reset!
|
|
15
|
+
space = Fiber[STORAGE_KEY]
|
|
16
|
+
return unless space
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
space.verify!
|
|
20
|
+
ensure
|
|
21
|
+
space.reset!
|
|
22
|
+
Fiber[STORAGE_KEY] = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@doubles = []
|
|
28
|
+
@stubs = Hash.new { |h, k| h[k] = {} }
|
|
29
|
+
@expectations = []
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def register_stub(target, method_name, implementation)
|
|
34
|
+
method_sym = method_name.to_sym
|
|
35
|
+
ensure_interceptor_prepended(target, method_sym)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@stubs[target.object_id][method_sym] = implementation
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch_stub(target, method_name)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@stubs[target.object_id][method_name.to_sym]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_expectation(expectation)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@expectations << expectation
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def register_double(double_obj)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@doubles << double_obj
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def verify!
|
|
60
|
+
@expectations.each(&:verify_expectations!)
|
|
61
|
+
@doubles.each(&:verify_expectations!)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reset!
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@stubs.clear
|
|
67
|
+
@doubles.clear
|
|
68
|
+
@expectations.clear
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def ensure_interceptor_prepended(target, method_name)
|
|
75
|
+
klass = target.singleton_class
|
|
76
|
+
klass.prepend(Interceptor) unless klass.include?(Interceptor)
|
|
77
|
+
Interceptor.add_intercept_method(method_name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crspec
|
|
4
|
+
module Rails
|
|
5
|
+
module DatabaseIsolation
|
|
6
|
+
def self.wrap_example(example)
|
|
7
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:lease_connection)
|
|
8
|
+
connection = ActiveRecord::Base.lease_connection
|
|
9
|
+
connection.begin_transaction(joinable: false)
|
|
10
|
+
savepoint_name = "ex_root"
|
|
11
|
+
connection.create_savepoint(savepoint_name) if connection.respond_to?(:create_savepoint)
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
example.execute!
|
|
15
|
+
ensure
|
|
16
|
+
if connection.respond_to?(:transaction_open?) && connection.transaction_open?
|
|
17
|
+
connection.rollback_transaction
|
|
18
|
+
end
|
|
19
|
+
if ActiveRecord::Base.respond_to?(:connection_handler) && ActiveRecord::Base.connection_handler.respond_to?(:release_connection)
|
|
20
|
+
ActiveRecord::Base.connection_handler.release_connection
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
else
|
|
24
|
+
example.execute!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Crspec
|
|
6
|
+
module Rails
|
|
7
|
+
module Parallel
|
|
8
|
+
class << self
|
|
9
|
+
attr_accessor :worker_count, :setup_blocks, :teardown_blocks, :enabled
|
|
10
|
+
|
|
11
|
+
def parallelize(workers: :number_of_processors, &block)
|
|
12
|
+
count = case workers
|
|
13
|
+
when :number_of_processors
|
|
14
|
+
Etc.nprocessors
|
|
15
|
+
when Integer
|
|
16
|
+
workers
|
|
17
|
+
else
|
|
18
|
+
Etc.nprocessors
|
|
19
|
+
end
|
|
20
|
+
@worker_count = count
|
|
21
|
+
@enabled = true
|
|
22
|
+
@setup_blocks ||= []
|
|
23
|
+
@teardown_blocks ||= []
|
|
24
|
+
|
|
25
|
+
return unless block_given?
|
|
26
|
+
|
|
27
|
+
instance_eval(&block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def enabled?
|
|
31
|
+
!!@enabled
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parallelize_setup(&block)
|
|
35
|
+
@setup_blocks ||= []
|
|
36
|
+
@setup_blocks << block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parallelize_teardown(&block)
|
|
40
|
+
@teardown_blocks ||= []
|
|
41
|
+
@teardown_blocks << block
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def setup_worker(worker_number)
|
|
45
|
+
env_num = worker_number == 1 ? "" : worker_number.to_s
|
|
46
|
+
ENV["TEST_ENV_NUMBER"] = env_num
|
|
47
|
+
ENV["PARALLEL_WORKERS"] = (@worker_count || Etc.nprocessors).to_s
|
|
48
|
+
|
|
49
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
50
|
+
setup_active_record_db(worker_number)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@setup_blocks&.each { |b| b.call(worker_number) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def teardown_worker(worker_number)
|
|
57
|
+
@teardown_blocks&.each { |b| b.call(worker_number) }
|
|
58
|
+
|
|
59
|
+
return unless defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset!
|
|
69
|
+
@enabled = false
|
|
70
|
+
@worker_count = nil
|
|
71
|
+
@setup_blocks = []
|
|
72
|
+
@teardown_blocks = []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def setup_active_record_db(worker_number)
|
|
78
|
+
return if worker_number == 1
|
|
79
|
+
|
|
80
|
+
config = begin
|
|
81
|
+
ActiveRecord::Base.connection_db_config
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
return unless config
|
|
86
|
+
|
|
87
|
+
db_name = "#{config.database}_#{worker_number}"
|
|
88
|
+
new_config = config.configuration_hash.merge(database: db_name)
|
|
89
|
+
begin
|
|
90
|
+
ActiveRecord::Base.establish_connection(new_config)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Crspec
|
|
6
|
+
module Rails
|
|
7
|
+
class SystemServer
|
|
8
|
+
def self.start_concurrent_server!(app = nil, port = 9887)
|
|
9
|
+
@server_mutex ||= Mutex.new
|
|
10
|
+
@server_mutex.synchronize do
|
|
11
|
+
return if @running
|
|
12
|
+
|
|
13
|
+
if defined?(Puma::Server)
|
|
14
|
+
rack_app = app || (defined?(::Rails) && ::Rails.respond_to?(:application) ? ::Rails.application : nil)
|
|
15
|
+
return unless rack_app
|
|
16
|
+
|
|
17
|
+
events = defined?(Puma::Events) ? Puma::Events.null : nil
|
|
18
|
+
server = Puma::Server.new(rack_app, events, { min_threads: 1, max_threads: Etc.nprocessors })
|
|
19
|
+
server.add_tcp_listener("127.0.0.1", port)
|
|
20
|
+
Thread.new { server.run }
|
|
21
|
+
end
|
|
22
|
+
@running = true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.running?
|
|
27
|
+
!!@running
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.reset!
|
|
31
|
+
@running = false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require_relative "execution_context"
|
|
5
|
+
require_relative "formatters/progress_formatter"
|
|
6
|
+
|
|
7
|
+
module Crspec
|
|
8
|
+
class Runner
|
|
9
|
+
attr_reader :concurrency, :passed_examples, :failed_examples, :total_duration, :formatter
|
|
10
|
+
|
|
11
|
+
def initialize(concurrency: Etc.nprocessors, formatter: nil)
|
|
12
|
+
@concurrency = concurrency
|
|
13
|
+
@formatter = formatter || Formatters::ProgressFormatter.new
|
|
14
|
+
@queue = Thread::Queue.new
|
|
15
|
+
@passed_examples = []
|
|
16
|
+
@failed_examples = []
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@total_duration = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(example_groups)
|
|
22
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
23
|
+
@formatter.start
|
|
24
|
+
example_groups.each { |group| enqueue_examples(group) }
|
|
25
|
+
|
|
26
|
+
workers = Array.new(@concurrency) do |worker_idx|
|
|
27
|
+
Thread.new do
|
|
28
|
+
worker_number = worker_idx + 1
|
|
29
|
+
Rails::Parallel.setup_worker(worker_number) if defined?(Rails::Parallel) && Rails::Parallel.enabled?
|
|
30
|
+
|
|
31
|
+
Fiber.set_scheduler(Async::Scheduler.new) if defined?(Async::Scheduler)
|
|
32
|
+
until @queue.empty?
|
|
33
|
+
example = begin
|
|
34
|
+
@queue.pop(true)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
break unless example
|
|
39
|
+
|
|
40
|
+
execute_example(example)
|
|
41
|
+
end
|
|
42
|
+
ensure
|
|
43
|
+
Rails::Parallel.teardown_worker(worker_number) if defined?(Rails::Parallel) && Rails::Parallel.enabled?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
workers.each(&:join)
|
|
48
|
+
@total_duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
49
|
+
@formatter.finish
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def success?
|
|
54
|
+
@failed_examples.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def enqueue_examples(group)
|
|
60
|
+
group.examples.each { |example| @queue.push(example) }
|
|
61
|
+
group.children.each { |child| enqueue_examples(child) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def execute_example(example)
|
|
65
|
+
ExecutionContext.isolate(example.id, example.metadata) do |_context|
|
|
66
|
+
example.execute!
|
|
67
|
+
ensure
|
|
68
|
+
if defined?(Mock::Space)
|
|
69
|
+
begin
|
|
70
|
+
Mock::Space.verify_and_reset!
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
if example.status == :passed
|
|
73
|
+
example.instance_variable_set(:@status, :failed)
|
|
74
|
+
example.instance_variable_set(:@error, e)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
record_result(example)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record_result(example)
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
if example.status == :passed
|
|
86
|
+
@passed_examples << example
|
|
87
|
+
@formatter.example_passed(example)
|
|
88
|
+
elsif example.status == :pending
|
|
89
|
+
@formatter.example_pending(example)
|
|
90
|
+
else
|
|
91
|
+
@failed_examples << example
|
|
92
|
+
@formatter.example_failed(example)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rewriter"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Crspec
|
|
8
|
+
module Transpiler
|
|
9
|
+
class CLI
|
|
10
|
+
def self.run(args)
|
|
11
|
+
new(args).run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(args)
|
|
15
|
+
@args = args
|
|
16
|
+
@mode = :help
|
|
17
|
+
@paths = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
parse_options
|
|
22
|
+
case @mode
|
|
23
|
+
when :analyze
|
|
24
|
+
analyze_paths(@paths)
|
|
25
|
+
when :write
|
|
26
|
+
write_paths(@paths)
|
|
27
|
+
else
|
|
28
|
+
puts "Usage: crspec-transpile [--analyze|--write] <files or directories>"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def parse_options
|
|
35
|
+
parser = OptionParser.new do |opts|
|
|
36
|
+
opts.banner = "Usage: crspec-transpile [options] <path>"
|
|
37
|
+
|
|
38
|
+
opts.on("-a", "--analyze", "Analyze files for thread-unsafe patterns") do
|
|
39
|
+
@mode = :analyze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on("-w", "--write", "Transpile RSpec files to Crspec in-place") do
|
|
43
|
+
@mode = :write
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
opts.on("-h", "--help", "Show help") do
|
|
47
|
+
@mode = :help
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
leftovers = parser.parse(@args)
|
|
52
|
+
@paths = leftovers.empty? ? ["spec"] : leftovers
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_files(paths)
|
|
56
|
+
files = []
|
|
57
|
+
paths.each do |p|
|
|
58
|
+
if File.directory?(p)
|
|
59
|
+
files.concat(Dir.glob(File.join(p, "**", "*_spec.rb")))
|
|
60
|
+
files.concat(Dir.glob(File.join(p, "**", "*.rb"))) if files.empty?
|
|
61
|
+
elsif File.file?(p)
|
|
62
|
+
files << p
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
files.uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def analyze_paths(paths)
|
|
69
|
+
files = find_files(paths)
|
|
70
|
+
total_warnings = 0
|
|
71
|
+
|
|
72
|
+
files.each do |file|
|
|
73
|
+
content = File.read(file)
|
|
74
|
+
rewriter = Rewriter.new(content)
|
|
75
|
+
rewriter.transpile
|
|
76
|
+
next if rewriter.warnings.empty?
|
|
77
|
+
|
|
78
|
+
puts "File: #{file}"
|
|
79
|
+
rewriter.warnings.each do |w|
|
|
80
|
+
puts " #{w}"
|
|
81
|
+
total_warnings += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
puts "Analysis complete. Total warnings found: #{total_warnings}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_paths(paths)
|
|
89
|
+
files = find_files(paths)
|
|
90
|
+
count = 0
|
|
91
|
+
|
|
92
|
+
files.each do |file|
|
|
93
|
+
content = File.read(file)
|
|
94
|
+
rewriter = Rewriter.new(content)
|
|
95
|
+
new_code = rewriter.transpile
|
|
96
|
+
|
|
97
|
+
next unless new_code != content
|
|
98
|
+
|
|
99
|
+
File.write(file, new_code)
|
|
100
|
+
puts "Transpiled: #{file}"
|
|
101
|
+
count += 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts "Transpilation complete. Updated #{count} file(s)."
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Crspec
|
|
6
|
+
module Transpiler
|
|
7
|
+
class Rewriter < Prism::Visitor
|
|
8
|
+
attr_reader :transformed_code, :warnings
|
|
9
|
+
|
|
10
|
+
def initialize(source_code)
|
|
11
|
+
super()
|
|
12
|
+
@source_code = source_code
|
|
13
|
+
@replacements = []
|
|
14
|
+
@warnings = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def transpile
|
|
18
|
+
result = Prism.parse(@source_code)
|
|
19
|
+
result.value.accept(self)
|
|
20
|
+
apply_replacements
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def visit_call_node(node)
|
|
24
|
+
# Convert RSpec.describe -> Crspec.describe
|
|
25
|
+
if node.receiver.is_a?(Prism::ConstantReadNode) &&
|
|
26
|
+
node.receiver.name == :RSpec &&
|
|
27
|
+
node.name == :describe
|
|
28
|
+
@replacements << {
|
|
29
|
+
start_offset: node.receiver.location.start_offset,
|
|
30
|
+
end_offset: node.receiver.location.end_offset,
|
|
31
|
+
text: "Crspec"
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Flag thread-unsafe before(:all) blocks
|
|
36
|
+
if node.name == :before && node.arguments
|
|
37
|
+
first_arg = node.arguments.arguments.first
|
|
38
|
+
if first_arg.is_a?(Prism::SymbolNode) && first_arg.value == "all"
|
|
39
|
+
@warnings << "Line #{node.location.start_line}: [Thread Safety Warning] before(:all) mutates global state across examples."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def visit_constant_read_node(node)
|
|
47
|
+
if node.name == :RSpec
|
|
48
|
+
@replacements << {
|
|
49
|
+
start_offset: node.location.start_offset,
|
|
50
|
+
end_offset: node.location.end_offset,
|
|
51
|
+
text: "Crspec"
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def apply_replacements
|
|
60
|
+
buffer = @source_code.dup
|
|
61
|
+
# Sort replacements backwards so offsets remain valid during splicing
|
|
62
|
+
sorted = @replacements.uniq { |r| [r[:start_offset], r[:end_offset]] }.sort_by { |r| -r[:start_offset] }
|
|
63
|
+
sorted.each do |r|
|
|
64
|
+
buffer[r[:start_offset]...r[:end_offset]] = r[:text]
|
|
65
|
+
end
|
|
66
|
+
@transformed_code = buffer
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/crspec.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "crspec/version"
|
|
4
|
+
require_relative "crspec/execution_context"
|
|
5
|
+
require_relative "crspec/matchers"
|
|
6
|
+
require_relative "crspec/expectations"
|
|
7
|
+
require_relative "crspec/example"
|
|
8
|
+
require_relative "crspec/example_group"
|
|
9
|
+
require_relative "crspec/mock/interceptor"
|
|
10
|
+
require_relative "crspec/mock/space"
|
|
11
|
+
require_relative "crspec/mock/double"
|
|
12
|
+
require_relative "crspec/formatters/progress_formatter"
|
|
13
|
+
require_relative "crspec/runner"
|
|
14
|
+
require_relative "crspec/dsl"
|
|
15
|
+
require_relative "crspec/rails/database_isolation"
|
|
16
|
+
require_relative "crspec/rails/system_server"
|
|
17
|
+
require_relative "crspec/rails/parallel"
|
|
18
|
+
require_relative "crspec/transpiler/rewriter"
|
|
19
|
+
require_relative "crspec/transpiler/cli"
|
|
20
|
+
|
|
21
|
+
module Crspec
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "crspec"
|
|
4
|
+
require "active_model"
|
|
5
|
+
|
|
6
|
+
class User
|
|
7
|
+
include ActiveModel::Model
|
|
8
|
+
|
|
9
|
+
attr_accessor :name, :email
|
|
10
|
+
|
|
11
|
+
validates :name, presence: true
|
|
12
|
+
validates :email, presence: true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Crspec.describe User, type: :model do
|
|
16
|
+
let(:valid_attributes) { { name: "Jane Doe", email: "jane@example.com" } }
|
|
17
|
+
subject(:user) { User.new(valid_attributes) }
|
|
18
|
+
|
|
19
|
+
it "validates primary attributes" do
|
|
20
|
+
expect(user.valid?).to be(true)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context "when email is omitted" do
|
|
24
|
+
let(:valid_attributes) { { name: "Jane Doe", email: nil } }
|
|
25
|
+
|
|
26
|
+
it "flags validation errors" do
|
|
27
|
+
expect(user.valid?).to be(false)
|
|
28
|
+
expect(user.errors[:email]).to include("can't be blank")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/sig/crspec.rbs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class ExecutionContextTest < Minitest::Test
|
|
6
|
+
def test_fiber_storage_isolation_and_inheritance
|
|
7
|
+
parent_ctx = nil
|
|
8
|
+
sub_fiber_ctx = nil
|
|
9
|
+
|
|
10
|
+
Crspec::ExecutionContext.isolate("ex-1", { type: :model }) do |ctx|
|
|
11
|
+
parent_ctx = ctx
|
|
12
|
+
assert_equal "ex-1", Crspec::ExecutionContext.current.example_id
|
|
13
|
+
|
|
14
|
+
# Sub-fiber created inside example inherits Fiber storage
|
|
15
|
+
Fiber.new do
|
|
16
|
+
sub_fiber_ctx = Crspec::ExecutionContext.current
|
|
17
|
+
end.resume
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
assert_equal parent_ctx.example_id, sub_fiber_ctx.example_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_per_fiber_lazy_memoization
|
|
24
|
+
Crspec::ExecutionContext.isolate("ex-2") do
|
|
25
|
+
ctx = Crspec::ExecutionContext.current
|
|
26
|
+
count = 0
|
|
27
|
+
val1 = ctx.fetch_memoized(:user) do
|
|
28
|
+
count += 1
|
|
29
|
+
"Jane"
|
|
30
|
+
end
|
|
31
|
+
val2 = ctx.fetch_memoized(:user) do
|
|
32
|
+
count += 1
|
|
33
|
+
"Jane"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
assert_equal "Jane", val1
|
|
37
|
+
assert_equal "Jane", val2
|
|
38
|
+
assert_equal 1, count
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_thread_safety_memoization
|
|
43
|
+
Crspec::ExecutionContext.isolate("ex-3") do
|
|
44
|
+
ctx = Crspec::ExecutionContext.current
|
|
45
|
+
counter = 0
|
|
46
|
+
threads = Array.new(10) do
|
|
47
|
+
Thread.new do
|
|
48
|
+
ctx.fetch_memoized(:shared_calc) do
|
|
49
|
+
sleep 0.01
|
|
50
|
+
counter += 1
|
|
51
|
+
42
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
results = threads.map(&:value)
|
|
57
|
+
assert_equal Array.new(10, 42), results
|
|
58
|
+
assert_equal 1, counter
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class MockTest < Minitest::Test
|
|
6
|
+
class StripeClient
|
|
7
|
+
def charge(amount)
|
|
8
|
+
"real_charge_#{amount}"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_fiber_isolated_stubs
|
|
13
|
+
client = StripeClient.new
|
|
14
|
+
fiber1_result = nil
|
|
15
|
+
fiber2_result = nil
|
|
16
|
+
|
|
17
|
+
f1 = Fiber.new do
|
|
18
|
+
Crspec::Mock::Space.current.register_stub(client, :charge, proc { "stub_fiber_1" })
|
|
19
|
+
fiber1_result = client.charge(100)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
f2 = Fiber.new do
|
|
23
|
+
Crspec::Mock::Space.current.register_stub(client, :charge, proc { "stub_fiber_2" })
|
|
24
|
+
fiber2_result = client.charge(200)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
f1.resume
|
|
28
|
+
f2.resume
|
|
29
|
+
|
|
30
|
+
assert_equal "stub_fiber_1", fiber1_result
|
|
31
|
+
assert_equal "stub_fiber_2", fiber2_result
|
|
32
|
+
assert_equal "real_charge_500", client.charge(500)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_double_and_expectation_dsl
|
|
36
|
+
Crspec::ExecutionContext.isolate("mock-test") do
|
|
37
|
+
stubs_mock = Crspec::Mock::Double.new("Stripe", charge: "ok")
|
|
38
|
+
assert_equal "ok", stubs_mock.charge(50)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_allow_receive_with_and_return
|
|
43
|
+
group = Crspec.describe "PaymentGateway" do
|
|
44
|
+
it "stubs method" do
|
|
45
|
+
client = StripeClient.new
|
|
46
|
+
allow(client).to receive(:charge).with(5000).and_return("succeeded")
|
|
47
|
+
expect(client.charge(5000)).to eq("succeeded")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
runner = Crspec::Runner.new(concurrency: 1)
|
|
52
|
+
runner.run([group])
|
|
53
|
+
assert runner.success?
|
|
54
|
+
end
|
|
55
|
+
end
|