diff --git a/.github/workflows/lint-js-and-ruby.yml b/.github/workflows/lint-js-and-ruby.yml index 7616b20ea9..a4138195ac 100644 --- a/.github/workflows/lint-js-and-ruby.yml +++ b/.github/workflows/lint-js-and-ruby.yml @@ -107,8 +107,8 @@ jobs: run: cd spec/dummy && RAILS_ENV="test" bundle exec rake react_on_rails:generate_packs - name: Detect dead code run: | - yarn run knip - yarn run knip --production + yarn run knip --exclude binaries + yarn run knip --production --exclude binaries - name: Lint JS run: yarn run eslint --report-unused-disable-directives - name: Check formatting diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..ddab0abce0 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,60 @@ +name: Playwright E2E Tests + +on: + push: + branches: [master] + workflow_dispatch: + +jobs: + playwright: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install yalc globally + run: yarn global add yalc + + - name: Install root dependencies + run: yarn install + + - name: Install dummy app dependencies + working-directory: spec/dummy + run: | + bundle install + yarn install + + - name: Install Playwright browsers + working-directory: spec/dummy + run: yarn playwright install --with-deps + + - name: Generate React on Rails packs + working-directory: spec/dummy + env: + RAILS_ENV: test + run: bundle exec rake react_on_rails:generate_packs + + - name: Build test assets + working-directory: spec/dummy + run: yarn build:test + + - name: Run Playwright tests + working-directory: spec/dummy + run: yarn test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: spec/dummy/e2e/playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index e20bdba3b9..3f63eaf013 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ ssr-generated # Claude Code local settings .claude/settings.local.json .claude/.fuse_hidden* + +# 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 90c1825575..c22696b637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ Pre-commit hooks automatically run: - **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 @@ -233,6 +234,148 @@ rm debug-*.js - 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 + +**Note:** Playwright will automatically start the Rails server on port 5017 before running tests. You don't need to manually start the server. + +```bash +cd spec/dummy + +# Run all tests (Rails server auto-starts) +yarn test:e2e + +# Run tests in UI mode (interactive debugging) +yarn test:e2e:ui + +# Run tests with visible browser +yarn test:e2e:headed + +# Debug a specific test +yarn test:e2e:debug + +# View test report +yarn test:e2e:report + +# Run specific test file +yarn test:e2e 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 test:e2e:ui` +- Use `page.pause()` to pause execution +- Check `playwright-report/` for detailed results after test failures +- Enable debug logging in `playwright.config.js` + +### CI Integration + +Playwright E2E tests run automatically in CI via GitHub Actions (`.github/workflows/playwright.yml`). The workflow: +- Runs on all PRs and pushes to master +- Uses GitHub Actions annotations for test failures +- Uploads HTML reports as artifacts (available for 30 days) +- Auto-starts Rails server before running tests + ## IDE Configuration Exclude these directories to prevent IDE slowdowns: @@ -240,3 +383,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 a16a1c084a..aaaa239d18 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 7f475c71dd..2e359a4ac1 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) @@ -418,6 +420,7 @@ DEPENDENCIES capybara capybara-screenshot coveralls + cypress-on-rails (~> 1.19) debug equivalent-xml gem-release diff --git a/bin/lefthook/eslint-lint b/bin/lefthook/eslint-lint index 674fddfadf..700325e453 100755 --- a/bin/lefthook/eslint-lint +++ b/bin/lefthook/eslint-lint @@ -31,7 +31,7 @@ if [ -n "$root_and_packages_pro_files" ]; then fi printf " %s\n" $root_and_packages_pro_files - if ! yarn run eslint $root_and_packages_pro_files --report-unused-disable-directives --fix; then + if ! yarn run eslint $root_and_packages_pro_files --fix; then exit_code=1 fi @@ -53,7 +53,7 @@ if [ -n "$react_on_rails_pro_files" ]; then # Strip react_on_rails_pro/ prefix for running in Pro directory react_on_rails_pro_files_relative=$(echo "$react_on_rails_pro_files" | sed 's|^react_on_rails_pro/||') - if ! (cd react_on_rails_pro && yarn run eslint $react_on_rails_pro_files_relative --report-unused-disable-directives --fix); then + if ! (cd react_on_rails_pro && yarn run eslint $react_on_rails_pro_files_relative --fix); then exit_code=1 fi diff --git a/eslint.config.ts b/eslint.config.ts index 20a11aaa9b..550f4ccf84 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -234,6 +234,10 @@ const config = tsEslint.config([ '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', + // Allow deprecated React APIs for backward compatibility with React < 18 + '@typescript-eslint/no-deprecated': 'off', + // Allow unbound methods - needed for method reassignment patterns + '@typescript-eslint/unbound-method': 'off', }, }, { diff --git a/knip.ts b/knip.ts index a44683ee68..d7047863b6 100644 --- a/knip.ts +++ b/knip.ts @@ -102,8 +102,17 @@ const config: KnipConfig = { 'config/webpack/webpack.config.js', // SWC configuration for Shakapacker 'config/swc.config.js', + // Playwright E2E test configuration and tests + 'e2e/playwright.config.js', + 'e2e/playwright/e2e/**/*.spec.js', + // CI workflow files that reference package.json scripts + '../../.github/workflows/playwright.yml', + ], + ignore: [ + '**/app-react16/**/*', + // Playwright support files and helpers - generated by cypress-on-rails gem + 'e2e/playwright/support/**', ], - ignore: ['**/app-react16/**/*'], project: ['**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}!', 'config/webpack/*.js'], paths: { 'Assets/*': ['client/app/assets/*'], @@ -127,10 +136,13 @@ const config: KnipConfig = { 'node-libs-browser', // The below dependencies are not detected by the Webpack plugin // due to the config issue. + 'css-loader', 'expose-loader', 'file-loader', 'imports-loader', 'null-loader', + 'sass', + 'sass-loader', 'sass-resources-loader', 'style-loader', 'url-loader', diff --git a/packages/react-on-rails-pro/src/ClientSideRenderer.ts b/packages/react-on-rails-pro/src/ClientSideRenderer.ts index 334184e64a..06da1b0733 100644 --- a/packages/react-on-rails-pro/src/ClientSideRenderer.ts +++ b/packages/react-on-rails-pro/src/ClientSideRenderer.ts @@ -188,7 +188,6 @@ You should return a React.Component always for the client side entry point.`); } try { - // eslint-disable-next-line @typescript-eslint/no-deprecated unmountComponentAtNode(domNode); } catch (e: unknown) { const error = e instanceof Error ? e : new Error('Unknown error'); diff --git a/packages/react-on-rails-pro/src/createReactOnRailsPro.ts b/packages/react-on-rails-pro/src/createReactOnRailsPro.ts index 4c0bf488e2..28fe296411 100644 --- a/packages/react-on-rails-pro/src/createReactOnRailsPro.ts +++ b/packages/react-on-rails-pro/src/createReactOnRailsPro.ts @@ -145,13 +145,11 @@ export default function createReactOnRailsPro( if (reactOnRailsPro.streamServerRenderedReactComponent) { reactOnRailsProSpecificFunctions.streamServerRenderedReactComponent = - // eslint-disable-next-line @typescript-eslint/unbound-method reactOnRailsPro.streamServerRenderedReactComponent; } if (reactOnRailsPro.serverRenderRSCReactComponent) { reactOnRailsProSpecificFunctions.serverRenderRSCReactComponent = - // eslint-disable-next-line @typescript-eslint/unbound-method reactOnRailsPro.serverRenderRSCReactComponent; } diff --git a/spec/dummy/.gitignore b/spec/dummy/.gitignore new file mode 100644 index 0000000000..64dbbc4d9a --- /dev/null +++ b/spec/dummy/.gitignore @@ -0,0 +1 @@ +playwright-report/ diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock index 4d43f77696..1c3622c8e3 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 0000000000..dbae9d22c3 --- /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 0000000000..72f1f1a84b --- /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 test:e2e + +# Run in UI mode for debugging +yarn test:e2e: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 test:e2e + +# Specific file +yarn test:e2e e2e/playwright/e2e/react_on_rails/basic_components.spec.js + +# UI mode (interactive) +yarn test:e2e:ui + +# Headed mode (visible browser) +yarn test:e2e:headed + +# Debug mode +yarn test:e2e:debug + +# Specific browser +yarn test:e2e --project=chromium +yarn test:e2e --project=firefox +yarn test:e2e --project=webkit + +# View last run report +yarn test:e2e:report +``` + +## Debugging + +1. **UI Mode**: `yarn test:e2e:ui` - Best for interactive debugging +2. **Headed Mode**: `yarn test:e2e: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 `e2e/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 0000000000..58112f1408 --- /dev/null +++ b/spec/dummy/e2e/playwright.config.js @@ -0,0 +1,81 @@ +// @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: process.env.CI ? [['github'], ['html']] : [['html', { open: 'never' }]], + /* 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: 'RAILS_ENV=test bundle exec rails server -b 127.0.0.1 -p 5017', + url: 'http://127.0.0.1:5017', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); 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 0000000000..99d390d1f3 --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/activerecord_fixtures.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# you can delete this file if you don't use Rails Test Fixtures + +raise "ActiveRecord is not defined. Unable to load fixtures." unless defined?(ActiveRecord) + +require "active_record/fixtures" + +fixtures_dir = command_options.try(:[], "fixtures_dir") +fixture_files = command_options.try(:[], "fixtures") + +fixtures_dir ||= ActiveRecord::Tasks::DatabaseTasks.fixtures_path +fixture_files ||= Dir["#{fixtures_dir}/**/*.yml"].map { |f| f[(fixtures_dir.size + 1)..-5] } + +Rails.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 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 0000000000..0399676ee1 --- /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 + Rails.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 0000000000..79f3c31d46 --- /dev/null +++ b/spec/dummy/e2e/playwright/app_commands/eval.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +raise "eval command is only available in test environment" unless Rails.env.test? + +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 0000000000..c23524b921 --- /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 0000000000..e9a72b0ac5 --- /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 0000000000..a7bfbc6dc3 --- /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 +# This is an example scenario - customize for your app's models +# MyModel.create name: 'something' 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 0000000000..a790bc282a --- /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 0000000000..25369de443 --- /dev/null +++ b/spec/dummy/e2e/playwright/e2e/react_on_rails/basic_components.spec.js @@ -0,0 +1,125 @@ +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('/'); + await page.waitForLoadState('networkidle'); + + // 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('/'); + await page.waitForLoadState('networkidle'); + + // 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 0000000000..e8053126f2 --- /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 0000000000..61e66362f7 --- /dev/null +++ b/spec/dummy/e2e/playwright/support/index.js @@ -0,0 +1,19 @@ +// *********************************************************** +// 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 Playwright. +// +// This file is automatically loaded before test execution +// to set up global hooks, fixtures, and helper functions. +// +// You can read more here: +// https://playwright.dev/docs/test-fixtures +// *********************************************************** + +// Import Playwright-Rails helper functions: +import './on-rails'; + +// Alternatively you can use CommonJS syntax: +// require('./on-rails') 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 0000000000..6a59a5bd16 --- /dev/null +++ b/spec/dummy/e2e/playwright/support/on-rails.js @@ -0,0 +1,55 @@ +import { request } 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 }); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Rails command '${data.name}' failed: ${response.status()} - ${text}`); + } + + return response.json(); +}; + +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; + const normalizedOptions = options || {}; + const cleanedOptions = Object.fromEntries( + Object.entries(normalizedOptions).filter(([, value]) => value !== undefined), + ); + + const response = await context.post('/__e2e__/vcr/insert', { data: [cassetteName, cleanedOptions] }); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`VCR insert cassette '${cassetteName}' failed: ${response.status()} - ${text}`); + } + + return response.json(); +}; + +const appVcrEjectCassette = async () => { + const context = await contextPromise; + + const response = await context.post('/__e2e__/vcr/eject'); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`VCR eject cassette failed: ${response.status()} - ${text}`); + } + + return response.json(); +}; + +export { appCommands, app, appScenario, appEval, appFactories, appVcrInsertCassette, appVcrEjectCassette }; diff --git a/spec/dummy/package.json b/spec/dummy/package.json index 958cd2df5c..8aaf22f7f6 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", @@ -71,6 +73,11 @@ "format": "cd ../.. && yarn run nps format", "test:js": "yarn run jest ./tests", "test": "yarn run build:test && yarn run lint && bin/rspec", + "test:e2e": "playwright test --config=e2e/playwright.config.js", + "test:e2e:ui": "playwright test --config=e2e/playwright.config.js --ui", + "test:e2e:headed": "playwright test --config=e2e/playwright.config.js --headed", + "test:e2e:debug": "playwright test --config=e2e/playwright.config.js --debug", + "test:e2e:report": "playwright show-report e2e/playwright-report", "build:test": "rm -rf public/webpack/test && yarn build:rescript && RAILS_ENV=test NODE_ENV=test bin/shakapacker", "build:dev": "rm -rf public/webpack/development && yarn build:rescript && RAILS_ENV=development NODE_ENV=development bin/shakapacker", "build:dev:server": "rm -rf public/webpack/development && yarn build:rescript && RAILS_ENV=development NODE_ENV=development bin/shakapacker --watch", diff --git a/spec/dummy/yarn.lock b/spec/dummy/yarn.lock index 861988c666..34fd693a73 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" @@ -3283,6 +3290,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" @@ -4982,6 +4994,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"