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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crspec
4
+ VERSION = "0.1.0"
5
+ 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,4 @@
1
+ module Crspec
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -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