smartest 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e192221b36307c61da97572a41e866b137f1a668bb9db26bda75fbd6489df89a
4
- data.tar.gz: eb3ee1b6ffae15eaf27b88ffbe4d01bfedbb763a7a1d52bb99bae34bd7e8dc97
3
+ metadata.gz: 6c8e44520f35d8daac5d01122fd2dc2b1f70f0fcbc61c1ff1d2a6c8ce5179a72
4
+ data.tar.gz: 4400e77b387079fa88978ff82d34b492f250fdf6de75aae773911a5b91672169
5
5
  SHA512:
6
- metadata.gz: 916737afbb7af9447c571d16de7c08e73ea52686462a2b99c4378a118e38d613fdbe3152f13fbf089fd518b8930c3e8f927451678e58e23777150a3d98158608
7
- data.tar.gz: fe618761cbe53ad37bde38182018835b7566d7a205d0edd92c7e43547d002b131771bce65f2f82a66b7dc3afa770c190d3d8ccfd4e374398e0e2341aa22baf98
6
+ metadata.gz: b5f7dde5de541b2eec20747164936cee3945076fb985c636d4ae2ea2473e0e2b795b9e5b393800b198e2bae5ae8c6e0892ab173d74e995404a1f4941ce27fd57
7
+ data.tar.gz: 0226224e48d5dfcaa82b9b22fc03ab295cab9fd463f5f0e17bab881d38e9f68eb98946641a45f5b6a13a884d1649c714a6e8aacd86e772b1aa6ec9d82fbf323c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
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
+
3
17
  ## 0.5.0
4
18
 
5
19
  ### Breaking Changes
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,57 @@ 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
+ This is aimed at local Rails system tests that combine Rails test data, stubs,
266
+ and Playwright browser assertions. It is not a Capybara compatibility layer or
267
+ the main choice for staging / production-like E2E suites; use Node.js
268
+ Playwright Test for that style of E2E testing.
269
+
270
+ The generated `page` fixture uses a per-test Playwright browser context with
271
+ `baseURL` set to the Rails server URL. Set
272
+ `SMARTEST_RAILS_TEST_SERVER_PORT` when you need a fixed port; otherwise the test
273
+ server asks the OS for an available port.
274
+
275
+ For Docker sidecar runs, initialize without installing browser binaries into
276
+ the Rails app container:
277
+
278
+ ```bash
279
+ SMARTEST_SKIP_BROWSER_DOWNLOAD=1 bundle exec smartest --init-rails
280
+ ```
281
+
282
+ At runtime, set `PLAYWRIGHT_WS_ENDPOINT`,
283
+ `SMARTEST_RAILS_TEST_SERVER_HOST`, `SMARTEST_RAILS_TEST_SERVER_PORT`, and
284
+ `SMARTEST_RAILS_BASE_URL` so the generated fixture connects to the Playwright
285
+ sidecar and gives the browser a Docker-network URL for Rails.
286
+
287
+ Run the generated Rails browser example with:
288
+
289
+ ```bash
290
+ bundle exec smartest smartest/example_rails_system_test.rb
291
+ ```
292
+
238
293
  ## Defining tests
239
294
 
240
295
  Use `test` at the top level:
@@ -646,7 +701,7 @@ Use simple stub helpers when a fixture needs to temporarily replace a Ruby
646
701
  method and reset it during teardown:
647
702
 
648
703
  ```ruby
649
- class PaymentFixture < Smartest::Fixture
704
+ class ApplicationTestFixture < Smartest::Fixture
650
705
  fixture :payment_gateway_stub do
651
706
  simple_stub_any_instance_of(PaymentGateway, :charge) { :approved }
652
707
  end
@@ -657,7 +712,7 @@ Register the fixture class from `around_suite` before tests request the fixture:
657
712
 
658
713
  ```ruby
659
714
  around_suite do |suite|
660
- use_fixture PaymentFixture
715
+ use_fixture ApplicationTestFixture
661
716
  suite.run
662
717
  end
663
718
  ```
@@ -665,10 +720,10 @@ end
665
720
  `use_fixture` is available inside `around_suite` or `around_test` blocks, not as
666
721
  a top-level method in a test file.
667
722
 
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:
723
+ The stub affects existing instances and new instances of the target class until
724
+ it is reset. Method stubs are shared across Fibers and Threads, including a
725
+ Rails test server running in another thread. Tests can request the fixture to
726
+ make the side effect explicit:
672
727
 
673
728
  ```ruby
674
729
  test("checkout succeeds") do |payment_gateway_stub:|
@@ -680,20 +735,22 @@ Use `simple_stub(Time, :now) { fixed_time }` for singleton methods such as class
680
735
  methods.
681
736
 
682
737
  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.
738
+ constants in test bodies, `around_test`, or `around_suite`.
739
+
740
+ Smartest stubs are process-wide state. They are intended for serial test
741
+ execution and for cases like a Rails test server thread serving the current
742
+ test. They do not provide isolation for multi-threaded parallel test execution:
743
+ one test can observe or reset another test's method or constant stub.
685
744
 
686
745
  The method stub helpers call `Smartest::SimpleStub` internally, apply the stub,
687
746
  register `on_teardown { stub.reset }`, and return the stub object.
688
747
  `with_stub_const` records the previous constant value, replaces it, yields to
689
748
  the block, and restores or removes the constant with `ensure`.
690
749
 
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).
750
+ `Smartest::SimpleStub#apply` raises
751
+ `Smartest::SimpleStub::AlreadyAppliedError` when the same stub object is already
752
+ applied. `#reset` raises `Smartest::SimpleStub::NotAppliedError` when that stub
753
+ object is not applied. See [Stubs](documentation/docs/stubs.md).
697
754
 
698
755
  ## Logged-in client example
699
756
 
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,242 @@
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 RailsSystemTestFixture < 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(
22
+ app: Rails.application,
23
+ host: ENV["SMARTEST_RAILS_TEST_SERVER_HOST"],
24
+ port: ENV["SMARTEST_RAILS_TEST_SERVER_PORT"],
25
+ )
26
+ server.start
27
+ server.wait_for_ready
28
+
29
+ on_teardown do
30
+ server.stop
31
+ server.wait_for_stopped
32
+ end
33
+
34
+ server
35
+ end
36
+
37
+ suite_fixture :base_url do |rails_server:|
38
+ ENV.fetch("SMARTEST_RAILS_BASE_URL", rails_server.base_url)
39
+ end
40
+
41
+ suite_fixture :browser do
42
+ ws_endpoint = ENV["PLAYWRIGHT_WS_ENDPOINT"]
43
+
44
+ if ws_endpoint && !ws_endpoint.empty?
45
+ playwright_execution = Playwright.connect_to_browser_server(
46
+ ws_endpoint,
47
+ browser_type: selected_browser_type.to_s,
48
+ )
49
+ on_teardown { playwright_execution.stop }
50
+
51
+ playwright_execution.browser
52
+ else
53
+ playwright_execution = Playwright.create(
54
+ playwright_cli_executable_path: ENV.fetch(
55
+ "PLAYWRIGHT_CLI_EXECUTABLE_PATH",
56
+ "./node_modules/.bin/playwright",
57
+ )
58
+ )
59
+ on_teardown { playwright_execution.stop }
60
+
61
+ playwright = playwright_execution.playwright
62
+ browser = playwright.public_send(selected_browser_type).launch(**browser_launch_options)
63
+ on_teardown { browser.close }
64
+ browser
65
+ end
66
+ end
67
+
68
+ fixture :browser_context do |base_url:, browser:|
69
+ context = browser.new_context(baseURL: base_url)
70
+ on_teardown { context.close }
71
+ context
72
+ end
73
+
74
+ fixture :page do |browser_context:|
75
+ page = browser_context.new_page
76
+ on_teardown { page.close }
77
+ page
78
+ end
79
+
80
+ private
81
+
82
+ def selected_browser_type
83
+ case ENV.fetch("BROWSER", "chromium")
84
+ when "firefox"
85
+ :firefox
86
+ when "webkit"
87
+ :webkit
88
+ else
89
+ :chromium
90
+ end
91
+ end
92
+
93
+ def browser_launch_options
94
+ launch_options = {}
95
+ launch_options[:headless] = !%w[0 false].include?(ENV.fetch("HEADLESS", "true"))
96
+ if (slow_mo = ENV.fetch("SLOW_MO", "0").to_i) > 0
97
+ launch_options[:slowMo] = slow_mo
98
+ end
99
+
100
+ launch_options
101
+ end
102
+ end
103
+ RUBY
104
+
105
+ PLAYWRIGHT_MATCHER = <<~RUBY
106
+ # frozen_string_literal: true
107
+
108
+ require "playwright"
109
+ require "playwright/test"
110
+
111
+ module PlaywrightMatcher
112
+ include Playwright::Test::Matchers
113
+ end
114
+ RUBY
115
+
116
+ EXAMPLE_RAILS_SYSTEM_TEST = <<~RUBY
117
+ # frozen_string_literal: true
118
+
119
+ require "test_helper"
120
+
121
+ test("loads the Rails application") do |page:|
122
+ response = page.goto("/")
123
+
124
+ expect(response.status).to be_between(200, 599)
125
+ end
126
+ RUBY
127
+
128
+ def initialize(root: Dir.pwd, output: $stdout, command_runner: nil)
129
+ @root = root
130
+ @output = output
131
+ @command_runner = command_runner || method(:run_system_command)
132
+ end
133
+
134
+ def run
135
+ Smartest::InitGenerator.new(
136
+ root: @root,
137
+ output: @output,
138
+ files: smartest_files,
139
+ final_message: nil
140
+ ).run
141
+ create_file("smartest/fixtures/rails_system_fixture.rb", RAILS_SYSTEM_FIXTURE)
142
+ create_file("smartest/matchers/playwright_matcher.rb", PLAYWRIGHT_MATCHER)
143
+ update_test_helper
144
+ update_gemfile
145
+ install_dependencies
146
+ @output.puts
147
+ @output.puts "Run your Rails browser test suite with: bundle exec smartest smartest/example_rails_system_test.rb"
148
+
149
+ 0
150
+ end
151
+
152
+ private
153
+
154
+ def smartest_files
155
+ Smartest::InitGenerator::FILES.merge("smartest/example_rails_system_test.rb" => EXAMPLE_RAILS_SYSTEM_TEST)
156
+ end
157
+
158
+ def create_file(path, contents)
159
+ absolute_path = File.join(@root, path)
160
+
161
+ if File.exist?(absolute_path)
162
+ @output.puts "exist #{path}"
163
+ return
164
+ end
165
+
166
+ FileUtils.mkdir_p(File.dirname(absolute_path))
167
+ File.write(absolute_path, contents)
168
+ @output.puts "create #{path}"
169
+ end
170
+
171
+ def update_test_helper
172
+ path = File.join(@root, "smartest/test_helper.rb")
173
+ contents = File.read(path)
174
+ updated = ensure_rails_registered(contents)
175
+
176
+ return if updated == contents
177
+
178
+ File.write(path, updated)
179
+ @output.puts "update smartest/test_helper.rb"
180
+ end
181
+
182
+ def ensure_rails_registered(contents)
183
+ missing_lines = []
184
+ missing_lines << " use_fixture RailsSystemTestFixture\n" unless contents.include?("use_fixture RailsSystemTestFixture")
185
+ missing_lines << " use_matcher PlaywrightMatcher\n" unless contents.include?("use_matcher PlaywrightMatcher")
186
+ return contents if missing_lines.empty?
187
+
188
+ if contents.include?("use_matcher PredicateMatcher")
189
+ contents.sub(/^(\s*use_matcher PredicateMatcher\n)/) do
190
+ "#{Regexp.last_match(1)}#{missing_lines.join}"
191
+ end
192
+ else
193
+ "#{contents.chomp}\n\naround_suite do |suite|\n#{missing_lines.join} suite.run\nend\n"
194
+ end
195
+ end
196
+
197
+ def update_gemfile
198
+ path = File.join(@root, "Gemfile")
199
+ exists = File.exist?(path)
200
+ contents = exists ? File.read(path) : "source \"https://rubygems.org\"\n"
201
+
202
+ if contents.match?(/gem ["']playwright-ruby-client["']/)
203
+ @output.puts "exist Gemfile playwright-ruby-client"
204
+ return
205
+ end
206
+
207
+ separator = contents.end_with?("\n") ? "" : "\n"
208
+ updated = "#{contents}#{separator}\ngem \"playwright-ruby-client\", group: :test\n"
209
+ File.write(path, updated)
210
+ @output.puts(exists ? "update Gemfile" : "create Gemfile")
211
+ end
212
+
213
+ def install_dependencies
214
+ install_commands.each do |command|
215
+ @output.puts "run #{command.join(" ")}"
216
+ next if @command_runner.call(command, chdir: @root)
217
+
218
+ raise "command failed: #{command.join(" ")}"
219
+ end
220
+
221
+ if skip_browser_download?
222
+ @output.puts "skip ./node_modules/.bin/playwright install (SMARTEST_SKIP_BROWSER_DOWNLOAD=1)"
223
+ end
224
+ end
225
+
226
+ def install_commands
227
+ commands = [["bundle", "install"]]
228
+ commands << ["npm", "init", "--yes"] unless File.exist?(File.join(@root, "package.json"))
229
+ commands << ["npm", "install", "playwright", "--save-dev"]
230
+ commands << ["./node_modules/.bin/playwright", "install"] unless skip_browser_download?
231
+ commands
232
+ end
233
+
234
+ def skip_browser_download?
235
+ %w[1 true].include?(ENV.fetch("SMARTEST_SKIP_BROWSER_DOWNLOAD", "false"))
236
+ end
237
+
238
+ def run_system_command(command, chdir:)
239
+ system(*command, chdir: chdir)
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,73 @@
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_READY_TIMEOUT = 10
13
+
14
+ attr_reader :host, :port
15
+
16
+ def initialize(app:, host: nil, port: nil)
17
+ @app = app
18
+ @host = host || "127.0.0.1"
19
+ @requested_port = port ? port.to_i : 0
20
+ @server = Puma::Server.new(@app)
21
+ @port = bind_tcp_listener
22
+ @thread = nil
23
+ end
24
+
25
+ def start
26
+ @thread ||= @server.run
27
+ end
28
+
29
+ def stop
30
+ @server.stop
31
+ end
32
+
33
+ def wait_for_ready(timeout: DEFAULT_READY_TIMEOUT)
34
+ Timeout.timeout(timeout) do
35
+ sleep 0.05 until responsive?
36
+ end
37
+ rescue Timeout::Error
38
+ raise "Rails test server did not become ready at #{base_url} within #{timeout} seconds"
39
+ end
40
+
41
+ def wait_for_stopped(timeout: DEFAULT_READY_TIMEOUT)
42
+ return unless @thread
43
+ return if @thread.join(timeout)
44
+
45
+ raise "Rails test server did not stop within #{timeout} seconds"
46
+ end
47
+
48
+ def base_url
49
+ "http://#{host}:#{port}"
50
+ end
51
+
52
+ private
53
+
54
+ def bind_tcp_listener
55
+ listener = @server.add_tcp_listener(@host, @requested_port)
56
+ bound_port = listener.respond_to?(:addr) ? listener.addr[1] : @requested_port
57
+
58
+ return bound_port if bound_port && bound_port.positive?
59
+
60
+ raise "Rails test server could not determine bound port"
61
+ end
62
+
63
+ def responsive?
64
+ Net::HTTP.start(host, port, open_timeout: 0.2, read_timeout: 0.2) do |http|
65
+ http.head("/")
66
+ end
67
+ true
68
+ rescue EOFError, IOError, Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
69
+ false
70
+ end
71
+ end
72
+ end
73
+ 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"
4
+ VERSION = "0.6.0"
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")
@@ -9,6 +9,8 @@ require "open3"
9
9
  require "tmpdir"
10
10
 
11
11
  module SmartestSelfTest
12
+ ENV_MISSING = Object.new.freeze
13
+
12
14
  module_function
13
15
 
14
16
  def test_case(name, block)
@@ -40,6 +42,20 @@ module SmartestSelfTest
40
42
  else
41
43
  raise Smartest::AssertionFailed, "expected #{expected_error}, but nothing was raised"
42
44
  end
45
+
46
+ def with_env(values)
47
+ previous_values = {}
48
+ values.each do |key, value|
49
+ previous_values[key] = ENV.fetch(key, ENV_MISSING)
50
+ value.nil? ? ENV.delete(key) : ENV[key] = value
51
+ end
52
+
53
+ yield
54
+ ensure
55
+ previous_values.each do |key, value|
56
+ value.equal?(ENV_MISSING) ? ENV.delete(key) : ENV[key] = value
57
+ end
58
+ end
43
59
  end
44
60
 
45
61
  class SelfTestRegisteredFixture < Smartest::Fixture
@@ -1723,6 +1739,7 @@ test("cli prints help") do
1723
1739
  expect(stdout).not_to include("Run a browser test file")
1724
1740
  expect(stdout).to include("bundle exec smartest --init")
1725
1741
  expect(stdout).to include("bundle exec smartest --init-browser")
1742
+ expect(stdout).to include("bundle exec smartest --init-rails")
1726
1743
  expect(stdout).to include("--profile N")
1727
1744
  expect(stdout).to include("smartest/**/*_test.rb")
1728
1745
  end
@@ -1741,6 +1758,7 @@ test("cli explains how to initialize when default smartest directory is missing"
1741
1758
  expect(stdout).to include("No smartest/ directory found.")
1742
1759
  expect(stdout).to include("bundle exec smartest --init")
1743
1760
  expect(stdout).to include("bundle exec smartest --init-browser")
1761
+ expect(stdout).to include("bundle exec smartest --init-rails")
1744
1762
  expect(stdout).to include("bundle exec smartest --help")
1745
1763
  end
1746
1764
  end
@@ -2198,3 +2216,274 @@ test("cli browser init generator skips npm init when package.json already exists
2198
2216
  expect(output.string).not_to include("npm init --yes")
2199
2217
  end
2200
2218
  end
2219
+
2220
+ test("smartest rails helper is loaded only by explicit require") do
2221
+ lib_path = File.expand_path("../lib", __dir__)
2222
+ stdout, stderr, status = Open3.capture3(
2223
+ { "RUBYLIB" => lib_path },
2224
+ "ruby",
2225
+ "-e",
2226
+ 'require "smartest"; puts Smartest.const_defined?(:Rails, false)'
2227
+ )
2228
+
2229
+ expect(status.success?).to eq(true)
2230
+ expect(stderr).to eq("")
2231
+ expect(stdout).to eq("false\n")
2232
+
2233
+ Dir.mktmpdir do |dir|
2234
+ File.write(File.join(dir, "puma.rb"), "module Puma\n class Server\n end\nend\n")
2235
+
2236
+ stdout, stderr, status = Open3.capture3(
2237
+ { "RUBYLIB" => "#{dir}:#{lib_path}" },
2238
+ "ruby",
2239
+ "-e",
2240
+ 'require "smartest/rails"; puts Smartest::Rails.const_defined?(:TestServer, false)'
2241
+ )
2242
+
2243
+ expect(status.success?).to eq(true)
2244
+ expect(stderr).to eq("")
2245
+ expect(stdout).to eq("true\n")
2246
+ end
2247
+ end
2248
+
2249
+ test("smartest rails test server wraps puma with explicit arguments and lifecycle") do
2250
+ lib_path = File.expand_path("../lib", __dir__)
2251
+
2252
+ Dir.mktmpdir do |dir|
2253
+ File.write(File.join(dir, "puma.rb"), <<~RUBY)
2254
+ module Puma
2255
+ class Listener
2256
+ def initialize(port)
2257
+ @port = port
2258
+ end
2259
+
2260
+ def addr
2261
+ ["AF_INET", @port]
2262
+ end
2263
+ end
2264
+
2265
+ class Server
2266
+ attr_reader :stopped
2267
+
2268
+ def initialize(app)
2269
+ @app = app
2270
+ end
2271
+
2272
+ def add_tcp_listener(_host, port)
2273
+ Listener.new(port.zero? ? 4321 : port)
2274
+ end
2275
+
2276
+ def run
2277
+ Thread.new {}
2278
+ end
2279
+
2280
+ def stop
2281
+ @stopped = true
2282
+ end
2283
+ end
2284
+ end
2285
+ RUBY
2286
+
2287
+ stdout, stderr, status = Open3.capture3(
2288
+ {
2289
+ "RUBYLIB" => "#{dir}:#{lib_path}",
2290
+ "SMARTEST_RAILS_TEST_SERVER_HOST" => "0.0.0.0",
2291
+ "SMARTEST_RAILS_TEST_SERVER_PORT" => "9876",
2292
+ "SMARTEST_RAILS_BASE_URL" => "http://app:9876"
2293
+ },
2294
+ "ruby",
2295
+ "-e",
2296
+ <<~'RUBY'
2297
+ require "smartest/rails"
2298
+
2299
+ server = Smartest::Rails::TestServer.new(app: Object.new, port: "4567")
2300
+ default_port_server = Smartest::Rails::TestServer.new(app: Object.new)
2301
+ thread = server.start
2302
+ server.stop
2303
+ server.wait_for_stopped
2304
+
2305
+ puts server.base_url
2306
+ puts default_port_server.base_url
2307
+ puts thread.is_a?(Thread)
2308
+ RUBY
2309
+ )
2310
+
2311
+ expect(status.success?).to eq(true)
2312
+ expect(stderr).to eq("")
2313
+ expect(stdout).to eq("http://127.0.0.1:4567\nhttp://127.0.0.1:4321\ntrue\n")
2314
+ end
2315
+ end
2316
+
2317
+ test("cli rails init generator creates Rails browser scaffold and installation commands") do
2318
+ Dir.mktmpdir do |dir|
2319
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
2320
+ source "https://rubygems.org"
2321
+
2322
+ gem "rails"
2323
+ gem "smartest"
2324
+ RUBY
2325
+
2326
+ commands = []
2327
+ output = StringIO.new
2328
+ generator = Smartest::InitRailsGenerator.new(
2329
+ root: dir,
2330
+ output: output,
2331
+ command_runner: ->(command, chdir:) {
2332
+ commands << [command, chdir]
2333
+ true
2334
+ }
2335
+ )
2336
+
2337
+ status = generator.run
2338
+
2339
+ expect(status).to eq(0)
2340
+ rails_fixture = File.read(File.join(dir, "smartest/fixtures/rails_system_fixture.rb"))
2341
+ expect(rails_fixture).to include("require 'smartest/rails'")
2342
+ expect(rails_fixture).to include("class RailsSystemTestFixture < Smartest::Fixture")
2343
+ expect(rails_fixture).to include("suite_fixture :rails_server")
2344
+ expect(rails_fixture).to include("server cannot boot against the development database")
2345
+ expect(rails_fixture).to include('require_relative "../../config/environment"')
2346
+ expect(rails_fixture).to include("Smartest::Rails::TestServer.new(")
2347
+ expect(rails_fixture).to include('host: ENV["SMARTEST_RAILS_TEST_SERVER_HOST"]')
2348
+ expect(rails_fixture).not_to include("bind_host:")
2349
+ expect(rails_fixture).to include('port: ENV["SMARTEST_RAILS_TEST_SERVER_PORT"]')
2350
+ expect(rails_fixture).not_to include("public_base_url")
2351
+ expect(rails_fixture).not_to include("SmartestRailsTestServer")
2352
+ expect(rails_fixture).not_to include("Puma::Server")
2353
+ expect(rails_fixture).to include("suite_fixture :base_url")
2354
+ expect(rails_fixture).to include('ENV.fetch("SMARTEST_RAILS_BASE_URL", rails_server.base_url)')
2355
+ expect(rails_fixture).not_to include("rails_test_server_port")
2356
+ expect(rails_fixture).not_to include("SMARTEST_RAILS_PORT")
2357
+ expect(rails_fixture).to include('ws_endpoint = ENV["PLAYWRIGHT_WS_ENDPOINT"]')
2358
+ expect(rails_fixture).not_to include("suite_fixture :playwright_execution")
2359
+ expect(rails_fixture).not_to include("suite_fixture :playwright do")
2360
+ expect(rails_fixture).not_to include("Playwright.connect_to_playwright_server")
2361
+ expect(rails_fixture).not_to include("rescue NotImplementedError")
2362
+ expect(rails_fixture).to include("playwright_execution = Playwright.connect_to_browser_server(")
2363
+ expect(rails_fixture).to include("browser_type: selected_browser_type.to_s")
2364
+ expect(rails_fixture).to include("playwright_execution.browser")
2365
+ expect(rails_fixture).to include('ENV.fetch(')
2366
+ expect(rails_fixture).to include('"PLAYWRIGHT_CLI_EXECUTABLE_PATH"')
2367
+ expect(rails_fixture).to include("suite_fixture :browser do")
2368
+ expect(rails_fixture).to include("playwright_execution = Playwright.create(")
2369
+ expect(rails_fixture).to include("on_teardown { playwright_execution.stop }")
2370
+ expect(rails_fixture).to include("playwright = playwright_execution.playwright")
2371
+ expect(rails_fixture).to include("playwright.public_send(selected_browser_type).launch(**browser_launch_options)")
2372
+ expect(rails_fixture).to include("def selected_browser_type")
2373
+ expect(rails_fixture).to include("def browser_launch_options")
2374
+ expect(rails_fixture).to include("fixture :browser_context do |base_url:, browser:|")
2375
+ expect(rails_fixture).to include("context = browser.new_context(baseURL: base_url)")
2376
+ expect(rails_fixture).to include("fixture :page do |browser_context:|")
2377
+
2378
+ example_test = File.read(File.join(dir, "smartest/example_rails_system_test.rb"))
2379
+ expect(example_test).to include('test("loads the Rails application") do |page:|')
2380
+ expect(example_test).to include('response = page.goto("/")')
2381
+ expect(example_test).to include("expect(response.status).to be_between(200, 599)")
2382
+
2383
+ helper_contents = File.read(File.join(dir, "smartest/test_helper.rb"))
2384
+ expect(helper_contents).to include("use_matcher PredicateMatcher\n use_fixture RailsSystemTestFixture\n use_matcher PlaywrightMatcher\n suite.run")
2385
+ expect(helper_contents).not_to include("Smartest::SimpleStub")
2386
+
2387
+ gemfile_contents = File.read(File.join(dir, "Gemfile"))
2388
+ expect(gemfile_contents).to include('gem "playwright-ruby-client", group: :test')
2389
+ expect(commands).to eq(
2390
+ [
2391
+ [["bundle", "install"], dir],
2392
+ [["npm", "init", "--yes"], dir],
2393
+ [["npm", "install", "playwright", "--save-dev"], dir],
2394
+ [["./node_modules/.bin/playwright", "install"], dir]
2395
+ ]
2396
+ )
2397
+ expect(output.string).to include("Run your Rails browser test suite with: bundle exec smartest smartest/example_rails_system_test.rb")
2398
+ end
2399
+ end
2400
+
2401
+ test("cli rails init generator skips browser install when requested by environment") do
2402
+ SmartestSelfTest.with_env("SMARTEST_SKIP_BROWSER_DOWNLOAD" => "1") do
2403
+ Dir.mktmpdir do |dir|
2404
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
2405
+ source "https://rubygems.org"
2406
+
2407
+ gem "rails"
2408
+ gem "smartest"
2409
+ RUBY
2410
+
2411
+ commands = []
2412
+ output = StringIO.new
2413
+ generator = Smartest::InitRailsGenerator.new(
2414
+ root: dir,
2415
+ output: output,
2416
+ command_runner: ->(command, chdir:) {
2417
+ commands << [command, chdir]
2418
+ true
2419
+ }
2420
+ )
2421
+
2422
+ status = generator.run
2423
+
2424
+ expect(status).to eq(0)
2425
+ expect(commands).to eq(
2426
+ [
2427
+ [["bundle", "install"], dir],
2428
+ [["npm", "init", "--yes"], dir],
2429
+ [["npm", "install", "playwright", "--save-dev"], dir]
2430
+ ]
2431
+ )
2432
+ expect(output.string).to include("skip ./node_modules/.bin/playwright install (SMARTEST_SKIP_BROWSER_DOWNLOAD=1)")
2433
+ expect(File.exist?(File.join(dir, "smartest/fixtures/rails_system_fixture.rb"))).to eq(true)
2434
+ expect(File.exist?(File.join(dir, "smartest/example_rails_system_test.rb"))).to eq(true)
2435
+ end
2436
+ end
2437
+ end
2438
+
2439
+ test("cli rails init generator skips duplicate registration and dependencies") do
2440
+ Dir.mktmpdir do |dir|
2441
+ FileUtils.mkdir_p(File.join(dir, "smartest/fixtures"))
2442
+ FileUtils.mkdir_p(File.join(dir, "smartest/matchers"))
2443
+ File.write(File.join(dir, "package.json"), "{\n \"name\": \"existing\"\n}\n")
2444
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
2445
+ source "https://rubygems.org"
2446
+
2447
+ gem "smartest"
2448
+ gem "playwright-ruby-client", group: :test
2449
+ RUBY
2450
+ File.write(File.join(dir, "smartest/test_helper.rb"), <<~RUBY)
2451
+ require "smartest/autorun"
2452
+
2453
+ around_suite do |suite|
2454
+ use_matcher PredicateMatcher
2455
+ use_fixture RailsSystemTestFixture
2456
+ use_matcher PlaywrightMatcher
2457
+ suite.run
2458
+ end
2459
+ RUBY
2460
+
2461
+ commands = []
2462
+ output = StringIO.new
2463
+ generator = Smartest::InitRailsGenerator.new(
2464
+ root: dir,
2465
+ output: output,
2466
+ command_runner: ->(command, chdir:) {
2467
+ commands << [command, chdir]
2468
+ true
2469
+ }
2470
+ )
2471
+
2472
+ status = generator.run
2473
+
2474
+ expect(status).to eq(0)
2475
+ helper_contents = File.read(File.join(dir, "smartest/test_helper.rb"))
2476
+ expect(helper_contents.scan("use_fixture RailsSystemTestFixture").length).to eq(1)
2477
+ expect(helper_contents.scan("use_matcher PlaywrightMatcher").length).to eq(1)
2478
+ expect(helper_contents).not_to include("Smartest::SimpleStub")
2479
+ expect(commands).to eq(
2480
+ [
2481
+ [["bundle", "install"], dir],
2482
+ [["npm", "install", "playwright", "--save-dev"], dir],
2483
+ [["./node_modules/.bin/playwright", "install"], dir]
2484
+ ]
2485
+ )
2486
+ expect(output.string).to include("exist Gemfile playwright-ruby-client")
2487
+ expect(output.string).not_to include("npm init --yes")
2488
+ end
2489
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smartest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Iwaki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-07 00:00:00.000000000 Z
11
+ date: 2026-05-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -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