crspec 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 649f2c29f7f22f52d1291691f9c96c2c9718ab907c38e8ea27604fab5ec9ef8f
4
- data.tar.gz: cfaac3973542770dc81f08c921bb29b054ed64f3dae0913ca75b175d42438242
3
+ metadata.gz: e70183d3b574744a0de1061ba25f99d495bdbaa4d9940cb76f76d37124701328
4
+ data.tar.gz: 02f551a7420e923ae7fd5f6357ee6c55256600adac922f6adeab7b3e811cae65
5
5
  SHA512:
6
- metadata.gz: 85dc17da65b7d4bb59766cb7a00220f9009fec3b4e869969d3dde17fbafe7c8f63b190b083d2fbd243f3e6b3e02470f0e3076a9142b38b5d5e65ddb7ad595894
7
- data.tar.gz: '0257282967b6ed5ca718bd01cf7329a920f8fca18266d44a59e196453bbd8db2d0bdaf52e646b9553cf86eed375c2f00c7e33139435d36876ae43bb4ddbd689a'
6
+ metadata.gz: b9abb32f9cbb3411ce1de2ab806395e5629c42802f83b1cc6f346f164d87c6c487ceea69a64c6650f2a2f2267436eedf06c30b64ba4313c4b757f9bb803bf901
7
+ data.tar.gz: b29fba9882c81d8bae86250693e764ed49c17cba74d180e05536e522bc6bf5d9f0faf293340937b39d1a7bf88a4b65a9fe7e617d635749f144548ad7598fe49b
data/README.md CHANGED
@@ -8,9 +8,10 @@
8
8
 
9
9
  - **Fiber Storage Context Isolation**: Solves legacy thread-local (`Thread.current`) state leakage by routing all execution metadata and `let` memoization through inherited Fiber storage (`Fiber[:crspec_execution_context]`).
10
10
  - **Fiber-Aware Mocking (`crspec-mock`)**: Prevents mock registry corruption across parallel tests using prepended method interceptors bound to `Fiber[:crspec_mock_space]`.
11
- - **Rails Connection Leasing & Parallel Testing (`crspec-rails`)**: Leases database connections per example fiber (`ActiveRecord::Base.lease_connection`), handles savepoint transaction strategies (`SAVEPOINT ex_root`), and integrates with Rails parallel worker suites (`Crspec::Rails::Parallel`).
11
+ - **Rails Integration & ActiveSupport Reuse (`crspec-rails`)**: Direct integration with `ActiveSupport::Testing::Assertions`, `ActiveSupport::Testing::TimeHelpers`, and `ActiveSupport::Testing::FileFixtures`. Leases database connections per example fiber (`ActiveRecord::Base.lease_connection`), handles savepoint transaction strategies (`SAVEPOINT ex_root`), and integrates with Rails parallel worker suites (`Crspec::Rails::Parallel`).
12
+ - **Rails Request Specs**: Expressive HTTP request verb helpers (`get`, `post`, `put`, `patch`, `delete`), response status inspection (`response.status`), headers, and `json_response` parsing.
12
13
  - **Prism-Based AST Transpiler (`crspec-transpiler`)**: Automated migration tool using `ruby/prism` C-parser to convert existing RSpec codebases to Crspec syntax and flag thread-unsafe constructs like `before(:all)`.
13
- - **Concurrent Execution Kernel**: Multi-threaded worker pool integrated with non-blocking fiber schedulers (`Async::Scheduler`).
14
+ - **Concurrent Execution Kernel**: Multi-threaded worker pool integrated with real-time progress dot formatting and non-blocking fiber schedulers (`Async::Scheduler`).
14
15
 
15
16
  ---
16
17
 
@@ -30,12 +31,30 @@ mise exec -- bundle install
30
31
 
31
32
  ---
32
33
 
34
+ ## Spec Helpers & Generator
35
+
36
+ Initialize standard `spec_helper.rb` (and `rails_helper.rb` if run within a Rails project):
37
+
38
+ ```bash
39
+ mise exec -- crspec --init
40
+ ```
41
+
42
+ Spec files include helpers explicitly via standard `require` statements:
43
+
44
+ ```ruby
45
+ require "spec_helper"
46
+ # or
47
+ require "rails_helper"
48
+ ```
49
+
50
+ ---
51
+
33
52
  ## Writing Specs
34
53
 
35
54
  `crspec` provides a familiar RSpec-style DSL for structuring example groups and expectations:
36
55
 
37
56
  ```ruby
38
- require "crspec"
57
+ require "spec_helper"
39
58
 
40
59
  Crspec.describe User, type: :model do
41
60
  let(:valid_attributes) { { name: "Jane Doe", email: "jane@example.com" } }
@@ -45,6 +64,12 @@ Crspec.describe User, type: :model do
45
64
  expect(user.valid?).to be(true)
46
65
  end
47
66
 
67
+ it "changes user count when created" do
68
+ expect {
69
+ User.create!(valid_attributes)
70
+ }.to change(User, :count).by(1)
71
+ end
72
+
48
73
  context "when email is omitted" do
49
74
  let(:valid_attributes) { { name: "Jane Doe", email: nil } }
50
75
 
@@ -62,6 +87,7 @@ end
62
87
  - `include(*items)`
63
88
  - `raise_error(ExceptionClass, "message")`
64
89
  - `respond_to(*methods)`
90
+ - `change(receiver, :method).by(amount)` / `change { ... }.by(amount)` / `.from(val).to(val)`
65
91
 
66
92
  ### Advanced: Execution Context & Extensions
67
93
  Regular spec authors do not need to interact with `execution_context` at all—`crspec` automatically isolates `let` memoization, database connections, and stubs invisibly per test.
@@ -90,7 +116,7 @@ Crspec.describe PaymentGateway do
90
116
 
91
117
  it "stubs credit card processing concurrently" do
92
118
  allow(stripe_client).to receive(:create).with(amount: 5000).and_return(status: "succeeded")
93
-
119
+
94
120
  result = stripe_client.create(amount: 5000)
95
121
  expect(result[:status]).to eq("succeeded")
96
122
  end
@@ -99,15 +125,37 @@ end
99
125
 
100
126
  ---
101
127
 
102
- ## Rails Concurrency & Parallel Testing (`crspec-rails`)
128
+ ## Rails Concurrency & Request Specs (`crspec-rails`)
103
129
 
104
130
  ### Database Isolation & Connection Leasing
105
- Wrap examples in connection leasing and transaction savepoints:
131
+ `rails_helper.rb` automatically configures database-independent transaction isolation using your application's `config/database.yml`:
106
132
 
107
133
  ```ruby
108
- Crspec.describe User, type: :model do
109
- around do |example|
110
- Crspec::Rails::DatabaseIsolation.wrap_example(example)
134
+ Crspec.configure do |config|
135
+ config.use_transactional_fixtures = true
136
+ config.infer_spec_type_from_file_location!
137
+ config.include(Crspec::Rails::RequestHelpers)
138
+
139
+ config.around(:each) do |example|
140
+ if defined?(ActiveRecord::Base) && config.use_transactional_fixtures
141
+ Crspec::Rails::DatabaseIsolation.wrap_example(example)
142
+ else
143
+ example.execute!
144
+ end
145
+ end
146
+ end
147
+ ```
148
+
149
+ ### Request Specs
150
+ Perform full REST API controller testing with built-in request helpers:
151
+
152
+ ```ruby
153
+ Crspec.describe "Books API", type: :request do
154
+ it "creates a book via POST" do
155
+ post "/books", params: { name: "Refactoring", author: "Martin Fowler" }
156
+
157
+ expect(response.status).to eq(201)
158
+ expect(json_response[:name]).to eq("Refactoring")
111
159
  end
112
160
  end
113
161
  ```
@@ -118,7 +166,6 @@ Configure worker counts and setup/teardown hooks:
118
166
  ```ruby
119
167
  Crspec::Rails::Parallel.parallelize(workers: 4) do
120
168
  parallelize_setup do |worker_number|
121
- # Prepare worker database, seed data, etc.
122
169
  puts "Worker #{worker_number} starting (TEST_ENV_NUMBER=#{ENV['TEST_ENV_NUMBER']})"
123
170
  end
124
171
 
@@ -128,13 +175,6 @@ Crspec::Rails::Parallel.parallelize(workers: 4) do
128
175
  end
129
176
  ```
130
177
 
131
- ### Multi-Fiber Integration Server
132
- Boot an embedded multi-threaded/multi-fiber Puma server for system/browser specs:
133
-
134
- ```ruby
135
- Crspec::Rails::SystemServer.start_concurrent_server!(Rails.application, 9887)
136
- ```
137
-
138
178
  ---
139
179
 
140
180
  ## Migrating from RSpec (`crspec-transpile`)
@@ -151,6 +191,16 @@ mise exec -- crspec-transpile --write spec/
151
191
 
152
192
  ---
153
193
 
194
+ ## Sample Rails Bookstore App
195
+
196
+ Explore the working sample Rails application in [`samples/book_store`](file:///Users/tachyons/code/crspec/samples/book_store):
197
+
198
+ ```bash
199
+ mise exec -- ./exe/crspec samples/book_store/spec/
200
+ ```
201
+
202
+ ---
203
+
154
204
  ## Running Specs
155
205
 
156
206
  Execute your test suite concurrently using the `crspec` executable:
data/lib/crspec/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "optparse"
4
4
  require_relative "../crspec"
5
+ require_relative "generators/init"
5
6
 
6
7
  module Crspec
7
8
  class CLI
@@ -11,14 +12,30 @@ module Crspec
11
12
 
12
13
  def initialize(args)
13
14
  @args = args
14
- @concurrency = Etc.nprocessors
15
+ @concurrency = Crspec.configuration.concurrency
15
16
  @paths = []
17
+ @requires = []
18
+ @init_mode = false
16
19
  end
17
20
 
18
21
  def run
19
22
  parse_options
23
+
24
+ if @init_mode
25
+ created = Generators::Init.generate
26
+ if created.empty?
27
+ puts "No helper files were created (already exists or not a Rails project)."
28
+ else
29
+ created.each { |f| puts " create #{f}" }
30
+ end
31
+ return true
32
+ end
33
+
34
+ load_requires
20
35
  load_specs
21
- runner = Runner.new(concurrency: @concurrency)
36
+
37
+ concurrency = @concurrency || Crspec.configuration.concurrency
38
+ runner = Runner.new(concurrency: concurrency)
22
39
  groups = Crspec.world.example_groups
23
40
 
24
41
  if groups.empty?
@@ -26,7 +43,7 @@ module Crspec
26
43
  return true
27
44
  end
28
45
 
29
- puts "Running Crspec suite with concurrency #{@concurrency}..."
46
+ puts "Running Crspec suite with concurrency #{concurrency}..."
30
47
  runner.run(groups)
31
48
 
32
49
  puts "\nFinished in #{runner.total_duration.round(4)} seconds"
@@ -54,6 +71,14 @@ module Crspec
54
71
  @concurrency = n
55
72
  end
56
73
 
74
+ opts.on("-r", "--require PATH", String, "Require a file before running specs") do |path|
75
+ @requires << path
76
+ end
77
+
78
+ opts.on("--init", "Initialize spec_helper.rb (and rails_helper.rb if Rails project)") do
79
+ @init_mode = true
80
+ end
81
+
57
82
  opts.on("-h", "--help", "Show help") do
58
83
  puts opts
59
84
  exit 0
@@ -64,6 +89,12 @@ module Crspec
64
89
  @paths = ["spec"] if @paths.empty?
65
90
  end
66
91
 
92
+ def load_requires
93
+ @requires.each do |req|
94
+ require req
95
+ end
96
+ end
97
+
67
98
  def load_specs
68
99
  files = []
69
100
  @paths.each do |p|
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Crspec
6
+ class Configuration
7
+ attr_accessor :concurrency, :use_transactional_fixtures, :formatter
8
+ attr_reader :before_hooks, :after_hooks, :around_hooks, :included_modules
9
+
10
+ def initialize
11
+ @concurrency = Etc.nprocessors
12
+ @use_transactional_fixtures = true
13
+ @formatter = nil
14
+ @before_hooks = []
15
+ @after_hooks = []
16
+ @around_hooks = []
17
+ @included_modules = []
18
+ @infer_spec_type = false
19
+ end
20
+
21
+ def before(_scope = :each, &block)
22
+ @before_hooks << block
23
+ end
24
+
25
+ def after(_scope = :each, &block)
26
+ @after_hooks.unshift(block)
27
+ end
28
+
29
+ def around(_scope = :each, &block)
30
+ @around_hooks << block
31
+ end
32
+
33
+ def include(mod)
34
+ @included_modules << mod
35
+ end
36
+
37
+ def infer_spec_type_from_file_location!
38
+ @infer_spec_type = true
39
+ end
40
+
41
+ def infer_spec_type?
42
+ !!@infer_spec_type
43
+ end
44
+ end
45
+
46
+ class << self
47
+ def configuration
48
+ @configuration ||= Configuration.new
49
+ end
50
+
51
+ def configure
52
+ yield configuration if block_given?
53
+ end
54
+
55
+ def reset_configuration!
56
+ @configuration = Configuration.new
57
+ end
58
+ end
59
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "expectations"
4
4
  require_relative "example"
5
5
  require_relative "mock/double"
6
+ require_relative "configuration"
6
7
 
7
8
  module Crspec
8
9
  class ExampleGroup
@@ -21,6 +22,7 @@ module Crspec
21
22
  @after_hooks = []
22
23
  @let_blocks = {}
23
24
  @eager_lets = []
25
+ @included_modules = []
24
26
  end
25
27
 
26
28
  def self.define(description, metadata = {}, parent = nil, &block)
@@ -43,6 +45,10 @@ module Crspec
43
45
  end
44
46
  alias specify it
45
47
 
48
+ def include(mod)
49
+ @included_modules << mod
50
+ end
51
+
46
52
  def let(name, &block)
47
53
  @let_blocks[name.to_sym] = block
48
54
  end
@@ -71,6 +77,8 @@ module Crspec
71
77
 
72
78
  def ancestor_hooks(type)
73
79
  hooks = []
80
+ hooks.concat(Crspec.configuration.before_hooks) if type == :before
81
+
74
82
  curr = self
75
83
  ancestors = []
76
84
  while curr
@@ -82,6 +90,9 @@ module Crspec
82
90
  list = type == :before ? grp.before_hooks : grp.after_hooks
83
91
  hooks.concat(list)
84
92
  end
93
+
94
+ hooks.concat(Crspec.configuration.after_hooks) if type == :after
95
+
85
96
  hooks
86
97
  end
87
98
 
@@ -109,11 +120,31 @@ module Crspec
109
120
  eager
110
121
  end
111
122
 
123
+ def ancestor_included_modules
124
+ mods = []
125
+ curr = self
126
+ ancestors = []
127
+ while curr
128
+ ancestors.unshift(curr)
129
+ curr = curr.parent
130
+ end
131
+ ancestors.each do |grp|
132
+ mods.concat(grp.instance_variable_get(:@included_modules) || [])
133
+ end
134
+ mods
135
+ end
136
+
112
137
  def create_instance(example)
138
+ modules_to_include = (Crspec.configuration.included_modules + ancestor_included_modules).uniq
139
+
113
140
  klass = Class.new do
114
141
  include Expectations
115
142
  include Mock::DSL
116
143
 
144
+ modules_to_include.each do |mod|
145
+ include mod
146
+ end
147
+
117
148
  attr_reader :__crspec_example__
118
149
 
119
150
  def initialize(example)
@@ -37,7 +37,7 @@ module Crspec
37
37
  @monitor.synchronize do
38
38
  return @memoized_values[key] if @memoized_values.key?(key)
39
39
 
40
- @memoized_values[key] = block.call
40
+ @memoized_values[key] = yield
41
41
  end
42
42
  end
43
43
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Crspec
6
+ module Generators
7
+ class Init
8
+ SPEC_HELPER_CONTENT = <<~RUBY
9
+ # frozen_string_literal: true
10
+
11
+ require "crspec"
12
+ require "etc"
13
+
14
+ Crspec.configure do |config|
15
+ config.concurrency = Etc.nprocessors
16
+
17
+ config.before(:each) do
18
+ # Setup hooks executed before every spec example
19
+ end
20
+
21
+ config.after(:each) do
22
+ # Teardown hooks executed after every spec example
23
+ end
24
+ end
25
+ RUBY
26
+
27
+ RAILS_HELPER_CONTENT = <<~RUBY
28
+ # frozen_string_literal: true
29
+
30
+ require_relative "spec_helper"
31
+ ENV["RAILS_ENV"] ||= "test"
32
+ require File.expand_path("../config/environment", __dir__)
33
+
34
+ # Prevent database truncation if environment is production
35
+ abort("The Rails environment is running in production mode!") if defined?(Rails) && Rails.env.production?
36
+
37
+ require "crspec"
38
+
39
+ # Including support files for tests
40
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
41
+ Rails.root.glob("spec/support/**/*.rb").each { |f| require f }
42
+ end
43
+
44
+ # Checks for pending migrations and applies them before tests are run.
45
+ begin
46
+ ActiveRecord::Migration.maintain_test_schema! if defined?(ActiveRecord::Migration)
47
+ rescue ActiveRecord::PendingMigrationError => e
48
+ abort e.to_s
49
+ end
50
+
51
+ Crspec.configure do |config|
52
+ config.use_transactional_fixtures = true
53
+ config.infer_spec_type_from_file_location!
54
+ config.include(Crspec::Rails::RequestHelpers)
55
+
56
+ config.around(:each) do |example|
57
+ if defined?(ActiveRecord::Base) && config.use_transactional_fixtures
58
+ Crspec::Rails::DatabaseIsolation.wrap_example(example)
59
+ else
60
+ example.execute!
61
+ end
62
+ end
63
+ end
64
+ RUBY
65
+
66
+ def self.rails_project?(root_dir = Dir.pwd)
67
+ File.exist?(File.join(root_dir, "bin/rails")) ||
68
+ File.exist?(File.join(root_dir, "config/environment.rb")) ||
69
+ File.exist?(File.join(root_dir, "config/application.rb"))
70
+ end
71
+
72
+ def self.generate(target_dir = "spec", root_dir = Dir.pwd)
73
+ FileUtils.mkdir_p(target_dir)
74
+
75
+ spec_helper_path = File.join(target_dir, "spec_helper.rb")
76
+ rails_helper_path = File.join(target_dir, "rails_helper.rb")
77
+
78
+ created = []
79
+
80
+ unless File.exist?(spec_helper_path)
81
+ File.write(spec_helper_path, SPEC_HELPER_CONTENT)
82
+ created << spec_helper_path
83
+ end
84
+
85
+ if rails_project?(root_dir) && !File.exist?(rails_helper_path)
86
+ File.write(rails_helper_path, RAILS_HELPER_CONTENT)
87
+ created << rails_helper_path
88
+ end
89
+
90
+ created
91
+ end
92
+ end
93
+ end
94
+ end
@@ -168,6 +168,57 @@ module Crspec
168
168
  end
169
169
  end
170
170
 
171
+ class ChangeMatcher < BaseMatcher
172
+ def initialize(receiver = nil, message = nil, &block)
173
+ super()
174
+ @receiver = receiver
175
+ @message = message
176
+ @block = block || -> { receiver.send(message) }
177
+ @expected_by = nil
178
+ @expected_from = nil
179
+ @expected_to = nil
180
+ end
181
+
182
+ def by(amount)
183
+ @expected_by = amount
184
+ self
185
+ end
186
+
187
+ def from(val)
188
+ @expected_from = val
189
+ self
190
+ end
191
+
192
+ def to(val)
193
+ @expected_to = val
194
+ self
195
+ end
196
+
197
+ def matches?(proc_to_run)
198
+ @before_val = @block.call
199
+ proc_to_run.call
200
+ @after_val = @block.call
201
+
202
+ if @expected_by
203
+ (@after_val - @before_val) == @expected_by
204
+ elsif !@expected_from.nil? && !@expected_to.nil?
205
+ @before_val == @expected_from && @after_val == @expected_to
206
+ elsif !@expected_to.nil?
207
+ @after_val == @expected_to
208
+ else
209
+ @before_val != @after_val
210
+ end
211
+ end
212
+
213
+ def failure_message
214
+ if @expected_by
215
+ "Expected result to change by #{@expected_by}, but changed by #{@after_val - @before_val} (from #{@before_val.inspect} to #{@after_val.inspect})"
216
+ else
217
+ "Expected result to change, but remained #{@before_val.inspect}"
218
+ end
219
+ end
220
+ end
221
+
171
222
  def eq(expected)
172
223
  EqMatcher.new(expected)
173
224
  end
@@ -199,5 +250,9 @@ module Crspec
199
250
  def respond_to(*methods)
200
251
  RespondToMatcher.new(*methods)
201
252
  end
253
+
254
+ def change(receiver = nil, message = nil, &block)
255
+ ChangeMatcher.new(receiver, message, &block)
256
+ end
202
257
  end
203
258
  end
@@ -270,7 +270,14 @@ module Crspec
270
270
  end
271
271
 
272
272
  def instance_double(target_class, name = nil, stubs = {})
273
- Double.new(name || target_class.name, stubs)
273
+ double_name = if target_class.is_a?(String) || target_class.is_a?(Symbol)
274
+ target_class.to_s
275
+ elsif target_class.respond_to?(:name)
276
+ target_class.name
277
+ else
278
+ target_class.to_s
279
+ end
280
+ Double.new(name || double_name, stubs)
274
281
  end
275
282
 
276
283
  def allow(target)
@@ -2,22 +2,13 @@
2
2
 
3
3
  module Crspec
4
4
  module Rails
5
- module DatabaseIsolation
5
+ class DatabaseIsolation
6
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
7
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
8
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
9
+ conn.transaction(requires_new: true) do
10
+ example.execute!
11
+ raise ActiveRecord::Rollback
21
12
  end
22
13
  end
23
14
  else
@@ -22,7 +22,7 @@ module Crspec
22
22
  @setup_blocks ||= []
23
23
  @teardown_blocks ||= []
24
24
 
25
- return unless block_given?
25
+ return unless block
26
26
 
27
27
  instance_eval(&block)
28
28
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+
6
+ module Crspec
7
+ module Rails
8
+ module RequestHelpers
9
+ if defined?(ActiveSupport::Testing::Assertions)
10
+ include ActiveSupport::Testing::Assertions
11
+ end
12
+
13
+ if defined?(ActiveSupport::Testing::TimeHelpers)
14
+ include ActiveSupport::Testing::TimeHelpers
15
+ end
16
+
17
+ if defined?(ActiveSupport::Testing::FileFixtures)
18
+ include ActiveSupport::Testing::FileFixtures
19
+ end
20
+
21
+ ResponseStruct = Struct.new(:status, :body, :headers)
22
+
23
+ def response
24
+ execution_context[:last_response]
25
+ end
26
+
27
+ def json_response
28
+ return nil unless response&.body
29
+ JSON.parse(response.body, symbolize_names: true)
30
+ rescue JSON::ParserError
31
+ nil
32
+ end
33
+
34
+ def process_request(method, path, params = {}, headers = {})
35
+ body_string = params.is_a?(String) ? params : params.to_json
36
+ env = {
37
+ "REQUEST_METHOD" => method.to_s.upcase,
38
+ "PATH_INFO" => path,
39
+ "rack.input" => StringIO.new(body_string),
40
+ "CONTENT_TYPE" => headers["CONTENT_TYPE"] || "application/json",
41
+ "HTTP_ACCEPT" => headers["HTTP_ACCEPT"] || "application/json",
42
+ "CONTENT_LENGTH" => body_string.bytesize.to_s
43
+ }
44
+
45
+ headers.each do |k, v|
46
+ env["HTTP_#{k.to_s.upcase.tr('-', '_')}"] = v unless k.to_s.start_with?("HTTP_")
47
+ end
48
+
49
+ status = 200
50
+ response_headers = { "Content-Type" => "application/json" }
51
+ response_body = ""
52
+
53
+ begin
54
+ if defined?(::Rails) && ::Rails.application && ::Rails.application.routes.routes.any?
55
+ begin
56
+ status, response_headers, body_obj = ::Rails.application.call(env)
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
61
+ else
62
+ response_body = params.to_json
63
+ end
64
+ ensure
65
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_handler)
66
+ ActiveRecord::Base.connection_handler.clear_active_connections!
67
+ end
68
+ end
69
+
70
+ res = ResponseStruct.new(status, response_body, response_headers)
71
+ execution_context[:last_response] = res
72
+ res
73
+ end
74
+
75
+ def get(path, params: {}, headers: {})
76
+ process_request(:get, path, params, headers)
77
+ end
78
+
79
+ def post(path, params: {}, headers: {})
80
+ process_request(:post, path, params, headers)
81
+ end
82
+
83
+ def put(path, params: {}, headers: {})
84
+ process_request(:put, path, params, headers)
85
+ end
86
+
87
+ def patch(path, params: {}, headers: {})
88
+ process_request(:patch, path, params, headers)
89
+ end
90
+
91
+ def delete(path, params: {}, headers: {})
92
+ process_request(:delete, path, params, headers)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -25,7 +25,7 @@ module Crspec
25
25
  when :write
26
26
  write_paths(@paths)
27
27
  else
28
- puts "Usage: crspec-transpile [--analyze|--write] <files or directories>"
28
+ Rails.logger.debug "Usage: crspec-transpile [--analyze|--write] <files or directories>"
29
29
  end
30
30
  end
31
31
 
@@ -75,14 +75,14 @@ module Crspec
75
75
  rewriter.transpile
76
76
  next if rewriter.warnings.empty?
77
77
 
78
- puts "File: #{file}"
78
+ Rails.logger.debug { "File: #{file}" }
79
79
  rewriter.warnings.each do |w|
80
- puts " #{w}"
80
+ Rails.logger.debug { " #{w}" }
81
81
  total_warnings += 1
82
82
  end
83
83
  end
84
84
 
85
- puts "Analysis complete. Total warnings found: #{total_warnings}"
85
+ Rails.logger.debug { "Analysis complete. Total warnings found: #{total_warnings}" }
86
86
  end
87
87
 
88
88
  def write_paths(paths)
@@ -97,11 +97,11 @@ module Crspec
97
97
  next unless new_code != content
98
98
 
99
99
  File.write(file, new_code)
100
- puts "Transpiled: #{file}"
100
+ Rails.logger.debug { "Transpiled: #{file}" }
101
101
  count += 1
102
102
  end
103
103
 
104
- puts "Transpilation complete. Updated #{count} file(s)."
104
+ Rails.logger.debug { "Transpilation complete. Updated #{count} file(s)." }
105
105
  end
106
106
  end
107
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crspec
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/crspec.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "crspec/version"
4
+ require_relative "crspec/configuration"
4
5
  require_relative "crspec/execution_context"
5
6
  require_relative "crspec/matchers"
6
7
  require_relative "crspec/expectations"
@@ -15,8 +16,10 @@ require_relative "crspec/dsl"
15
16
  require_relative "crspec/rails/database_isolation"
16
17
  require_relative "crspec/rails/system_server"
17
18
  require_relative "crspec/rails/parallel"
19
+ require_relative "crspec/rails/request_helpers"
18
20
  require_relative "crspec/transpiler/rewriter"
19
21
  require_relative "crspec/transpiler/cli"
22
+ require_relative "crspec/generators/init"
20
23
 
21
24
  module Crspec
22
25
  class Error < StandardError; end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aboobacker MK
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-29 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -38,36 +38,31 @@ files:
38
38
  - LICENSE.txt
39
39
  - README.md
40
40
  - Rakefile
41
- - demo.rb
42
41
  - exe/crspec
43
42
  - exe/crspec-transpile
44
43
  - lib/crspec.rb
45
44
  - lib/crspec/cli.rb
45
+ - lib/crspec/configuration.rb
46
46
  - lib/crspec/dsl.rb
47
47
  - lib/crspec/example.rb
48
48
  - lib/crspec/example_group.rb
49
49
  - lib/crspec/execution_context.rb
50
50
  - lib/crspec/expectations.rb
51
51
  - lib/crspec/formatters/progress_formatter.rb
52
+ - lib/crspec/generators/init.rb
52
53
  - lib/crspec/matchers.rb
53
54
  - lib/crspec/mock/double.rb
54
55
  - lib/crspec/mock/interceptor.rb
55
56
  - lib/crspec/mock/space.rb
56
57
  - lib/crspec/rails/database_isolation.rb
57
58
  - lib/crspec/rails/parallel.rb
59
+ - lib/crspec/rails/request_helpers.rb
58
60
  - lib/crspec/rails/system_server.rb
59
61
  - lib/crspec/runner.rb
60
62
  - lib/crspec/transpiler/cli.rb
61
63
  - lib/crspec/transpiler/rewriter.rb
62
64
  - lib/crspec/version.rb
63
- - samples/user_spec.rb
64
65
  - sig/crspec.rbs
65
- - test/crspec/execution_context_test.rb
66
- - test/crspec/mock_test.rb
67
- - test/crspec/rails_test.rb
68
- - test/crspec/runner_test.rb
69
- - test/crspec/transpiler_test.rb
70
- - test/test_helper.rb
71
66
  homepage: https://github.com/crspec/crspec
72
67
  licenses:
73
68
  - MIT
data/demo.rb DELETED
@@ -1,126 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/inline"
4
-
5
- gemfile do
6
- source "https://rubygems.org"
7
- gem "crspec", path: __dir__
8
- gem "prism"
9
- end
10
-
11
- require "crspec"
12
-
13
- puts "================================================================="
14
- puts " Crspec Single-File Inline Demo & Feature Verification "
15
- puts "================================================================="
16
-
17
- # Reset global state for clean standalone run
18
- Crspec.reset!
19
-
20
- # Domain Models
21
- class User
22
- attr_accessor :name, :email
23
-
24
- def initialize(name:, email:)
25
- @name = name
26
- @email = email
27
- end
28
-
29
- def valid?
30
- return false if name.nil? || email.nil?
31
-
32
- email.include?("@")
33
- end
34
- end
35
-
36
- class PaymentProcessor
37
- def charge(amount)
38
- "real_charge_#{amount}"
39
- end
40
- end
41
-
42
- # 1. Core DSL, Context Isolation, Lazy Let Memoization, Matchers
43
- Crspec.describe User, type: :model do
44
- let(:valid_attributes) { { name: "Jane Doe", email: "jane@example.com" } }
45
- subject(:user) { User.new(**valid_attributes) }
46
-
47
- before do
48
- # Per-example setup hook
49
- end
50
-
51
- it "validates primary attributes concurrently" do
52
- expect(user.valid?).to be(true)
53
- expect(user.name).to eq("Jane Doe")
54
- expect(user.email).to include("example.com")
55
- end
56
-
57
- context "when email is invalid" do
58
- let(:valid_attributes) { { name: "Jane Doe", email: nil } }
59
-
60
- it "flags validation errors" do
61
- expect(user.valid?).to eq(false)
62
- end
63
- end
64
-
65
- context "when error is expected" do
66
- it "catches raised exceptions" do
67
- expect { raise ArgumentError, "Invalid user input" }.to raise_error(ArgumentError, "Invalid user input")
68
- end
69
- end
70
- end
71
-
72
- # 2. Fiber-Aware Mocking Engine (crspec-mock)
73
- Crspec.describe PaymentProcessor do
74
- let(:processor) { PaymentProcessor.new }
75
-
76
- it "supports fiber-isolated method stubs" do
77
- allow(processor).to receive(:charge).with(5000).and_return("succeeded")
78
- expect(processor.charge(5000)).to eq("succeeded")
79
- end
80
-
81
- it "supports test doubles and method expectations" do
82
- gateway_double = double("StripeGateway", process: "ok")
83
- expect(gateway_double.process).to eq("ok")
84
- end
85
- end
86
-
87
- # 3. Rails Parallel Worker Integration (crspec-rails)
88
- Crspec::Rails::Parallel.parallelize(workers: 4) do
89
- parallelize_setup do |worker_num|
90
- # Per-worker setup hook (e.g. database setup for worker_num)
91
- end
92
-
93
- parallelize_teardown do |worker_num|
94
- # Per-worker teardown hook
95
- end
96
- end
97
-
98
- # 4. Prism AST Transpiler Verification (crspec-transpiler)
99
- rspec_sample_code = <<~RUBY
100
- RSpec.describe "Legacy Suite" do
101
- it "runs" do
102
- expect(1).to eq(1)
103
- end
104
- end
105
- RUBY
106
-
107
- transpiled = Crspec::Transpiler::Rewriter.new(rspec_sample_code).transpile
108
- puts "[Prism Transpiler Check] RSpec -> Crspec: #{transpiled.include?("Crspec.describe") ? "PASSED" : "FAILED"}"
109
-
110
- # 5. Concurrent Multi-Threaded Execution Kernel
111
- puts "\n[Executing Specs Concurrently Across 4 Worker Threads]..."
112
- runner = Crspec::Runner.new(concurrency: 4)
113
- runner.run(Crspec.world.example_groups)
114
-
115
- puts "\n---------------- Execution Summary ----------------"
116
- puts "Total Duration : #{runner.total_duration.round(4)}s"
117
- puts "Passed Examples: #{runner.passed_examples.size}"
118
- puts "Failed Examples: #{runner.failed_examples.size}"
119
- puts "---------------------------------------------------"
120
-
121
- if runner.success?
122
- puts "\nSUCCESS: All specs executed concurrently across 4 worker threads and passed cleanly!"
123
- else
124
- puts "\nFAILURE: Some specs failed."
125
- exit 1
126
- end
data/samples/user_spec.rb DELETED
@@ -1,31 +0,0 @@
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
@@ -1,61 +0,0 @@
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
@@ -1,55 +0,0 @@
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
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "test_helper"
4
- require "minitest/mock"
5
-
6
- class RailsTest < Minitest::Test
7
- def setup
8
- Crspec::Rails::Parallel.reset!
9
- Crspec.reset!
10
- end
11
-
12
- def teardown
13
- Crspec::Rails::Parallel.reset!
14
- end
15
-
16
- def test_database_isolation_wrapper
17
- mock_example = Minitest::Mock.new
18
- mock_example.expect(:execute!, true)
19
-
20
- Crspec::Rails::DatabaseIsolation.wrap_example(mock_example)
21
- assert mock_example.verify
22
- end
23
-
24
- def test_system_server_start
25
- Crspec::Rails::SystemServer.reset!
26
- refute Crspec::Rails::SystemServer.running?
27
-
28
- Crspec::Rails::SystemServer.start_concurrent_server!(nil, 9887)
29
- assert Crspec::Rails::SystemServer.running?
30
- end
31
-
32
- def test_rails_parallel_test_setup_and_teardown_hooks
33
- setup_called = []
34
- teardown_called = []
35
-
36
- Crspec::Rails::Parallel.parallelize(workers: 2) do
37
- parallelize_setup do |worker_num|
38
- setup_called << worker_num
39
- end
40
-
41
- parallelize_teardown do |worker_num|
42
- teardown_called << worker_num
43
- end
44
- end
45
-
46
- assert Crspec::Rails::Parallel.enabled?
47
- assert_equal 2, Crspec::Rails::Parallel.worker_count
48
-
49
- group = Crspec.describe "Parallel Test Group" do
50
- it "runs example" do
51
- expect(1).to eq(1)
52
- end
53
- end
54
-
55
- runner = Crspec::Runner.new(concurrency: 2)
56
- runner.run([group])
57
-
58
- assert runner.success?
59
- assert_equal [1, 2], setup_called.sort
60
- assert_equal [1, 2], teardown_called.sort
61
- end
62
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "test_helper"
4
-
5
- class RunnerTest < Minitest::Test
6
- def setup
7
- Crspec.reset!
8
- end
9
-
10
- def test_concurrent_runner_execution
11
- group = Crspec.describe "User Model" do
12
- let(:name) { "Alice" }
13
-
14
- it "validates name" do
15
- expect(name).to eq("Alice")
16
- end
17
-
18
- it "calculates async property" do
19
- expect(1 + 1).to eq(2)
20
- end
21
- end
22
-
23
- formatter = Crspec::Formatters::NullFormatter.new
24
- runner = Crspec::Runner.new(concurrency: 2, formatter: formatter)
25
- runner.run([group])
26
-
27
- assert runner.success?
28
- assert_equal 2, runner.passed_examples.size
29
- assert_equal 0, runner.failed_examples.size
30
- end
31
-
32
- def test_failing_example_recorded
33
- group = Crspec.describe "Failing Suite" do
34
- it "fails expectation" do
35
- expect(1).to eq(2)
36
- end
37
- end
38
-
39
- formatter = Crspec::Formatters::NullFormatter.new
40
- runner = Crspec::Runner.new(concurrency: 1, formatter: formatter)
41
- runner.run([group])
42
-
43
- refute runner.success?
44
- assert_equal 1, runner.failed_examples.size
45
- assert_equal "Expected 2, got 1", runner.failed_examples.first.error.message
46
- end
47
-
48
- def test_progress_formatter_output
49
- out = StringIO.new
50
- formatter = Crspec::Formatters::ProgressFormatter.new(out, color: false)
51
-
52
- group = Crspec.describe "Formatter Suite" do
53
- it "passes" do
54
- expect(1).to eq(1)
55
- end
56
-
57
- it "fails" do
58
- expect(1).to eq(2)
59
- end
60
- end
61
-
62
- runner = Crspec::Runner.new(concurrency: 1, formatter: formatter)
63
- runner.run([group])
64
-
65
- assert_includes out.string, "."
66
- assert_includes out.string, "F"
67
- end
68
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "test_helper"
4
-
5
- class TranspilerTest < Minitest::Test
6
- def test_prism_ast_rewriting_rspec_to_crspec
7
- source = <<~RUBY
8
- RSpec.describe User, type: :model do
9
- let(:valid_attributes) { { name: "Jane" } }
10
- it "validates attributes" do
11
- expect(1).to eq(1)
12
- end
13
- end
14
- RUBY
15
-
16
- rewriter = Crspec::Transpiler::Rewriter.new(source)
17
- transformed = rewriter.transpile
18
-
19
- assert_includes transformed, "Crspec.describe User"
20
- refute_includes transformed, "RSpec.describe"
21
- end
22
-
23
- def test_detects_thread_unsafe_before_all
24
- source = <<~RUBY
25
- Crspec.describe Account do
26
- before(:all) do
27
- @account = Account.create!
28
- end
29
- it "tests something" do
30
- expect(1).to eq(1)
31
- end
32
- end
33
- RUBY
34
-
35
- rewriter = Crspec::Transpiler::Rewriter.new(source)
36
- rewriter.transpile
37
-
38
- refute_empty rewriter.warnings
39
- assert_includes rewriter.warnings.first, "before(:all) mutates global state"
40
- end
41
- end
data/test/test_helper.rb DELETED
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
- require "crspec"
5
-
6
- require "minitest/autorun"