diff --git a/.gitignore b/.gitignore index 9aee9436a..674eafd22 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,7 @@ ssr-generated # Claude Code local settings .claude/settings.local.json + +# Playwright test artifacts (from cypress-on-rails gem) +/spec/dummy/e2e/playwright-report/ +/spec/dummy/test-results/ diff --git a/CLAUDE.md b/CLAUDE.md index 749b7e1dc..2da736006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ Git hooks will automatically run linting on **all changed files (staged + unstag - **Run tests**: - Ruby tests: `rake run_rspec` - JavaScript tests: `yarn run test` or `rake js_tests` + - Playwright E2E tests: See Playwright section below - All tests: `rake` (default task runs lint and all tests except examples) - **Linting** (MANDATORY BEFORE EVERY COMMIT): - **REQUIRED**: `bundle exec rubocop` - Must pass with zero offenses @@ -126,6 +127,135 @@ This project maintains both a Ruby gem and an NPM package: - Generated examples are in `gen-examples/` (ignored by git) - Only use `yarn` as the JS package manager, never `npm` +## Playwright E2E Testing + +### Overview +Playwright E2E testing is integrated via the `cypress-on-rails` gem (v1.19+), which provides seamless integration between Playwright and Rails. This allows you to control Rails application state during tests, use factory_bot, and more. + +### Setup +The gem and Playwright are already configured. To install Playwright browsers: + +```bash +cd spec/dummy +yarn playwright install --with-deps +``` + +### Running Playwright Tests + +```bash +cd spec/dummy + +# Run all tests +yarn playwright test + +# Run tests in UI mode (interactive debugging) +yarn playwright test --ui + +# Run tests with visible browser +yarn playwright test --headed + +# Debug a specific test +yarn playwright test --debug + +# Run specific test file +yarn playwright test e2e/playwright/e2e/react_on_rails/basic_components.spec.js +``` + +### Writing Tests + +Tests are located in `spec/dummy/e2e/playwright/e2e/`. The gem provides helpful commands for Rails integration: + +```javascript +import { test, expect } from "@playwright/test"; +import { app, appEval, appFactories } from '../../support/on-rails'; + +test.describe("My React Component", () => { + test.beforeEach(async ({ page }) => { + // Clean database before each test + await app('clean'); + }); + + test("should interact with component", async ({ page }) => { + // Create test data using factory_bot + await appFactories([['create', 'user', { name: 'Test User' }]]); + + // Or run arbitrary Ruby code + await appEval('User.create!(email: "test@example.com")'); + + // Navigate and test + await page.goto("/"); + const component = page.locator('#MyComponent-react-component-0'); + await expect(component).toBeVisible(); + }); +}); +``` + +### Available Rails Helpers + +The `cypress-on-rails` gem provides these helpers (imported from `support/on-rails.js`): + +- `app('clean')` - Clean database +- `appEval(code)` - Run arbitrary Ruby code +- `appFactories(options)` - Create records via factory_bot +- `appScenario(name)` - Load predefined scenario +- See `e2e/playwright/app_commands/` for available commands + +### Creating App Commands + +Add custom commands in `e2e/playwright/app_commands/`: + +```ruby +# e2e/playwright/app_commands/my_command.rb +CypressOnRails::SmartFactoryWrapper.configure( + always_reload: !Rails.configuration.cache_classes, + factory: :factory_bot, + dir: "{#{FactoryBot.definition_file_paths.join(',')}}" +) + +command 'my_command' do |options| + # Your custom Rails code + { success: true, data: options } +end +``` + +### Test Organization + +``` +spec/dummy/e2e/ +├── playwright.config.js # Playwright configuration +├── playwright/ +│ ├── support/ +│ │ ├── index.js # Test setup +│ │ └── on-rails.js # Rails helper functions +│ ├── e2e/ +│ │ ├── react_on_rails/ # React on Rails specific tests +│ │ │ └── basic_components.spec.js +│ │ └── rails_examples/ # Example tests +│ │ └── using_scenarios.spec.js +│ └── app_commands/ # Rails helper commands +│ ├── clean.rb +│ ├── factory_bot.rb +│ ├── eval.rb +│ └── scenarios/ +│ └── basic.rb +``` + +### Best Practices + +- Use `app('clean')` in `beforeEach` to ensure clean state +- Leverage Rails helpers (`appFactories`, `appEval`) instead of UI setup +- Test React on Rails specific features: SSR, hydration, component registry +- Use component IDs like `#ComponentName-react-component-0` for selectors +- Monitor console errors during tests +- Test across different browsers with `--project` flag + +### Debugging + +- Run in UI mode: `yarn playwright test --ui` +- Use `page.pause()` to pause execution +- Check `playwright-report/` for detailed results after test failures +- Enable debug logging in `playwright.config.js` + ## IDE Configuration Exclude these directories to prevent IDE slowdowns: @@ -133,3 +263,4 @@ Exclude these directories to prevent IDE slowdowns: - `/coverage`, `/tmp`, `/gen-examples`, `/packages/react-on-rails/lib` - `/node_modules`, `/spec/dummy/node_modules`, `/spec/dummy/tmp` - `/spec/dummy/app/assets/webpack`, `/spec/dummy/log` +- `/spec/dummy/e2e/playwright-report`, `/spec/dummy/test-results` diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies index e11cd21c9..0d5317fa5 100644 --- a/Gemfile.development_dependencies +++ b/Gemfile.development_dependencies @@ -49,6 +49,7 @@ group :test do gem "capybara" gem "capybara-screenshot" gem "coveralls", require: false + gem "cypress-on-rails", "~> 1.19" gem "equivalent-xml" gem "generator_spec" gem "launchy" diff --git a/Gemfile.lock b/Gemfile.lock index 5fe05932b..c5c89619c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,8 @@ GEM thor (>= 0.19.4, < 2.0) tins (~> 1.6) crass (1.0.6) + cypress-on-rails (1.19.0) + rack date (3.3.4) debug (1.9.2) irb (~> 1.10) @@ -410,6 +412,7 @@ DEPENDENCIES capybara capybara-screenshot coveralls + cypress-on-rails (~> 1.19) debug equivalent-xml gem-release diff --git a/package.json b/package.json index f9392fbf5..364e8bc3d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.27.1", "@eslint/compat": "^1.2.7", + "@playwright/test": "^1.55.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock index 764172090..8a370a57a 100644 --- a/spec/dummy/Gemfile.lock +++ b/spec/dummy/Gemfile.lock @@ -122,6 +122,8 @@ GEM thor (>= 0.19.4, < 2.0) tins (~> 1.6) crass (1.0.6) + cypress-on-rails (1.19.0) + rack date (3.4.1) debug (1.9.2) irb (~> 1.10) @@ -412,6 +414,7 @@ DEPENDENCIES capybara capybara-screenshot coveralls + cypress-on-rails (~> 1.19) debug equivalent-xml generator_spec diff --git a/spec/dummy/config/initializers/cypress_on_rails.rb b/spec/dummy/config/initializers/cypress_on_rails.rb new file mode 100644 index 000000000..dbae9d22c --- /dev/null +++ b/spec/dummy/config/initializers/cypress_on_rails.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +if defined?(CypressOnRails) + CypressOnRails.configure do |c| + c.api_prefix = "" + c.install_folder = File.expand_path("#{__dir__}/../../e2e/playwright") + # WARNING!! CypressOnRails can execute arbitrary ruby code + # please use with extra caution if enabling on hosted servers or starting your local server on 0.0.0.0 + c.use_middleware = !Rails.env.production? + # c.use_vcr_middleware = !Rails.env.production? + # # Use this if you want to use use_cassette wrapper instead of manual insert/eject + # # c.use_vcr_use_cassette_middleware = !Rails.env.production? + # # Pass custom VCR options + # c.vcr_options = { + # hook_into: :webmock, + # default_cassette_options: { record: :once }, + # cassette_library_dir: File.expand_path("#{__dir__}/../../e2e/playwright/fixtures/vcr_cassettes") + # } + 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. + # Return nil to continue through the Cypress command. Return a response [status, header, body] to halt. + # c.before_request = lambda { |request| + # unless request.env['warden'].authenticate(:secret_key) + # return [403, {}, ["forbidden"]] + # end + # } + end + + # # if you compile your asssets on CI + # if ENV['CYPRESS'].present? && ENV['CI'].present? + # Rails.application.configure do + # config.assets.compile = false + # config.assets.unknown_asset_fallback = false + # end + # end +end diff --git a/spec/dummy/e2e/README.md b/spec/dummy/e2e/README.md new file mode 100644 index 000000000..2e9ba4ca8 --- /dev/null +++ b/spec/dummy/e2e/README.md @@ -0,0 +1,202 @@ +# Playwright E2E Tests for React on Rails + +This directory contains end-to-end tests using Playwright integrated with Rails via the `cypress-on-rails` gem. + +## Quick Start + +```bash +# Install Playwright browsers (first time only) +yarn playwright install --with-deps + +# Run all tests +yarn playwright test + +# Run in UI mode for debugging +yarn playwright test --ui +``` + +## Features + +The `cypress-on-rails` gem provides seamless integration between Playwright and Rails: + +- **Database Control**: Clean/reset database between tests +- **Factory Bot Integration**: Create test data easily +- **Run Ruby Code**: Execute arbitrary Ruby code from tests +- **Scenarios**: Load predefined application states +- **No UI Setup Needed**: Set up test data via Rails instead of clicking through UI + +## Test Organization + +``` +e2e/ +├── playwright.config.js # Playwright configuration +└── playwright/ + ├── support/ + │ ├── index.js # Test setup + │ └── on-rails.js # Rails helper functions + ├── e2e/ + │ ├── react_on_rails/ # React on Rails tests + │ │ └── basic_components.spec.js + │ └── rails_examples/ # Example tests + │ └── using_scenarios.spec.js + └── app_commands/ # Rails commands callable from tests + ├── clean.rb # Database cleanup + ├── factory_bot.rb # Factory bot integration + ├── eval.rb # Run arbitrary Ruby + └── scenarios/ + └── basic.rb # Predefined scenarios +``` + +## Writing Tests + +### Basic Test Structure + +```javascript +import { test, expect } from '@playwright/test'; +import { app } from '../../support/on-rails'; + +test.describe('My Feature', () => { + test.beforeEach(async ({ page }) => { + // Clean database before each test + await app('clean'); + }); + + test('should do something', async ({ page }) => { + await page.goto('/'); + // Your test code here + }); +}); +``` + +### Using Rails Helpers + +```javascript +import { app, appEval, appFactories, appScenario } from '../../support/on-rails'; + +// Clean database +await app('clean'); + +// Run arbitrary Ruby code +await appEval('User.create!(email: "test@example.com")'); + +// Use factory_bot +await appFactories([ + ['create', 'user', { name: 'Test User' }], + ['create_list', 'post', 3], +]); + +// Load a predefined scenario +await appScenario('basic'); +``` + +### Testing React on Rails Components + +```javascript +test('should interact with React component', async ({ page }) => { + await page.goto('/'); + + // Target component by ID (React on Rails naming convention) + const component = page.locator('#HelloWorld-react-component-1'); + await expect(component).toBeVisible(); + + // Test interactivity + const input = component.locator('input'); + await input.fill('New Value'); + + const heading = component.locator('h3'); + await expect(heading).toContainText('New Value'); +}); +``` + +### Testing Server-Side Rendering + +```javascript +test('should have server-rendered content', async ({ page }) => { + // Disable JavaScript to verify SSR + await page.route('**/*.js', (route) => route.abort()); + await page.goto('/'); + + // Component should still be visible + const component = page.locator('#ReduxApp-react-component-0'); + await expect(component).toBeVisible(); +}); +``` + +## Available Commands + +### Default Commands (in `app_commands/`) + +- `clean` - Clean/reset database +- `eval` - Run arbitrary Ruby code +- `factory_bot` - Create records via factory_bot +- `scenarios/{name}` - Load predefined scenario + +### Custom Commands + +Create new commands in `playwright/app_commands/`: + +```ruby +# app_commands/my_command.rb +command 'my_command' do |options| + # Your Rails code here + { success: true, data: options } +end +``` + +Use in tests: + +```javascript +await app('my_command', { some: 'options' }); +``` + +## Running Tests + +```bash +# All tests +yarn playwright test + +# Specific file +yarn playwright test e2e/playwright/e2e/react_on_rails/basic_components.spec.js + +# UI mode (interactive) +yarn playwright test --ui + +# Headed mode (visible browser) +yarn playwright test --headed + +# Debug mode +yarn playwright test --debug + +# Specific browser +yarn playwright test --project=chromium +yarn playwright test --project=firefox +yarn playwright test --project=webkit + +# View last run report +yarn playwright show-report +``` + +## Debugging + +1. **UI Mode**: `yarn playwright test --ui` - Best for interactive debugging +2. **Headed Mode**: `yarn playwright test --headed` - See browser actions +3. **Pause Execution**: Add `await page.pause()` in your test +4. **Console Logging**: Check browser console in headed mode +5. **Screenshots**: Automatically taken on failure +6. **Test Reports**: Check `playwright-report/` after test run + +## Best Practices + +1. **Clean State**: Always use `await app('clean')` in `beforeEach` +2. **Use Rails Helpers**: Prefer `appEval`/`appFactories` over UI setup +3. **Component Selectors**: Use React on Rails component IDs (`#ComponentName-react-component-N`) +4. **Test SSR**: Verify components work without JavaScript +5. **Test Hydration**: Ensure client-side hydration works correctly +6. **Monitor Console**: Listen for console errors during tests +7. **Scenarios for Complex Setup**: Create reusable scenarios for complex application states + +## More Information + +- [Playwright Documentation](https://playwright.dev/) +- [cypress-on-rails Gem](https://github.com/shakacode/cypress-on-rails) +- [React on Rails Testing Guide](../../CLAUDE.md#playwright-e2e-testing) diff --git a/spec/dummy/e2e/playwright.config.js b/spec/dummy/e2e/playwright.config.js new file mode 100644 index 000000000..aeb77d6d4 --- /dev/null +++ b/spec/dummy/e2e/playwright.config.js @@ -0,0 +1,78 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +module.exports = defineConfig({ + testDir: './playwright/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:5017', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/spec/dummy/e2e/playwright/app_commands/activerecord_fixtures.rb b/spec/dummy/e2e/playwright/app_commands/activerecord_fixtures.rb new file mode 100644 index 000000000..95087c941 --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/activerecord_fixtures.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# you can delete this file if you don't use Rails Test Fixtures + +fixtures_dir = command_options.try(:[], "fixtures_dir") +fixture_files = command_options.try(:[], "fixtures") + +if defined?(ActiveRecord) + require "active_record/fixtures" + + fixtures_dir ||= ActiveRecord::Tasks::DatabaseTasks.fixtures_path + fixture_files ||= Dir["#{fixtures_dir}/**/*.yml"].map { |f| f[(fixtures_dir.size + 1)..-5] } + + logger.debug "loading fixtures: { dir: #{fixtures_dir}, files: #{fixture_files} }" + ActiveRecord::FixtureSet.reset_cache + ActiveRecord::FixtureSet.create_fixtures(fixtures_dir, fixture_files) + "Fixtures Done" # this gets returned +else # this else part can be removed + logger.error "Looks like activerecord_fixtures has to be modified to suite your need" + Post.create(title: "MyCypressFixtures") + Post.create(title: "MyCypressFixtures2") + Post.create(title: "MyRailsFixtures") + Post.create(title: "MyRailsFixtures2") +end diff --git a/spec/dummy/e2e/playwright/app_commands/clean.rb b/spec/dummy/e2e/playwright/app_commands/clean.rb new file mode 100644 index 000000000..644fcf261 --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/clean.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +if defined?(DatabaseCleaner) + # cleaning the database using database_cleaner + DatabaseCleaner.strategy = :truncation + DatabaseCleaner.clean +else + logger.warn "add database_cleaner or update cypress/app_commands/clean.rb" + Post.delete_all if defined?(Post) +end + +CypressOnRails::SmartFactoryWrapper.reload + +if defined?(VCR) + VCR.eject_cassette # make sure we no cassette inserted before the next test starts + VCR.turn_off! + WebMock.disable! if defined?(WebMock) +end + +Rails.logger.info "APPCLEANED" # used by log_fail.rb diff --git a/spec/dummy/e2e/playwright/app_commands/eval.rb b/spec/dummy/e2e/playwright/app_commands/eval.rb new file mode 100644 index 000000000..e7ef2d05d --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/eval.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Kernel.eval(command_options) unless command_options.nil? diff --git a/spec/dummy/e2e/playwright/app_commands/factory_bot.rb b/spec/dummy/e2e/playwright/app_commands/factory_bot.rb new file mode 100644 index 000000000..c23524b92 --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/factory_bot.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +Array.wrap(command_options).map do |factory_options| + factory_method = factory_options.shift + begin + logger.debug "running #{factory_method}, #{factory_options}" + CypressOnRails::SmartFactoryWrapper.public_send(factory_method, *factory_options) + rescue StandardError => e + logger.error "#{e.class}: #{e.message}" + logger.error e.backtrace.join("\n") + logger.error e.record.inspect.to_s if e.is_a?(ActiveRecord::RecordInvalid) + raise e + end +end diff --git a/spec/dummy/e2e/playwright/app_commands/log_fail.rb b/spec/dummy/e2e/playwright/app_commands/log_fail.rb new file mode 100644 index 000000000..e9a72b0ac --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/log_fail.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +# rubocop:disable all + +# This file is called when a cypress spec fails and allows for extra logging to be captured +filename = command_options.fetch("runnable_full_title", "no title").gsub(/[^[:print:]]/, "") + +# grab last lines until "APPCLEANED" (Make sure in clean.rb to log the text "APPCLEANED") +system "tail -n 10000 -r log/#{Rails.env}.log | sed \"/APPCLEANED/ q\" | sed 'x;1!H;$!d;x' > 'log/#{filename}.log'" +# Alternative command if the above does not work +# system "tail -n 10000 log/#{Rails.env}.log | tac | sed \"/APPCLEANED/ q\" | sed 'x;1!H;$!d;x' > 'log/#{filename}.log'" + +# create a json debug file for server debugging +json_result = {} +json_result["error"] = command_options.fetch("error_message", "no error message") + +if defined?(ActiveRecord::Base) + json_result["records"] = + ActiveRecord::Base.descendants.each_with_object({}) do |record_class, records| + records[record_class.to_s] = record_class.limit(100).map(&:attributes) + rescue StandardError + end +end + +filename = command_options.fetch("runnable_full_title", "no title").gsub(/[^[:print:]]/, "") +File.open("#{Rails.root}/log/#{filename}.json", "w+") do |file| + file << JSON.pretty_generate(json_result) +end diff --git a/spec/dummy/e2e/playwright/app_commands/scenarios/basic.rb b/spec/dummy/e2e/playwright/app_commands/scenarios/basic.rb new file mode 100644 index 000000000..42ae61ee5 --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/scenarios/basic.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# You can setup your Rails state here +# MyModel.create name: 'something' +Post.create(title: "I am a Postman") diff --git a/spec/dummy/e2e/playwright/e2e/rails_examples/using_scenarios.spec.js b/spec/dummy/e2e/playwright/e2e/rails_examples/using_scenarios.spec.js new file mode 100644 index 000000000..a790bc282 --- /dev/null +++ b/spec/dummy/e2e/playwright/e2e/rails_examples/using_scenarios.spec.js @@ -0,0 +1,13 @@ +import { test } from '@playwright/test'; +import { app, appScenario } from '../../support/on-rails'; + +test.describe('Rails using scenarios examples', () => { + test.beforeEach(async () => { + await app('clean'); + }); + + test('setup basic scenario', async ({ page }) => { + await appScenario('basic'); + await page.goto('/'); + }); +}); diff --git a/spec/dummy/e2e/playwright/e2e/react_on_rails/basic_components.spec.js b/spec/dummy/e2e/playwright/e2e/react_on_rails/basic_components.spec.js new file mode 100644 index 000000000..d4119f3fb --- /dev/null +++ b/spec/dummy/e2e/playwright/e2e/react_on_rails/basic_components.spec.js @@ -0,0 +1,123 @@ +import { test, expect } from '@playwright/test'; +import { app } from '../../support/on-rails'; + +test.describe('React on Rails Basic Components', () => { + test.beforeEach(async () => { + await app('clean'); + }); + + test('should render server-side rendered React component without Redux', async ({ page }) => { + await page.goto('/'); + + // Check for HelloWorld component + const helloWorld = page.locator('#HelloWorld-react-component-1'); + await expect(helloWorld).toBeVisible(); + + // Verify it has content + const heading = helloWorld.locator('h3'); + await expect(heading).toBeVisible(); + await expect(heading).toContainText('Hello'); + }); + + test('should render server-side rendered Redux component', async ({ page }) => { + await page.goto('/'); + + // Check for server-rendered Redux component + const reduxApp = page.locator('#ReduxApp-react-component-0'); + await expect(reduxApp).toBeVisible(); + + // Verify it has content + const heading = reduxApp.locator('h3'); + await expect(heading).toBeVisible(); + }); + + test('should handle client-side interactivity in React component', async ({ page }) => { + await page.goto('/'); + + // Find the HelloWorld component + const helloWorld = page.locator('#HelloWorld-react-component-1'); + + // Find the input field and type a new name + const input = helloWorld.locator('input'); + await input.clear(); + await input.fill('Playwright Test'); + + // Verify the heading updates + const heading = helloWorld.locator('h3'); + await expect(heading).toContainText('Playwright Test'); + }); + + test('should handle Redux state changes', async ({ page }) => { + await page.goto('/'); + + // Find the Redux app component + const reduxApp = page.locator('#ReduxApp-react-component-0'); + + // Interact with the input + const input = reduxApp.locator('input'); + await input.clear(); + await input.fill('Redux with Playwright'); + + // Verify the state change is reflected + const heading = reduxApp.locator('h3'); + await expect(heading).toContainText('Redux with Playwright'); + }); + + test('should have server-rendered content in initial HTML', async ({ page }) => { + // Disable JavaScript to verify server rendering + await page.route('**/*.js', (route) => route.abort()); + await page.goto('/'); + + // Check that server-rendered components are visible even without JS + const reduxApp = page.locator('#ReduxApp-react-component-0'); + await expect(reduxApp).toBeVisible(); + + // The content should be present + const heading = reduxApp.locator('h3'); + await expect(heading).toBeVisible(); + }); + + test('should properly hydrate server-rendered components', async ({ page }) => { + await page.goto('/'); + + // Wait for hydration + await page.waitForLoadState('networkidle'); + + // Check that components are interactive after hydration + const helloWorld = page.locator('#HelloWorld-react-component-1'); + const input = helloWorld.locator('input'); + + // Should be able to interact with the input + await expect(input).toBeEnabled(); + await input.fill('Hydrated Component'); + + // Check the update works + const heading = helloWorld.locator('h3'); + await expect(heading).toContainText('Hydrated Component'); + }); + + test('should not have console errors on page load', async ({ page }) => { + const consoleErrors = []; + + // Listen for console errors + page.on('console', (message) => { + if (message.type() === 'error') { + // Filter out known non-issues + const text = message.text(); + if ( + !text.includes('Download the React DevTools') && + !text.includes('SharedArrayBuffer will require cross-origin isolation') && + !text.includes('immediate_hydration') + ) { + consoleErrors.push(text); + } + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Check that no unexpected errors occurred + expect(consoleErrors).toHaveLength(0); + }); +}); diff --git a/spec/dummy/e2e/playwright/e2e_helper.rb b/spec/dummy/e2e/playwright/e2e_helper.rb new file mode 100644 index 000000000..e8053126f --- /dev/null +++ b/spec/dummy/e2e/playwright/e2e_helper.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# This is loaded once before the first command is executed + +begin + require "database_cleaner-active_record" +rescue LoadError => e + puts e.message + begin + require "database_cleaner" + rescue LoadError => e + puts e.message + end +end + +begin + require "factory_bot_rails" +rescue LoadError => e + puts e.message + begin + require "factory_girl_rails" + rescue LoadError => e + puts e.message + end +end + +require "cypress_on_rails/smart_factory_wrapper" + +factory = CypressOnRails::SimpleRailsFactory +factory = FactoryBot if defined?(FactoryBot) +factory = FactoryGirl if defined?(FactoryGirl) + +CypressOnRails::SmartFactoryWrapper.configure( + always_reload: false, + factory: factory, + files: [ + Rails.root.join("spec", "factories.rb"), + Rails.root.join("spec", "factories", "**", "*.rb") + ] +) diff --git a/spec/dummy/e2e/playwright/support/index.js b/spec/dummy/e2e/playwright/support/index.js new file mode 100644 index 000000000..ea0043ba4 --- /dev/null +++ b/spec/dummy/e2e/playwright/support/index.js @@ -0,0 +1,21 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './on-rails'; +// import 'cypress-on-rails/support/index' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/spec/dummy/e2e/playwright/support/on-rails.js b/spec/dummy/e2e/playwright/support/on-rails.js new file mode 100644 index 000000000..9c8781493 --- /dev/null +++ b/spec/dummy/e2e/playwright/support/on-rails.js @@ -0,0 +1,41 @@ +import { request, expect } from '@playwright/test'; +import config from '../../playwright.config'; + +const contextPromise = request.newContext({ + baseURL: config.use ? config.use.baseURL : 'http://localhost:5017', +}); + +const appCommands = async (data) => { + const context = await contextPromise; + const response = await context.post('/__e2e__/command', { data }); + + expect(response.ok()).toBeTruthy(); + return response.body; +}; + +const app = (name, options = {}) => appCommands({ name, options }).then((body) => body[0]); +const appScenario = (name, options = {}) => app(`scenarios/${name}`, options); +const appEval = (code) => app('eval', code); +const appFactories = (options) => app('factory_bot', options); + +const appVcrInsertCassette = async (cassetteName, options) => { + const context = await contextPromise; + // eslint-disable-next-line no-param-reassign + if (!options) options = {}; + + // eslint-disable-next-line no-param-reassign + Object.keys(options).forEach((key) => (options[key] === undefined ? delete options[key] : {})); + const response = await context.post('/__e2e__/vcr/insert', { data: [cassetteName, options] }); + expect(response.ok()).toBeTruthy(); + return response.body; +}; + +const appVcrEjectCassette = async () => { + const context = await contextPromise; + + const response = await context.post('/__e2e__/vcr/eject'); + expect(response.ok()).toBeTruthy(); + return response.body; +}; + +export { appCommands, app, appScenario, appEval, appFactories, appVcrInsertCassette, appVcrEjectCassette }; diff --git a/spec/dummy/package.json b/spec/dummy/package.json index d21f58cf0..7e9999305 100644 --- a/spec/dummy/package.json +++ b/spec/dummy/package.json @@ -33,6 +33,7 @@ "@babel/plugin-transform-runtime": "7.17.0", "@babel/preset-env": "7", "@babel/preset-react": "^7.10.4", + "@playwright/test": "^1.55.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.1", "@rescript/react": "^0.13.0", "@types/react": "^19.0.0", @@ -46,6 +47,7 @@ "file-loader": "^6.2.0", "imports-loader": "^1.2.0", "jest": "^29.7.0", + "playwright": "^1.55.1", "react-refresh": "^0.11.0", "rescript": "^11.1.4", "sass": "^1.43.4", diff --git a/spec/dummy/yarn.lock b/spec/dummy/yarn.lock index a7970829a..5406c287f 100644 --- a/spec/dummy/yarn.lock +++ b/spec/dummy/yarn.lock @@ -1313,6 +1313,13 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== +"@playwright/test@^1.55.1": + version "1.55.1" + resolved "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz#80f775d5f948cd3ef550fcc45ef99986d3ffb36c" + integrity sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig== + dependencies: + playwright "1.55.1" + "@pmmmwh/react-refresh-webpack-plugin@^0.5.1": version "0.5.3" resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.3.tgz#b8f0e035f6df71b5c4126cb98de29f65188b9e7b" @@ -3273,6 +3280,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@^1.2.7: version "1.2.13" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" @@ -5004,6 +5016,20 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.55.1: + version "1.55.1" + resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz#5d3bb1846bc4289d364ea1a9dcb33f14545802e9" + integrity sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w== + +playwright@1.55.1, playwright@^1.55.1: + version "1.55.1" + resolved "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz#8a9954e9e61ed1ab479212af9be336888f8b3f0e" + integrity sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A== + dependencies: + playwright-core "1.55.1" + optionalDependencies: + fsevents "2.3.2" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" diff --git a/yarn.lock b/yarn.lock index 6038b342d..2fbdcd98a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1471,6 +1471,13 @@ resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz" integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@playwright/test@^1.55.1": + version "1.55.1" + resolved "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz#80f775d5f948cd3ef550fcc45ef99986d3ffb36c" + integrity sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig== + dependencies: + playwright "1.55.1" + "@publint/pack@^0.1.2": version "0.1.2" resolved "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz" @@ -3366,7 +3373,7 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: +fsevents@2.3.2, fsevents@^2.3.2: version "2.3.2" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -5111,6 +5118,20 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.55.1: + version "1.55.1" + resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz#5d3bb1846bc4289d364ea1a9dcb33f14545802e9" + integrity sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w== + +playwright@1.55.1: + version "1.55.1" + resolved "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz#8a9954e9e61ed1ab479212af9be336888f8b3f0e" + integrity sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A== + dependencies: + playwright-core "1.55.1" + optionalDependencies: + fsevents "2.3.2" + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"