diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5da2a..cde0500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,81 @@ This project adheres to [Semantic Versioning](https://semver.org/). --- +## [Unreleased] + +### Added +* **Rake tasks for test execution**: Added `cypress:open` and `cypress:run` rake tasks for seamless test execution, similar to cypress-rails functionality. Also added `playwright:open` and `playwright:run` tasks. +* **Server lifecycle hooks**: Added configuration hooks for test server management: + - `before_server_start`: Run code before Rails server starts + - `after_server_start`: Run code after Rails server is ready + - `after_transaction_start`: Run code after database transaction begins + - `after_state_reset`: Run code after application state is reset + - `before_server_stop`: Run code before Rails server stops +* **State reset endpoint**: Added `/cypress_rails_reset_state` and `/__cypress__/reset_state` endpoints for compatibility with cypress-rails +* **Transactional test mode**: Added support for automatic database transaction rollback between tests +* **Environment configuration**: Support for `CYPRESS_RAILS_HOST` and `CYPRESS_RAILS_PORT` environment variables +* **Automatic server management**: Test server automatically starts and stops with test execution + +### Migration Guide + +#### From Manual Server Management (Old Way) +If you were previously running tests manually: + +**Before (Manual Process):** +```bash +# Terminal 1: Start Rails server +CYPRESS=1 bin/rails server -p 5017 + +# Terminal 2: Run tests +yarn cypress open --project ./e2e +# or +npx cypress run --project ./e2e +``` + +**After (Automated with Rake Tasks):** +```bash +# Single command - server managed automatically! +bin/rails cypress:open +# or +bin/rails cypress:run +``` + +#### From cypress-rails Gem +If migrating from the `cypress-rails` gem: + +1. Update your Gemfile: + ```ruby + # Remove + gem 'cypress-rails' + + # Add + gem 'cypress-on-rails', '~> 1.0' + ``` + +2. Run bundle and generator: + ```bash + bundle install + rails g cypress_on_rails:install + ``` + +3. Configure hooks in `config/initializers/cypress_on_rails.rb` (optional): + ```ruby + CypressOnRails.configure do |c| + # These hooks match cypress-rails functionality + c.before_server_start = -> { DatabaseCleaner.clean } + c.after_server_start = -> { Rails.application.load_seed } + c.transactional_server = true + end + ``` + +4. Use the same commands you're familiar with: + ```bash + bin/rails cypress:open + bin/rails cypress:run + ``` + +--- + ## [1.18.0] — 2025-08-27 [Compare]: https://github.com/shakacode/cypress-playwright-on-rails/compare/v1.17.0...v1.18.0 diff --git a/README.md b/README.md index 473caff..db5a4cc 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,29 @@ Please use with extra caution if starting your local server on 0.0.0.0 or runnin Getting started on your local environment +### Using Rake Tasks (Recommended) + +The easiest way to run tests is using the provided rake tasks, which automatically manage the Rails server: + +```shell +# For Cypress +bin/rails cypress:open # Opens Cypress test runner UI +bin/rails cypress:run # Runs Cypress tests in headless mode + +# For Playwright +bin/rails playwright:open # Opens Playwright test runner UI +bin/rails playwright:run # Runs Playwright tests in headless mode +``` + +These tasks will: +- Start the Rails test server automatically +- Execute your tests +- Stop the server when done + +### Manual Server Management + +You can also manage the server manually: + ```shell # start rails CYPRESS=1 bin/rails server -p 5017 @@ -506,6 +529,44 @@ Consider VCR configuration in `cypress_helper.rb` to ignore hosts. All cassettes will be recorded and saved automatically, using the pattern `/graphql/` +## Server Hooks Configuration + +When using the rake tasks (`cypress:open`, `cypress:run`, `playwright:open`, `playwright:run`), you can configure lifecycle hooks to customize test server behavior: + +```ruby +CypressOnRails.configure do |c| + # Run code before Rails server starts + c.before_server_start = -> { + puts "Preparing test environment..." + } + + # Run code after Rails server is ready + c.after_server_start = -> { + puts "Server is ready for testing!" + } + + # Run code after database transaction begins (transactional mode only) + c.after_transaction_start = -> { + # Load seed data that should be rolled back after tests + } + + # Run code after application state is reset + c.after_state_reset = -> { + Rails.cache.clear + } + + # Run code before Rails server stops + c.before_server_stop = -> { + puts "Cleaning up test environment..." + } + + # Configure server settings + c.server_host = 'localhost' # or use ENV['CYPRESS_RAILS_HOST'] + c.server_port = 3001 # or use ENV['CYPRESS_RAILS_PORT'] + c.transactional_server = true # Enable automatic transaction rollback +end +``` + ## `before_request` configuration You may perform any custom action before running a CypressOnRails command, such as authentication, or sending metrics. Please set `before_request` as part of the CypressOnRails configuration. diff --git a/lib/cypress_on_rails/configuration.rb b/lib/cypress_on_rails/configuration.rb index 9ff0eda..a128052 100644 --- a/lib/cypress_on_rails/configuration.rb +++ b/lib/cypress_on_rails/configuration.rb @@ -10,6 +10,18 @@ class Configuration attr_accessor :before_request attr_accessor :logger attr_accessor :vcr_options + + # Server hooks for managing test lifecycle + attr_accessor :before_server_start + attr_accessor :after_server_start + attr_accessor :after_transaction_start + attr_accessor :after_state_reset + attr_accessor :before_server_stop + + # Server configuration + attr_accessor :server_host + attr_accessor :server_port + attr_accessor :transactional_server # Attributes for backwards compatibility def cypress_folder @@ -38,6 +50,18 @@ def reset self.before_request = -> (request) {} self.logger = Logger.new(STDOUT) self.vcr_options = {} + + # Server hooks + self.before_server_start = nil + self.after_server_start = nil + self.after_transaction_start = nil + self.after_state_reset = nil + self.before_server_stop = nil + + # Server configuration + self.server_host = ENV.fetch('CYPRESS_RAILS_HOST', 'localhost') + self.server_port = ENV.fetch('CYPRESS_RAILS_PORT', nil) + self.transactional_server = true end def tagged_logged diff --git a/lib/cypress_on_rails/railtie.rb b/lib/cypress_on_rails/railtie.rb index aaf808c..c9fe9d9 100644 --- a/lib/cypress_on_rails/railtie.rb +++ b/lib/cypress_on_rails/railtie.rb @@ -3,10 +3,17 @@ module CypressOnRails class Railtie < Rails::Railtie + rake_tasks do + load 'tasks/cypress.rake' + end initializer :setup_cypress_middleware, after: :load_config_initializers do |app| if CypressOnRails.configuration.use_middleware? require 'cypress_on_rails/middleware' app.middleware.use Middleware + + # Add state reset middleware for compatibility with cypress-rails + require 'cypress_on_rails/state_reset_middleware' + app.middleware.use StateResetMiddleware end if CypressOnRails.configuration.use_vcr_middleware? require 'cypress_on_rails/vcr/insert_eject_middleware' diff --git a/lib/cypress_on_rails/server.rb b/lib/cypress_on_rails/server.rb new file mode 100644 index 0000000..dff8817 --- /dev/null +++ b/lib/cypress_on_rails/server.rb @@ -0,0 +1,197 @@ +require 'socket' +require 'timeout' +require 'fileutils' +require 'cypress_on_rails/configuration' + +module CypressOnRails + class Server + attr_reader :host, :port, :framework, :install_folder + + def initialize(options = {}) + config = CypressOnRails.configuration + + @framework = options[:framework] || :cypress + @host = options[:host] || config.server_host + @port = options[:port] || config.server_port || find_available_port + @port = @port.to_i if @port + @install_folder = options[:install_folder] || config.install_folder || detect_install_folder + @transactional = options.fetch(:transactional, config.transactional_server) + end + + def open + start_server do + run_command(open_command, "Opening #{framework} test runner") + end + end + + def run + start_server do + result = run_command(run_command_args, "Running #{framework} tests") + exit(result ? 0 : 1) + end + end + + def init + ensure_install_folder_exists + puts "#{framework.to_s.capitalize} configuration initialized at #{install_folder}" + end + + private + + def detect_install_folder + # Check common locations for cypress/playwright installation + possible_folders = ['e2e', 'spec/e2e', 'spec/cypress', 'spec/playwright', 'cypress', 'playwright'] + folder = possible_folders.find { |f| File.exist?(File.expand_path(f)) } + folder || 'e2e' + end + + def ensure_install_folder_exists + unless File.exist?(install_folder) + puts "Creating #{install_folder} directory..." + FileUtils.mkdir_p(install_folder) + end + end + + def find_available_port + server = TCPServer.new('127.0.0.1', 0) + port = server.addr[1] + server.close + port + end + + def start_server(&block) + config = CypressOnRails.configuration + + run_hook(config.before_server_start) + + ENV['CYPRESS'] = '1' + ENV['RAILS_ENV'] = 'test' + + server_pid = spawn_server + + begin + wait_for_server + run_hook(config.after_server_start) + + puts "Rails server started on #{base_url}" + + if @transactional && defined?(ActiveRecord::Base) + ActiveRecord::Base.connection.begin_transaction(joinable: false) + run_hook(config.after_transaction_start) + end + + yield + + ensure + run_hook(config.before_server_stop) + + if @transactional && defined?(ActiveRecord::Base) + ActiveRecord::Base.connection.rollback_transaction if ActiveRecord::Base.connection.transaction_open? + end + + stop_server(server_pid) + ENV.delete('CYPRESS') + end + end + + def spawn_server + rails_args = if File.exist?('bin/rails') + ['bin/rails'] + else + ['bundle', 'exec', 'rails'] + end + + server_args = rails_args + ['server', '-p', port.to_s, '-b', host] + + puts "Starting Rails server: #{server_args.join(' ')}" + + spawn(*server_args, out: $stdout, err: $stderr) + end + + def wait_for_server(timeout = 30) + Timeout.timeout(timeout) do + loop do + begin + TCPSocket.new(host, port).close + break + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + sleep 0.1 + end + end + end + rescue Timeout::Error + raise "Rails server failed to start on #{host}:#{port} after #{timeout} seconds" + end + + def stop_server(pid) + if pid + puts "Stopping Rails server (PID: #{pid})" + Process.kill('TERM', pid) + Process.wait(pid) + end + rescue Errno::ESRCH + # Process already terminated + end + + def base_url + "http://#{host}:#{port}" + end + + def open_command + case framework + when :cypress + if command_exists?('yarn') + ['yarn', 'cypress', 'open', '--project', install_folder, '--config', "baseUrl=#{base_url}"] + elsif command_exists?('npx') + ['npx', 'cypress', 'open', '--project', install_folder, '--config', "baseUrl=#{base_url}"] + else + ['cypress', 'open', '--project', install_folder, '--config', "baseUrl=#{base_url}"] + end + when :playwright + if command_exists?('yarn') + ['yarn', 'playwright', 'test', '--ui'] + elsif command_exists?('npx') + ['npx', 'playwright', 'test', '--ui'] + else + ['playwright', 'test', '--ui'] + end + end + end + + def run_command_args + case framework + when :cypress + if command_exists?('yarn') + ['yarn', 'cypress', 'run', '--project', install_folder, '--config', "baseUrl=#{base_url}"] + elsif command_exists?('npx') + ['npx', 'cypress', 'run', '--project', install_folder, '--config', "baseUrl=#{base_url}"] + else + ['cypress', 'run', '--project', install_folder, '--config', "baseUrl=#{base_url}"] + end + when :playwright + if command_exists?('yarn') + ['yarn', 'playwright', 'test'] + elsif command_exists?('npx') + ['npx', 'playwright', 'test'] + else + ['playwright', 'test'] + end + end + end + + def run_command(command_args, description) + puts "#{description}: #{command_args.join(' ')}" + system(*command_args) + end + + def command_exists?(command) + system("which #{command} > /dev/null 2>&1") + end + + def run_hook(hook) + if hook && hook.respond_to?(:call) + hook.call + end + end + end +end \ No newline at end of file diff --git a/lib/cypress_on_rails/state_reset_middleware.rb b/lib/cypress_on_rails/state_reset_middleware.rb new file mode 100644 index 0000000..3851b1c --- /dev/null +++ b/lib/cypress_on_rails/state_reset_middleware.rb @@ -0,0 +1,58 @@ +module CypressOnRails + class StateResetMiddleware + def initialize(app) + @app = app + end + + def call(env) + if env['PATH_INFO'] == '/__cypress__/reset_state' || env['PATH_INFO'] == '/cypress_rails_reset_state' + reset_application_state + [200, { 'Content-Type' => 'text/plain' }, ['State reset completed']] + else + @app.call(env) + end + end + + private + + def reset_application_state + config = CypressOnRails.configuration + + # Default state reset actions + if defined?(DatabaseCleaner) + DatabaseCleaner.clean_with(:truncation) + elsif defined?(ActiveRecord::Base) + connection = ActiveRecord::Base.connection + + # Use disable_referential_integrity if available for safer table clearing + if connection.respond_to?(:disable_referential_integrity) + connection.disable_referential_integrity do + connection.tables.each do |table| + next if table == 'schema_migrations' || table == 'ar_internal_metadata' + connection.execute("DELETE FROM #{connection.quote_table_name(table)}") + end + end + else + # Fallback to regular deletion with proper table name quoting + connection.tables.each do |table| + next if table == 'schema_migrations' || table == 'ar_internal_metadata' + connection.execute("DELETE FROM #{connection.quote_table_name(table)}") + end + end + end + + # Clear Rails cache + Rails.cache.clear if defined?(Rails) && Rails.cache + + # Reset any class-level state + ActiveSupport::Dependencies.clear if defined?(ActiveSupport::Dependencies) + + # Run after_state_reset hook after cleanup is complete + run_hook(config.after_state_reset) + end + + def run_hook(hook) + hook.call if hook && hook.respond_to?(:call) + end + end +end \ No newline at end of file diff --git a/lib/generators/cypress_on_rails/templates/config/initializers/cypress_on_rails.rb.erb b/lib/generators/cypress_on_rails/templates/config/initializers/cypress_on_rails.rb.erb index 4f5eb8d..afc3a22 100644 --- a/lib/generators/cypress_on_rails/templates/config/initializers/cypress_on_rails.rb.erb +++ b/lib/generators/cypress_on_rails/templates/config/initializers/cypress_on_rails.rb.erb @@ -15,6 +15,18 @@ if defined?(CypressOnRails) <% unless options.experimental %># <% end %> cassette_library_dir: File.expand_path("#{__dir__}/../../<%= options.install_folder %>/<%= options.framework %>/fixtures/vcr_cassettes") <% unless options.experimental %># <% end %> } c.logger = Rails.logger + + # Server configuration for rake tasks (cypress:open, cypress:run, playwright:open, playwright:run) + # c.server_host = 'localhost' # or use ENV['CYPRESS_RAILS_HOST'] + # c.server_port = 3001 # or use ENV['CYPRESS_RAILS_PORT'] + # c.transactional_server = true # Enable automatic transaction rollback between tests + + # Server lifecycle hooks for rake tasks + # c.before_server_start = -> { DatabaseCleaner.clean_with(:truncation) } + # c.after_server_start = -> { puts "Test server started on port #{CypressOnRails.configuration.server_port}" } + # c.after_transaction_start = -> { Rails.application.load_seed } + # c.after_state_reset = -> { Rails.cache.clear } + # c.before_server_stop = -> { puts "Stopping test server..." } # If you want to enable a before_request logic, such as authentication, logging, sending metrics, etc. # Refer to https://www.rubydoc.info/gems/rack/Rack/Request for the `request` argument. diff --git a/lib/tasks/cypress.rake b/lib/tasks/cypress.rake new file mode 100644 index 0000000..6e7bc5e --- /dev/null +++ b/lib/tasks/cypress.rake @@ -0,0 +1,33 @@ +namespace :cypress do + desc "Open Cypress test runner UI" + task :open => :environment do + require 'cypress_on_rails/server' + CypressOnRails::Server.new.open + end + + desc "Run Cypress tests in headless mode" + task :run => :environment do + require 'cypress_on_rails/server' + CypressOnRails::Server.new.run + end + + desc "Initialize Cypress configuration" + task :init => :environment do + require 'cypress_on_rails/server' + CypressOnRails::Server.new.init + end +end + +namespace :playwright do + desc "Open Playwright test runner UI" + task :open => :environment do + require 'cypress_on_rails/server' + CypressOnRails::Server.new(framework: :playwright).open + end + + desc "Run Playwright tests in headless mode" + task :run => :environment do + require 'cypress_on_rails/server' + CypressOnRails::Server.new(framework: :playwright).run + end +end \ No newline at end of file