smartest 0.5.0.alpha1 → 0.6.0.alpha1

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: 967084d2b9e35152ef0f03e3ac524e92378e23b946dd919328d95f751553451a
4
- data.tar.gz: b4df54be2c78b6a7341cedf0dc2b95bec0e52f6f51734fe3769bf005bfdfff00
3
+ metadata.gz: 52a400f8b0fb05816a1adae02b69ce475d81bd63ad3516dded34fc47b3efc516
4
+ data.tar.gz: eeef5df2ac8161d3998fb942e7796ccfead8f7f8409b835c771fec4db7756137
5
5
  SHA512:
6
- metadata.gz: c0c2f5f9a2d79ae06e2391a8b1a8138337f175f0a36b25f3969d5cab2e1f05bf90dfd85c734fdf581fdf15c7110338d3e6af7f52550259d24b5650dd0eb553ce
7
- data.tar.gz: 67b85ab5f35b8d9844efacad366b549abe2fab44505266e60f0e10c3495e36b6009581508da0e647cafddb5c03bc13c95884a709565ce26cb7ccc29e3b37d09e
6
+ metadata.gz: 8336c21065222424824f95636730ab6ba1683fff0f711a3d37d180bebe6bac8cbf95be4c9bb8e0a794f0719fb5ba86102703d5af99902c1ab8461b5de0f6ab7c
7
+ data.tar.gz: 067c9a2f862cf07035b50bc8783e92f919a68ec8000546b2ab96e0ed2bde7c8cb0b811d99f7e9bf8bb2983d85ac7883fd830a38f86930c5a688b6e800f459fb7
data/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.0 - Unreleased
3
+ ## 0.6.0 (not published yet)
4
+
5
+ ### New Features
6
+
7
+ - Add Rails browser-test integration with `bundle exec smartest --init-rails`,
8
+ generated Playwright fixtures, and `Smartest::Rails::TestServer` loaded by
9
+ explicit `require "smartest/rails"`.
10
+
11
+ ### Breaking Changes
12
+
13
+ - Make method stubs process-wide across Fibers and Threads. Remove
14
+ `Smartest::SimpleStub#apply!` and `#reset!`; `#apply` and `#reset` now raise
15
+ when called on an already-applied or not-applied stub object.
16
+
17
+ ## 0.5.0
4
18
 
5
19
  ### Breaking Changes
6
20
 
data/README.md CHANGED
@@ -141,6 +141,9 @@ To create a Smartest test scaffold:
141
141
  For browser tests:
142
142
  bundle exec smartest --init-browser
143
143
 
144
+ For Rails browser tests:
145
+ bundle exec smartest --init-rails
146
+
144
147
  See all commands:
145
148
  bundle exec smartest --help
146
149
  ```
@@ -190,6 +193,7 @@ bundle exec smartest smartest/user_test.rb
190
193
  bundle exec smartest smartest/user_test.rb:12
191
194
  bundle exec smartest --init
192
195
  bundle exec smartest --init-browser
196
+ bundle exec smartest --init-rails
193
197
  ```
194
198
 
195
199
  Output resembles:
@@ -235,6 +239,39 @@ Run the generated browser example with:
235
239
  bundle exec smartest smartest/example_browser_test.rb
236
240
  ```
237
241
 
242
+ ## Rails browser quick start
243
+
244
+ Initialize a Rails browser-test scaffold:
245
+
246
+ ```bash
247
+ bundle exec smartest --init-rails
248
+ ```
249
+
250
+ The Rails init command creates the normal Smartest helper and matcher files,
251
+ then adds:
252
+
253
+ ```text
254
+ smartest/fixtures/rails_system_fixture.rb
255
+ smartest/matchers/playwright_matcher.rb
256
+ smartest/example_rails_system_test.rb
257
+ ```
258
+
259
+ The generated fixture requires `smartest/rails` and starts
260
+ `Smartest::Rails::TestServer` against `Rails.application` in the same Ruby
261
+ process as the test runner. `Smartest::Rails::TestServer` is only loaded by
262
+ explicitly requiring `smartest/rails`; plain `require "smartest"` does not load
263
+ Puma.
264
+
265
+ The generated `page` fixture uses a per-test Playwright browser context with
266
+ `baseURL` set to the Rails server URL. Set `SMARTEST_RAILS_PORT` when you need a
267
+ fixed port; otherwise the test server asks the OS for an available port.
268
+
269
+ Run the generated Rails browser example with:
270
+
271
+ ```bash
272
+ bundle exec smartest smartest/example_rails_system_test.rb
273
+ ```
274
+
238
275
  ## Defining tests
239
276
 
240
277
  Use `test` at the top level:
@@ -646,7 +683,7 @@ Use simple stub helpers when a fixture needs to temporarily replace a Ruby
646
683
  method and reset it during teardown:
647
684
 
648
685
  ```ruby
649
- class PaymentFixture < Smartest::Fixture
686
+ class ApplicationTestFixture < Smartest::Fixture
650
687
  fixture :payment_gateway_stub do
651
688
  simple_stub_any_instance_of(PaymentGateway, :charge) { :approved }
652
689
  end
@@ -657,7 +694,7 @@ Register the fixture class from `around_suite` before tests request the fixture:
657
694
 
658
695
  ```ruby
659
696
  around_suite do |suite|
660
- use_fixture PaymentFixture
697
+ use_fixture ApplicationTestFixture
661
698
  suite.run
662
699
  end
663
700
  ```
@@ -665,10 +702,10 @@ end
665
702
  `use_fixture` is available inside `around_suite` or `around_test` blocks, not as
666
703
  a top-level method in a test file.
667
704
 
668
- The stub affects existing instances and new instances of the target class in
669
- the current Fiber until it is reset. Other Fibers and Threads continue to see
670
- the original method unless they apply their own stub. Tests can request the
671
- fixture to make the side effect explicit:
705
+ The stub affects existing instances and new instances of the target class until
706
+ it is reset. Method stubs are shared across Fibers and Threads, including a
707
+ Rails test server running in another thread. Tests can request the fixture to
708
+ make the side effect explicit:
672
709
 
673
710
  ```ruby
674
711
  test("checkout succeeds") do |payment_gateway_stub:|
@@ -680,20 +717,22 @@ Use `simple_stub(Time, :now) { fixed_time }` for singleton methods such as class
680
717
  methods.
681
718
 
682
719
  Use `with_stub_const("AppConfig::PAYMENT_PROVIDER", "fake") { ... }` for
683
- constants in test bodies, `around_test`, or `around_suite`. Constant stubs are
684
- process-global; avoid concurrent tests that stub the same constant.
720
+ constants in test bodies, `around_test`, or `around_suite`.
721
+
722
+ Smartest stubs are process-wide state. They are intended for serial test
723
+ execution and for cases like a Rails test server thread serving the current
724
+ test. They do not provide isolation for multi-threaded parallel test execution:
725
+ one test can observe or reset another test's method or constant stub.
685
726
 
686
727
  The method stub helpers call `Smartest::SimpleStub` internally, apply the stub,
687
728
  register `on_teardown { stub.reset }`, and return the stub object.
688
729
  `with_stub_const` records the previous constant value, replaces it, yields to
689
730
  the block, and restores or removes the constant with `ensure`.
690
731
 
691
- `Smartest::SimpleStub#apply` and `#reset` are idempotent in the current Fiber.
692
- `apply!` raises
693
- `Smartest::SimpleStub::AlreadyAppliedError` when the stub is already active in
694
- the current Fiber, and `reset!` raises
695
- `Smartest::SimpleStub::NotAppliedError` when it is not active there. See
696
- [Stubs](documentation/docs/stubs.md).
732
+ `Smartest::SimpleStub#apply` raises
733
+ `Smartest::SimpleStub::AlreadyAppliedError` when the same stub object is already
734
+ applied. `#reset` raises `Smartest::SimpleStub::NotAppliedError` when that stub
735
+ object is not applied. See [Stubs](documentation/docs/stubs.md).
697
736
 
698
737
  ## Logged-in client example
699
738
 
data/SMARTEST_DESIGN.md CHANGED
@@ -869,6 +869,9 @@ To create a Smartest test scaffold:
869
869
  For browser tests:
870
870
  bundle exec smartest --init-browser
871
871
 
872
+ For Rails browser tests:
873
+ bundle exec smartest --init-rails
874
+
872
875
  See all commands:
873
876
  bundle exec smartest --help
874
877
  ```
data/exe/smartest CHANGED
@@ -28,6 +28,9 @@ usage = <<~USAGE
28
28
  bundle exec smartest --init-browser
29
29
  Generate a Playwright browser-test scaffold
30
30
 
31
+ bundle exec smartest --init-rails
32
+ Generate a Rails Playwright browser-test scaffold
33
+
31
34
  Options:
32
35
  --profile N
33
36
  Print the N slowest tests. Defaults to 5.
@@ -48,6 +51,9 @@ missing_smartest_directory_message = <<~MESSAGE
48
51
  For browser tests:
49
52
  bundle exec smartest --init-browser
50
53
 
54
+ For Rails browser tests:
55
+ bundle exec smartest --init-rails
56
+
51
57
  See all commands:
52
58
  bundle exec smartest --help
53
59
  MESSAGE
@@ -75,6 +81,11 @@ begin
75
81
  exit Smartest::InitBrowserGenerator.new.run
76
82
  end
77
83
 
84
+ if ARGV.include?("--init-rails")
85
+ command = :init_rails
86
+ exit Smartest::InitRailsGenerator.new.run
87
+ end
88
+
78
89
  Smartest.disable_autorun!
79
90
  Smartest.install_dsl!
80
91
  test_load_path = File.expand_path("smartest", Dir.pwd)
@@ -103,6 +114,8 @@ rescue Exception => error
103
114
  "Error initializing Smartest:"
104
115
  when :init_browser
105
116
  "Error initializing Smartest browser scaffold:"
117
+ when :init_rails
118
+ "Error initializing Smartest Rails browser scaffold:"
106
119
  else
107
120
  "Error loading tests:"
108
121
  end
@@ -74,7 +74,7 @@ module Smartest
74
74
  end
75
75
 
76
76
  def apply_simple_stub(stub)
77
- stub.apply!
77
+ stub.apply
78
78
  on_teardown { stub.reset }
79
79
  stub
80
80
  end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Smartest
6
+ class InitRailsGenerator
7
+ RAILS_SYSTEM_FIXTURE = <<~RUBY
8
+ # frozen_string_literal: true
9
+
10
+ require 'smartest/rails'
11
+ require "playwright"
12
+
13
+ class RailsSystemFixture < Smartest::Fixture
14
+ suite_fixture :rails_server do
15
+ # Set the environment before loading config/environment so the test
16
+ # server cannot boot against the development database by default.
17
+ ENV["RAILS_ENV"] ||= "test"
18
+ ENV["RACK_ENV"] ||= ENV["RAILS_ENV"]
19
+ require_relative "../../config/environment"
20
+
21
+ server = Smartest::Rails::TestServer.new(app: Rails.application)
22
+ server.start
23
+ server.wait_for_ready
24
+
25
+ on_teardown do
26
+ server.stop
27
+ server.wait_for_stopped
28
+ end
29
+
30
+ server
31
+ end
32
+
33
+ suite_fixture :base_url do |rails_server:|
34
+ rails_server.base_url
35
+ end
36
+
37
+ suite_fixture :playwright do
38
+ runtime = Playwright.create(
39
+ playwright_cli_executable_path: "./node_modules/.bin/playwright",
40
+ )
41
+ on_teardown { runtime.stop }
42
+ runtime.playwright
43
+ end
44
+
45
+ suite_fixture :browser do |playwright:|
46
+ browser_type = case ENV["BROWSER"]
47
+ when "firefox"
48
+ :firefox
49
+ when "webkit"
50
+ :webkit
51
+ else
52
+ :chromium
53
+ end
54
+
55
+ launch_options = {}
56
+ launch_options[:headless] = !%w[0 false].include?(ENV["HEADLESS"])
57
+ if (slow_mo = ENV["SLOW_MO"].to_i) > 0
58
+ launch_options[:slowMo] = slow_mo
59
+ end
60
+
61
+ browser = playwright.send(browser_type).launch(**launch_options)
62
+ on_teardown { browser.close }
63
+ browser
64
+ end
65
+
66
+ fixture :browser_context do |base_url:, browser:|
67
+ context = browser.new_context(baseURL: base_url)
68
+ on_teardown { context.close }
69
+ context
70
+ end
71
+
72
+ fixture :page do |browser_context:|
73
+ page = browser_context.new_page
74
+ on_teardown { page.close }
75
+ page
76
+ end
77
+ end
78
+ RUBY
79
+
80
+ PLAYWRIGHT_MATCHER = <<~RUBY
81
+ # frozen_string_literal: true
82
+
83
+ require "playwright"
84
+ require "playwright/test"
85
+
86
+ module PlaywrightMatcher
87
+ include Playwright::Test::Matchers
88
+ end
89
+ RUBY
90
+
91
+ EXAMPLE_RAILS_SYSTEM_TEST = <<~RUBY
92
+ # frozen_string_literal: true
93
+
94
+ require "test_helper"
95
+
96
+ test("loads the Rails application") do |page:|
97
+ response = page.goto("/")
98
+
99
+ expect(response.status).to be_between(200, 599)
100
+ end
101
+ RUBY
102
+
103
+ def initialize(root: Dir.pwd, output: $stdout, command_runner: nil)
104
+ @root = root
105
+ @output = output
106
+ @command_runner = command_runner || method(:run_system_command)
107
+ end
108
+
109
+ def run
110
+ Smartest::InitGenerator.new(
111
+ root: @root,
112
+ output: @output,
113
+ files: smartest_files,
114
+ final_message: nil
115
+ ).run
116
+ create_file("smartest/fixtures/rails_system_fixture.rb", RAILS_SYSTEM_FIXTURE)
117
+ create_file("smartest/matchers/playwright_matcher.rb", PLAYWRIGHT_MATCHER)
118
+ update_test_helper
119
+ update_gemfile
120
+ install_dependencies
121
+ @output.puts
122
+ @output.puts "Run your Rails browser test suite with: bundle exec smartest smartest/example_rails_system_test.rb"
123
+
124
+ 0
125
+ end
126
+
127
+ private
128
+
129
+ def smartest_files
130
+ Smartest::InitGenerator::FILES.merge("smartest/example_rails_system_test.rb" => EXAMPLE_RAILS_SYSTEM_TEST)
131
+ end
132
+
133
+ def create_file(path, contents)
134
+ absolute_path = File.join(@root, path)
135
+
136
+ if File.exist?(absolute_path)
137
+ @output.puts "exist #{path}"
138
+ return
139
+ end
140
+
141
+ FileUtils.mkdir_p(File.dirname(absolute_path))
142
+ File.write(absolute_path, contents)
143
+ @output.puts "create #{path}"
144
+ end
145
+
146
+ def update_test_helper
147
+ path = File.join(@root, "smartest/test_helper.rb")
148
+ contents = File.read(path)
149
+ updated = ensure_rails_registered(contents)
150
+
151
+ return if updated == contents
152
+
153
+ File.write(path, updated)
154
+ @output.puts "update smartest/test_helper.rb"
155
+ end
156
+
157
+ def ensure_rails_registered(contents)
158
+ missing_lines = []
159
+ missing_lines << " use_fixture RailsSystemFixture\n" unless contents.include?("use_fixture RailsSystemFixture")
160
+ missing_lines << " use_matcher PlaywrightMatcher\n" unless contents.include?("use_matcher PlaywrightMatcher")
161
+ return contents if missing_lines.empty?
162
+
163
+ if contents.include?("use_matcher PredicateMatcher")
164
+ contents.sub(/^(\s*use_matcher PredicateMatcher\n)/) do
165
+ "#{Regexp.last_match(1)}#{missing_lines.join}"
166
+ end
167
+ else
168
+ "#{contents.chomp}\n\naround_suite do |suite|\n#{missing_lines.join} suite.run\nend\n"
169
+ end
170
+ end
171
+
172
+ def update_gemfile
173
+ path = File.join(@root, "Gemfile")
174
+ exists = File.exist?(path)
175
+ contents = exists ? File.read(path) : "source \"https://rubygems.org\"\n"
176
+
177
+ if contents.match?(/gem ["']playwright-ruby-client["']/)
178
+ @output.puts "exist Gemfile playwright-ruby-client"
179
+ return
180
+ end
181
+
182
+ separator = contents.end_with?("\n") ? "" : "\n"
183
+ updated = "#{contents}#{separator}\ngem \"playwright-ruby-client\", group: :test\n"
184
+ File.write(path, updated)
185
+ @output.puts(exists ? "update Gemfile" : "create Gemfile")
186
+ end
187
+
188
+ def install_dependencies
189
+ install_commands.each do |command|
190
+ @output.puts "run #{command.join(" ")}"
191
+ next if @command_runner.call(command, chdir: @root)
192
+
193
+ raise "command failed: #{command.join(" ")}"
194
+ end
195
+ end
196
+
197
+ def install_commands
198
+ commands = [["bundle", "install"]]
199
+ commands << ["npm", "init", "--yes"] unless File.exist?(File.join(@root, "package.json"))
200
+ commands << ["npm", "install", "playwright", "--save-dev"]
201
+ commands << ["./node_modules/.bin/playwright", "install"]
202
+ commands
203
+ end
204
+
205
+ def run_system_command(command, chdir:)
206
+ system(*command, chdir: chdir)
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "puma"
5
+ require "timeout"
6
+
7
+ require_relative "../smartest"
8
+
9
+ module Smartest
10
+ module Rails
11
+ class TestServer
12
+ DEFAULT_HOST = "127.0.0.1"
13
+ DEFAULT_READY_TIMEOUT = 10
14
+
15
+ attr_reader :host, :port
16
+
17
+ def initialize(app:, host: DEFAULT_HOST, port: nil)
18
+ @app = app
19
+ @host = host
20
+ @requested_port = port || ENV["SMARTEST_RAILS_PORT"]&.to_i || 0
21
+ @server = Puma::Server.new(@app)
22
+ @port = bind_tcp_listener
23
+ @thread = nil
24
+ end
25
+
26
+ def start
27
+ @thread ||= @server.run
28
+ end
29
+
30
+ def stop
31
+ @server.stop
32
+ end
33
+
34
+ def wait_for_ready(timeout: DEFAULT_READY_TIMEOUT)
35
+ Timeout.timeout(timeout) do
36
+ sleep 0.05 until responsive?
37
+ end
38
+ rescue Timeout::Error
39
+ raise "Rails test server did not become ready at #{base_url} within #{timeout} seconds"
40
+ end
41
+
42
+ def wait_for_stopped(timeout: DEFAULT_READY_TIMEOUT)
43
+ return unless @thread
44
+ return if @thread.join(timeout)
45
+
46
+ raise "Rails test server did not stop within #{timeout} seconds"
47
+ end
48
+
49
+ def base_url
50
+ "http://#{host}:#{port}"
51
+ end
52
+
53
+ private
54
+
55
+ def bind_tcp_listener
56
+ listener = @server.add_tcp_listener(@host, @requested_port)
57
+ bound_port = listener.respond_to?(:addr) ? listener.addr[1] : @requested_port
58
+
59
+ return bound_port if bound_port && bound_port.positive?
60
+
61
+ raise "Rails test server could not determine bound port"
62
+ end
63
+
64
+ def responsive?
65
+ Net::HTTP.start(host, port, open_timeout: 0.2, read_timeout: 0.2) do |http|
66
+ http.head("/")
67
+ end
68
+ true
69
+ rescue EOFError, IOError, Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -4,22 +4,40 @@ require "digest"
4
4
 
5
5
  module Smartest
6
6
  class SimpleStub
7
- STORAGE_KEY = :__smartest_simple_stub
7
+ StubEntry = Struct.new(:owner, :implementation)
8
8
 
9
9
  class AlreadyAppliedError < Smartest::Error; end
10
10
  class NotAppliedError < Smartest::Error; end
11
11
 
12
12
  class << self
13
13
  def implementation_for(klass_key, method_name)
14
- current_stubs&.fetch(stub_key(klass_key, method_name), nil)
14
+ stub_registry_mutex.synchronize do
15
+ active_stubs.fetch(stub_key(klass_key, method_name), nil)&.last&.implementation
16
+ end
15
17
  end
16
18
 
17
- def active_stubs
18
- Thread.current[STORAGE_KEY] ||= {}
19
+ def activate_stub(stub, stub_key, implementation)
20
+ stub_registry_mutex.synchronize do
21
+ stack = active_stubs[stub_key] ||= []
22
+ return false if stack.any? { |entry| entry.owner.equal?(stub) }
23
+
24
+ stack << StubEntry.new(stub, implementation)
25
+ true
26
+ end
19
27
  end
20
28
 
21
- def clear_active_stubs_if_empty
22
- Thread.current[STORAGE_KEY] = nil if current_stubs&.empty?
29
+ def deactivate_stub(stub, stub_key)
30
+ stub_registry_mutex.synchronize do
31
+ stack = active_stubs.fetch(stub_key, nil)
32
+ return false unless stack
33
+
34
+ index = stack.index { |entry| entry.owner.equal?(stub) }
35
+ return false unless index
36
+
37
+ stack.delete_at(index)
38
+ active_stubs.delete(stub_key) if stack.empty?
39
+ true
40
+ end
23
41
  end
24
42
 
25
43
  def ensure_dispatcher_method(klass, klass_key, method_name)
@@ -45,10 +63,6 @@ module Smartest
45
63
  [klass_key, method_name]
46
64
  end
47
65
 
48
- def current_stubs
49
- Thread.current[STORAGE_KEY]
50
- end
51
-
52
66
  def call_implementation(receiver, implementation, args, kwargs, block)
53
67
  return call_implementation_with_block(receiver, implementation, args, kwargs, block) if block
54
68
 
@@ -110,6 +124,14 @@ module Smartest
110
124
  def call_mutex
111
125
  @call_mutex ||= Mutex.new
112
126
  end
127
+
128
+ def active_stubs
129
+ @active_stubs ||= {}
130
+ end
131
+
132
+ def stub_registry_mutex
133
+ @stub_registry_mutex ||= Mutex.new
134
+ end
113
135
  end
114
136
 
115
137
  def initialize(klass, method_name, &implementation)
@@ -122,26 +144,10 @@ module Smartest
122
144
  end
123
145
 
124
146
  def apply
125
- return if stub_defined?
126
-
127
- apply_stub
128
- end
129
-
130
- def apply!
131
- raise AlreadyAppliedError, "stub for #{@klass}##{@method_name} is already applied" if stub_defined?
132
-
133
147
  apply_stub
134
148
  end
135
149
 
136
150
  def reset
137
- return unless stub_defined?
138
-
139
- reset_stub
140
- end
141
-
142
- def reset!
143
- raise NotAppliedError, "stub for #{@klass}##{@method_name} is not applied" unless stub_defined?
144
-
145
151
  reset_stub
146
152
  end
147
153
 
@@ -151,16 +157,15 @@ module Smartest
151
157
  raise ArgumentError, "block must be given for applying stub" unless @implementation
152
158
 
153
159
  self.class.ensure_dispatcher_method(@klass, klass_key, @method_name)
154
- active_stubs[stub_key] = @implementation
160
+ return self if self.class.activate_stub(self, stub_key, @implementation)
161
+
162
+ raise AlreadyAppliedError, "stub for #{@klass}##{@method_name} is already applied"
155
163
  end
156
164
 
157
165
  def reset_stub
158
- active_stubs.delete(stub_key)
159
- self.class.clear_active_stubs_if_empty
160
- end
166
+ return self if self.class.deactivate_stub(self, stub_key)
161
167
 
162
- def active_stubs
163
- self.class.active_stubs
168
+ raise NotAppliedError, "stub for #{@klass}##{@method_name} is not applied"
164
169
  end
165
170
 
166
171
  def stub_key
@@ -170,9 +175,5 @@ module Smartest
170
175
  def klass_key
171
176
  @klass_key ||= Digest::SHA256.hexdigest(@klass.object_id.to_s)
172
177
  end
173
-
174
- def stub_defined?
175
- self.class.current_stubs&.key?(stub_key)
176
- end
177
178
  end
178
179
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smartest
4
- VERSION = "0.5.0.alpha1"
4
+ VERSION = "0.6.0.alpha1"
5
5
  end
data/lib/smartest.rb CHANGED
@@ -28,6 +28,7 @@ require_relative "smartest/reporter"
28
28
  require_relative "smartest/runner"
29
29
  require_relative "smartest/init_generator"
30
30
  require_relative "smartest/init_browser_generator"
31
+ require_relative "smartest/init_rails_generator"
31
32
  require_relative "smartest/cli_arguments"
32
33
 
33
34
  module Smartest
@@ -71,7 +71,7 @@ test("simple stub stubs instance methods until reset") do
71
71
  existing = SimpleStubSelfTestSubject.new("Alice")
72
72
  stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "stubbed" }
73
73
 
74
- stub.apply!
74
+ stub.apply
75
75
 
76
76
  begin
77
77
  expect(existing.name).to eq("stubbed")
@@ -84,13 +84,23 @@ test("simple stub stubs instance methods until reset") do
84
84
  expect(SimpleStubSelfTestSubject.new("Bob").name).to eq("original Bob")
85
85
  end
86
86
 
87
- test("simple stub can be reset from a fresh stub object") do
88
- Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :greeting) { |prefix| "#{prefix}, stubbed" }.apply!
87
+ test("simple stub reset requires the applied stub object") do
88
+ stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :greeting) do |prefix|
89
+ "#{prefix}, stubbed"
90
+ end
91
+
92
+ stub.apply
89
93
 
90
94
  begin
91
95
  expect(SimpleStubSelfTestSubject.new("Alice").greeting("Hi")).to eq("Hi, stubbed")
96
+
97
+ error = SimpleStubSelfTest.capture_error(Smartest::SimpleStub::NotAppliedError) do
98
+ Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :greeting).reset
99
+ end
100
+
101
+ expect(error.message).to eq("stub for SimpleStubSelfTestSubject#greeting is not applied")
92
102
  ensure
93
- Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :greeting).reset!
103
+ stub.reset
94
104
  end
95
105
 
96
106
  expect(SimpleStubSelfTestSubject.new("Alice").greeting("Hi")).to eq("Hi, Alice")
@@ -127,6 +137,39 @@ test("simple_stub_any_instance_of applies and resets from fixture teardown") do
127
137
  expect(status).to eq(0)
128
138
  end
129
139
 
140
+ test("test-scoped method stubs restore suite-scoped method stubs") do
141
+ fixture_class = Class.new(Smartest::Fixture) do
142
+ suite_fixture :suite_name_stub do
143
+ simple_stub_any_instance_of(SimpleStubSelfTestSubject, :name) { "suite #{@name}" }
144
+ end
145
+
146
+ fixture :test_name_stub do |suite_name_stub:|
147
+ expect(suite_name_stub).to be_a(Smartest::SimpleStub)
148
+ simple_stub_any_instance_of(SimpleStubSelfTestSubject, :name) { "test #{@name}" }
149
+ end
150
+ end
151
+
152
+ suite = Smartest::Suite.new
153
+ suite.fixture_classes.add(fixture_class)
154
+ suite.tests.add(
155
+ SimpleStubSelfTest.test_case(
156
+ "uses test scoped override",
157
+ proc { |test_name_stub:| expect(SimpleStubSelfTestSubject.new("Alice").name).to eq("test Alice") }
158
+ )
159
+ )
160
+ suite.tests.add(
161
+ SimpleStubSelfTest.test_case(
162
+ "sees suite scoped stub after override teardown",
163
+ proc { |suite_name_stub:| expect(SimpleStubSelfTestSubject.new("Alice").name).to eq("suite Alice") }
164
+ )
165
+ )
166
+
167
+ status, = SimpleStubSelfTest.run_suite(suite)
168
+
169
+ expect(status).to eq(0)
170
+ expect(SimpleStubSelfTestSubject.new("Alice").name).to eq("original Alice")
171
+ end
172
+
130
173
  test("simple_stub applies and resets singleton methods from fixture teardown") do
131
174
  fixture_class = Class.new(Smartest::Fixture) do
132
175
  fixture :stubbed_time do
@@ -261,7 +304,7 @@ test("simple stub preserves receiver self and method blocks") do
261
304
  block.call("#{prefix}, stubbed #{@name}")
262
305
  end
263
306
 
264
- stub.apply!
307
+ stub.apply
265
308
 
266
309
  begin
267
310
  result = subject.yielding_greeting("Hi") { |message| message.upcase }
@@ -273,16 +316,16 @@ test("simple stub preserves receiver self and method blocks") do
273
316
  expect(subject.yielding_greeting("Hi") { |message| message }).to eq("Hi, Alice")
274
317
  end
275
318
 
276
- test("simple stub is scoped to the current fiber") do
319
+ test("simple stub is shared with fibers") do
277
320
  subject = SimpleStubSelfTestSubject.new("Alice")
278
321
  stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "stubbed #{@name}" }
279
322
 
280
- stub.apply!
323
+ stub.apply
281
324
 
282
325
  begin
283
326
  expect(subject.name).to eq("stubbed Alice")
284
327
  Fiber.new do
285
- expect(subject.name).to eq("original Alice")
328
+ expect(subject.name).to eq("stubbed Alice")
286
329
  end.resume
287
330
  ensure
288
331
  stub.reset
@@ -291,52 +334,62 @@ test("simple stub is scoped to the current fiber") do
291
334
  expect(subject.name).to eq("original Alice")
292
335
  end
293
336
 
294
- test("simple stub can differ per thread") do
337
+ test("simple stub is shared with threads") do
295
338
  subject = SimpleStubSelfTestSubject.new("Alice")
296
339
  queue = Queue.new
297
- main_stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "main #{@name}" }
340
+ stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "stubbed #{@name}" }
298
341
 
299
- main_stub.apply!
342
+ stub.apply
300
343
 
301
344
  thread = Thread.new do
302
- thread_stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "thread #{@name}" }
303
- thread_stub.apply!
304
-
305
- begin
306
- queue << subject.name
307
- rescue Exception => error
308
- queue << error
309
- ensure
310
- thread_stub.reset
311
- end
345
+ queue << subject.name
346
+ rescue Exception => error
347
+ queue << error
312
348
  end
313
349
 
314
350
  begin
315
- expect(subject.name).to eq("main Alice")
351
+ expect(subject.name).to eq("stubbed Alice")
316
352
 
317
353
  thread_result = queue.pop
318
354
  raise thread_result if thread_result.is_a?(Exception)
319
355
 
320
- expect(thread_result).to eq("thread Alice")
356
+ expect(thread_result).to eq("stubbed Alice")
321
357
  thread.join
322
- expect(subject.name).to eq("main Alice")
323
358
  ensure
324
- main_stub.reset
359
+ stub.reset
325
360
  thread.kill if thread.alive?
326
361
  end
327
362
 
328
363
  expect(subject.name).to eq("original Alice")
329
364
  end
330
365
 
331
- test("simple stub supports safe and strict apply reset APIs") do
366
+ test("simple stub restores previous implementation when nested stubs reset") do
367
+ subject = SimpleStubSelfTestSubject.new("Alice")
368
+ outer_stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "outer #{@name}" }
369
+ inner_stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "inner #{@name}" }
370
+
371
+ outer_stub.apply
372
+ inner_stub.apply
373
+
374
+ begin
375
+ expect(subject.name).to eq("inner Alice")
376
+ inner_stub.reset
377
+ expect(subject.name).to eq("outer Alice")
378
+ ensure
379
+ outer_stub.reset
380
+ end
381
+
382
+ expect(subject.name).to eq("original Alice")
383
+ end
384
+
385
+ test("simple stub apply and reset are strict") do
332
386
  stub = Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "stubbed" }
333
387
 
334
- stub.apply
335
388
  stub.apply
336
389
 
337
390
  begin
338
391
  error = SimpleStubSelfTest.capture_error(Smartest::SimpleStub::AlreadyAppliedError) do
339
- Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name) { "other" }.apply!
392
+ stub.apply
340
393
  end
341
394
 
342
395
  expect(error.message).to eq("stub for SimpleStubSelfTestSubject#name is already applied")
@@ -344,10 +397,8 @@ test("simple stub supports safe and strict apply reset APIs") do
344
397
  stub.reset
345
398
  end
346
399
 
347
- stub.reset
348
-
349
400
  error = SimpleStubSelfTest.capture_error(Smartest::SimpleStub::NotAppliedError) do
350
- stub.reset!
401
+ stub.reset
351
402
  end
352
403
 
353
404
  expect(error.message).to eq("stub for SimpleStubSelfTestSubject#name is not applied")
@@ -367,7 +418,7 @@ test("simple stub validates constructor arguments and apply block") do
367
418
  expect(error.message).to eq("method name must be a Symbol.")
368
419
 
369
420
  error = SimpleStubSelfTest.capture_error(ArgumentError) do
370
- Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name).apply!
421
+ Smartest::SimpleStub.new(SimpleStubSelfTestSubject, :name).apply
371
422
  end
372
423
 
373
424
  expect(error.message).to eq("block must be given for applying stub")
@@ -1723,6 +1723,7 @@ test("cli prints help") do
1723
1723
  expect(stdout).not_to include("Run a browser test file")
1724
1724
  expect(stdout).to include("bundle exec smartest --init")
1725
1725
  expect(stdout).to include("bundle exec smartest --init-browser")
1726
+ expect(stdout).to include("bundle exec smartest --init-rails")
1726
1727
  expect(stdout).to include("--profile N")
1727
1728
  expect(stdout).to include("smartest/**/*_test.rb")
1728
1729
  end
@@ -1741,6 +1742,7 @@ test("cli explains how to initialize when default smartest directory is missing"
1741
1742
  expect(stdout).to include("No smartest/ directory found.")
1742
1743
  expect(stdout).to include("bundle exec smartest --init")
1743
1744
  expect(stdout).to include("bundle exec smartest --init-browser")
1745
+ expect(stdout).to include("bundle exec smartest --init-rails")
1744
1746
  expect(stdout).to include("bundle exec smartest --help")
1745
1747
  end
1746
1748
  end
@@ -2198,3 +2200,205 @@ test("cli browser init generator skips npm init when package.json already exists
2198
2200
  expect(output.string).not_to include("npm init --yes")
2199
2201
  end
2200
2202
  end
2203
+
2204
+ test("smartest rails helper is loaded only by explicit require") do
2205
+ lib_path = File.expand_path("../lib", __dir__)
2206
+ stdout, stderr, status = Open3.capture3(
2207
+ { "RUBYLIB" => lib_path },
2208
+ "ruby",
2209
+ "-e",
2210
+ 'require "smartest"; puts Smartest.const_defined?(:Rails, false)'
2211
+ )
2212
+
2213
+ expect(status.success?).to eq(true)
2214
+ expect(stderr).to eq("")
2215
+ expect(stdout).to eq("false\n")
2216
+
2217
+ Dir.mktmpdir do |dir|
2218
+ File.write(File.join(dir, "puma.rb"), "module Puma\n class Server\n end\nend\n")
2219
+
2220
+ stdout, stderr, status = Open3.capture3(
2221
+ { "RUBYLIB" => "#{dir}:#{lib_path}" },
2222
+ "ruby",
2223
+ "-e",
2224
+ 'require "smartest/rails"; puts Smartest::Rails.const_defined?(:TestServer, false)'
2225
+ )
2226
+
2227
+ expect(status.success?).to eq(true)
2228
+ expect(stderr).to eq("")
2229
+ expect(stdout).to eq("true\n")
2230
+ end
2231
+ end
2232
+
2233
+ test("smartest rails test server wraps puma with env port and lifecycle") do
2234
+ lib_path = File.expand_path("../lib", __dir__)
2235
+
2236
+ Dir.mktmpdir do |dir|
2237
+ File.write(File.join(dir, "puma.rb"), <<~RUBY)
2238
+ module Puma
2239
+ class Listener
2240
+ def initialize(port)
2241
+ @port = port
2242
+ end
2243
+
2244
+ def addr
2245
+ ["AF_INET", @port]
2246
+ end
2247
+ end
2248
+
2249
+ class Server
2250
+ attr_reader :stopped
2251
+
2252
+ def initialize(app)
2253
+ @app = app
2254
+ end
2255
+
2256
+ def add_tcp_listener(_host, port)
2257
+ Listener.new(port.zero? ? 4321 : port)
2258
+ end
2259
+
2260
+ def run
2261
+ Thread.new {}
2262
+ end
2263
+
2264
+ def stop
2265
+ @stopped = true
2266
+ end
2267
+ end
2268
+ end
2269
+ RUBY
2270
+
2271
+ stdout, stderr, status = Open3.capture3(
2272
+ { "RUBYLIB" => "#{dir}:#{lib_path}", "SMARTEST_RAILS_PORT" => "4567" },
2273
+ "ruby",
2274
+ "-e",
2275
+ <<~'RUBY'
2276
+ require "smartest/rails"
2277
+
2278
+ server = Smartest::Rails::TestServer.new(app: Object.new)
2279
+ thread = server.start
2280
+ server.stop
2281
+ server.wait_for_stopped
2282
+
2283
+ puts server.base_url
2284
+ puts thread.is_a?(Thread)
2285
+ RUBY
2286
+ )
2287
+
2288
+ expect(status.success?).to eq(true)
2289
+ expect(stderr).to eq("")
2290
+ expect(stdout).to eq("http://127.0.0.1:4567\ntrue\n")
2291
+ end
2292
+ end
2293
+
2294
+ test("cli rails init generator creates Rails browser scaffold and installation commands") do
2295
+ Dir.mktmpdir do |dir|
2296
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
2297
+ source "https://rubygems.org"
2298
+
2299
+ gem "rails"
2300
+ gem "smartest"
2301
+ RUBY
2302
+
2303
+ commands = []
2304
+ output = StringIO.new
2305
+ generator = Smartest::InitRailsGenerator.new(
2306
+ root: dir,
2307
+ output: output,
2308
+ command_runner: ->(command, chdir:) {
2309
+ commands << [command, chdir]
2310
+ true
2311
+ }
2312
+ )
2313
+
2314
+ status = generator.run
2315
+
2316
+ expect(status).to eq(0)
2317
+ rails_fixture = File.read(File.join(dir, "smartest/fixtures/rails_system_fixture.rb"))
2318
+ expect(rails_fixture).to include("require 'smartest/rails'")
2319
+ expect(rails_fixture).to include("class RailsSystemFixture < Smartest::Fixture")
2320
+ expect(rails_fixture).to include("suite_fixture :rails_server")
2321
+ expect(rails_fixture).to include("server cannot boot against the development database")
2322
+ expect(rails_fixture).to include('require_relative "../../config/environment"')
2323
+ expect(rails_fixture).to include("Smartest::Rails::TestServer.new(app: Rails.application)")
2324
+ expect(rails_fixture).not_to include("SmartestRailsTestServer")
2325
+ expect(rails_fixture).not_to include("Puma::Server")
2326
+ expect(rails_fixture).to include("suite_fixture :base_url")
2327
+ expect(rails_fixture).to include("fixture :browser_context do |base_url:, browser:|")
2328
+ expect(rails_fixture).to include("context = browser.new_context(baseURL: base_url)")
2329
+ expect(rails_fixture).to include("fixture :page do |browser_context:|")
2330
+
2331
+ example_test = File.read(File.join(dir, "smartest/example_rails_system_test.rb"))
2332
+ expect(example_test).to include('test("loads the Rails application") do |page:|')
2333
+ expect(example_test).to include('response = page.goto("/")')
2334
+ expect(example_test).to include("expect(response.status).to be_between(200, 599)")
2335
+
2336
+ helper_contents = File.read(File.join(dir, "smartest/test_helper.rb"))
2337
+ expect(helper_contents).to include("use_matcher PredicateMatcher\n use_fixture RailsSystemFixture\n use_matcher PlaywrightMatcher\n suite.run")
2338
+ expect(helper_contents).not_to include("Smartest::SimpleStub")
2339
+
2340
+ gemfile_contents = File.read(File.join(dir, "Gemfile"))
2341
+ expect(gemfile_contents).to include('gem "playwright-ruby-client", group: :test')
2342
+ expect(commands).to eq(
2343
+ [
2344
+ [["bundle", "install"], dir],
2345
+ [["npm", "init", "--yes"], dir],
2346
+ [["npm", "install", "playwright", "--save-dev"], dir],
2347
+ [["./node_modules/.bin/playwright", "install"], dir]
2348
+ ]
2349
+ )
2350
+ expect(output.string).to include("Run your Rails browser test suite with: bundle exec smartest smartest/example_rails_system_test.rb")
2351
+ end
2352
+ end
2353
+
2354
+ test("cli rails init generator skips duplicate registration and dependencies") do
2355
+ Dir.mktmpdir do |dir|
2356
+ FileUtils.mkdir_p(File.join(dir, "smartest/fixtures"))
2357
+ FileUtils.mkdir_p(File.join(dir, "smartest/matchers"))
2358
+ File.write(File.join(dir, "package.json"), "{\n \"name\": \"existing\"\n}\n")
2359
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
2360
+ source "https://rubygems.org"
2361
+
2362
+ gem "smartest"
2363
+ gem "playwright-ruby-client", group: :test
2364
+ RUBY
2365
+ File.write(File.join(dir, "smartest/test_helper.rb"), <<~RUBY)
2366
+ require "smartest/autorun"
2367
+
2368
+ around_suite do |suite|
2369
+ use_matcher PredicateMatcher
2370
+ use_fixture RailsSystemFixture
2371
+ use_matcher PlaywrightMatcher
2372
+ suite.run
2373
+ end
2374
+ RUBY
2375
+
2376
+ commands = []
2377
+ output = StringIO.new
2378
+ generator = Smartest::InitRailsGenerator.new(
2379
+ root: dir,
2380
+ output: output,
2381
+ command_runner: ->(command, chdir:) {
2382
+ commands << [command, chdir]
2383
+ true
2384
+ }
2385
+ )
2386
+
2387
+ status = generator.run
2388
+
2389
+ expect(status).to eq(0)
2390
+ helper_contents = File.read(File.join(dir, "smartest/test_helper.rb"))
2391
+ expect(helper_contents.scan("use_fixture RailsSystemFixture").length).to eq(1)
2392
+ expect(helper_contents.scan("use_matcher PlaywrightMatcher").length).to eq(1)
2393
+ expect(helper_contents).not_to include("Smartest::SimpleStub")
2394
+ expect(commands).to eq(
2395
+ [
2396
+ [["bundle", "install"], dir],
2397
+ [["npm", "install", "playwright", "--save-dev"], dir],
2398
+ [["./node_modules/.bin/playwright", "install"], dir]
2399
+ ]
2400
+ )
2401
+ expect(output.string).to include("exist Gemfile playwright-ruby-client")
2402
+ expect(output.string).not_to include("npm init --yes")
2403
+ end
2404
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smartest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0.alpha1
4
+ version: 0.6.0.alpha1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Iwaki
@@ -62,9 +62,11 @@ files:
62
62
  - lib/smartest/hook_contexts.rb
63
63
  - lib/smartest/init_browser_generator.rb
64
64
  - lib/smartest/init_generator.rb
65
+ - lib/smartest/init_rails_generator.rb
65
66
  - lib/smartest/matcher_registry.rb
66
67
  - lib/smartest/matchers.rb
67
68
  - lib/smartest/parameter_extractor.rb
69
+ - lib/smartest/rails.rb
68
70
  - lib/smartest/reporter.rb
69
71
  - lib/smartest/runner.rb
70
72
  - lib/smartest/simple_stub.rb