diff --git a/CHANGELOG.md b/CHANGELOG.md index cde0500..d8afc69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ If migrating from the `cypress-rails` gem: ```ruby # Remove gem 'cypress-rails' - + # Add gem 'cypress-on-rails', '~> 1.0' ``` diff --git a/README.md b/README.md index db5a4cc..064c0e2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,28 @@ Consider first learning the basics of Playwright before attempting to integrate * [Good start Here](https://playwright.dev/docs/writing-tests) +## Quick Start + +```bash +# 1. Add to Gemfile +gem 'cypress-on-rails', '~> 1.0' + +# 2. Install and generate +bundle install +bin/rails g cypress_on_rails:install + +# 3. Run tests (new rake tasks!) +bin/rails cypress:open # Open Cypress UI +bin/rails cypress:run # Run headless +``` + +For Playwright: +```bash +bin/rails g cypress_on_rails:install --framework playwright +bin/rails playwright:open # Open Playwright UI +bin/rails playwright:run # Run headless +``` + ## Overview Gem for using [cypress.io](http://github.com/cypress-io/) or [playwright.dev](https://playwright.dev/) in Rails and Ruby Rack applications to control state as mentioned in [Cypress Best Practices](https://docs.cypress.io/guides/references/best-practices.html#Organizing-Tests-Logging-In-Controlling-State). @@ -64,7 +86,16 @@ Has examples of setting up state with: * scenarios * custom commands -## Resources +## Documentation + +### 📚 Essential Guides +* **[Best Practices Guide](docs/BEST_PRACTICES.md)** - Recommended patterns and practices +* **[Troubleshooting Guide](docs/TROUBLESHOOTING.md)** - Solutions to common issues +* **[Playwright Guide](docs/PLAYWRIGHT_GUIDE.md)** - Complete Playwright documentation +* **[VCR Integration Guide](docs/VCR_GUIDE.md)** - HTTP recording and mocking +* **[DX Improvements](docs/DX_IMPROVEMENTS.md)** - Recent improvements based on user feedback + +### 🎥 Resources * [Video of getting started with this gem](https://grant-ps.blog/2018/08/10/getting-started-with-cypress-io-and-ruby-on-rails/) * [Article: Introduction to Cypress on Rails](https://www.shakacode.com/blog/introduction-to-cypress-on-rails/) diff --git a/docs/BEST_PRACTICES.md b/docs/BEST_PRACTICES.md new file mode 100644 index 0000000..cdd626e --- /dev/null +++ b/docs/BEST_PRACTICES.md @@ -0,0 +1,678 @@ +# Best Practices Guide + +This guide provides recommended patterns and practices for using cypress-playwright-on-rails effectively. + +## Table of Contents +- [Project Structure](#project-structure) +- [Test Organization](#test-organization) +- [Data Management](#data-management) +- [Performance Optimization](#performance-optimization) +- [CI/CD Integration](#cicd-integration) +- [Security Considerations](#security-considerations) +- [Debugging Strategies](#debugging-strategies) +- [Common Patterns](#common-patterns) + +## Project Structure + +### Recommended Directory Layout +``` +├── e2e/ # All E2E test related files +│ ├── cypress/ # Cypress tests +│ │ ├── e2e/ # Test specs +│ │ │ ├── auth/ # Grouped by feature +│ │ │ ├── users/ +│ │ │ └── products/ +│ │ ├── fixtures/ # Test data +│ │ ├── support/ # Helpers and commands +│ │ └── downloads/ # Downloaded files during tests +│ ├── playwright/ # Playwright tests +│ │ ├── e2e/ # Test specs +│ │ └── support/ # Helpers +│ ├── app_commands/ # Shared Ruby commands +│ │ ├── scenarios/ # Complex test setups +│ │ └── helpers/ # Ruby helper modules +│ └── shared/ # Shared utilities +└── config/ + └── initializers/ + └── cypress_on_rails.rb # Configuration +``` + +### Separating Concerns +```ruby +# e2e/app_commands/helpers/test_data.rb +module TestData + def self.standard_user + { + name: 'John Doe', + email: 'john@example.com', + role: 'user' + } + end + + def self.admin_user + { + name: 'Admin User', + email: 'admin@example.com', + role: 'admin' + } + end +end + +# e2e/app_commands/scenarios/standard_setup.rb +require_relative '../helpers/test_data' + +User.create!(TestData.standard_user) +User.create!(TestData.admin_user) +``` + +## Test Organization + +### Group Related Tests +```js +// e2e/cypress/e2e/users/registration.cy.js +describe('User Registration', () => { + context('Valid Input', () => { + it('registers with email', () => { + // Test implementation + }); + + it('registers with social login', () => { + // Test implementation + }); + }); + + context('Invalid Input', () => { + it('shows error for duplicate email', () => { + // Test implementation + }); + }); +}); +``` + +### Use Page Objects +```js +// e2e/cypress/support/pages/LoginPage.js +class LoginPage { + visit() { + cy.visit('/login'); + } + + fillEmail(email) { + cy.get('[data-cy=email]').type(email); + } + + fillPassword(password) { + cy.get('[data-cy=password]').type(password); + } + + submit() { + cy.get('[data-cy=submit]').click(); + } + + login(email, password) { + this.visit(); + this.fillEmail(email); + this.fillPassword(password); + this.submit(); + } +} + +export default new LoginPage(); + +// Usage in test +import LoginPage from '../../support/pages/LoginPage'; + +it('user can login', () => { + LoginPage.login('user@example.com', 'password'); + cy.url().should('include', '/dashboard'); +}); +``` + +### Data-Test Attributes +```erb + +
+ + +
+``` + +```js +// Prefer data-cy or data-test-id over classes/IDs +cy.get('[data-cy=email-input]').type('test@example.com'); +// NOT: cy.get('#email').type('test@example.com'); +``` + +## Data Management + +### Factory Bot Best Practices +```ruby +# spec/factories/users.rb +FactoryBot.define do + factory :user do + sequence(:email) { |n| "user#{n}@example.com" } + name { Faker::Name.name } + confirmed_at { Time.current } + + trait :admin do + role { 'admin' } + end + + trait :with_posts do + transient do + posts_count { 3 } + end + + after(:create) do |user, evaluator| + create_list(:post, evaluator.posts_count, user: user) + end + end + end +end +``` + +### Scenario Patterns +```ruby +# e2e/app_commands/scenarios/e_commerce_setup.rb +class ECommerceSetup + def self.run(options = {}) + ActiveRecord::Base.transaction do + # Create categories + categories = create_categories + + # Create products + products = create_products(categories) + + # Create users + users = create_users + + # Create orders + create_orders(users, products) if options[:with_orders] + + { categories: categories, products: products, users: users } + end + end + + private + + def self.create_categories + ['Electronics', 'Clothing', 'Books'].map do |name| + Category.create!(name: name) + end + end + + def self.create_products(categories) + # Implementation + end +end + +# Usage in test +ECommerceSetup.run(with_orders: true) +``` + +### Database Cleaning Strategies +```ruby +# e2e/app_commands/clean.rb +class SmartCleaner + PRESERVE_TABLES = %w[ + schema_migrations + ar_internal_metadata + spatial_ref_sys # PostGIS + ].freeze + + def self.clean(strategy: :transaction) + case strategy + when :transaction + DatabaseCleaner.strategy = :transaction + when :truncation + DatabaseCleaner.strategy = :truncation, { + except: PRESERVE_TABLES + } + when :deletion + DatabaseCleaner.strategy = :deletion, { + except: PRESERVE_TABLES + } + end + + DatabaseCleaner.clean + + # Reset sequences + reset_sequences if postgresql? + + # Clear caches + clear_caches + end + + private + + def self.postgresql? + ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' + end + + def self.reset_sequences + ActiveRecord::Base.connection.tables.each do |table| + ActiveRecord::Base.connection.reset_pk_sequence!(table) + end + end + + def self.clear_caches + Rails.cache.clear + I18n.reload! if defined?(I18n) + end +end +``` + +## Performance Optimization + +### Minimize Database Operations +```js +// Bad: Multiple database operations +it('creates multiple users', () => { + cy.appFactories([['create', 'user', { name: 'User 1' }]]); + cy.appFactories([['create', 'user', { name: 'User 2' }]]); + cy.appFactories([['create', 'user', { name: 'User 3' }]]); +}); + +// Good: Batch operations +it('creates multiple users', () => { + cy.appFactories([ + ['create', 'user', { name: 'User 1' }], + ['create', 'user', { name: 'User 2' }], + ['create', 'user', { name: 'User 3' }] + ]); +}); + +// Better: Use create_list +it('creates multiple users', () => { + cy.appFactories([ + ['create_list', 'user', 3] + ]); +}); +``` + +### Smart Waiting Strategies +```js +// Bad: Fixed waits +cy.wait(5000); + +// Good: Wait for specific conditions +cy.get('[data-cy=loading]').should('not.exist'); +cy.get('[data-cy=user-list]').should('be.visible'); + +// Better: Wait for API calls +cy.intercept('GET', '/api/users').as('getUsers'); +cy.visit('/users'); +cy.wait('@getUsers'); +``` + +### Parallel Testing Configuration +```ruby +# config/database.yml +test: + <<: *default + database: myapp_test<%= ENV['TEST_ENV_NUMBER'] %> + +# config/initializers/cypress_on_rails.rb +if ENV['PARALLEL_WORKERS'].present? + CypressOnRails.configure do |c| + worker_id = ENV['TEST_ENV_NUMBER'] || '1' + c.server_port = 5000 + worker_id.to_i + end +end +``` + +## CI/CD Integration + +### GitHub Actions Example +```yaml +# .github/workflows/e2e.yml +name: E2E Tests +on: [push, pull_request] + +jobs: + cypress: + runs-on: ubuntu-latest + strategy: + matrix: + browser: [chrome, firefox, edge] + + services: + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: | + yarn install --frozen-lockfile + bundle install + + - name: Setup database + env: + RAILS_ENV: test + DATABASE_URL: postgresql://postgres:password@localhost:5432/test + run: | + bundle exec rails db:create + bundle exec rails db:schema:load + + - name: Run E2E tests + env: + RAILS_ENV: test + DATABASE_URL: postgresql://postgres:password@localhost:5432/test + run: | + bundle exec rails cypress:run + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + if: failure() + with: + name: cypress-artifacts-${{ matrix.browser }} + path: | + e2e/cypress/screenshots + e2e/cypress/videos +``` + +### CircleCI Configuration +```yaml +# .circleci/config.yml +version: 2.1 + +orbs: + cypress: cypress-io/cypress@2 + +jobs: + e2e-tests: + docker: + - image: cimg/ruby:3.2-browsers + - image: cimg/postgres:14.0 + environment: + POSTGRES_PASSWORD: password + + parallelism: 4 + + steps: + - checkout + + - restore_cache: + keys: + - gem-cache-{{ checksum "Gemfile.lock" }} + - yarn-cache-{{ checksum "yarn.lock" }} + + - run: + name: Install dependencies + command: | + bundle install --path vendor/bundle + yarn install --frozen-lockfile + + - save_cache: + key: gem-cache-{{ checksum "Gemfile.lock" }} + paths: + - vendor/bundle + + - save_cache: + key: yarn-cache-{{ checksum "yarn.lock" }} + paths: + - node_modules + + - run: + name: Setup database + command: | + bundle exec rails db:create db:schema:load + environment: + RAILS_ENV: test + + - run: + name: Run E2E tests + command: | + TESTFILES=$(circleci tests glob "e2e/cypress/e2e/**/*.cy.js" | circleci tests split) + bundle exec rails cypress:run -- --spec $TESTFILES + + - store_test_results: + path: test-results + + - store_artifacts: + path: e2e/cypress/screenshots + + - store_artifacts: + path: e2e/cypress/videos + +workflows: + test: + jobs: + - e2e-tests +``` + +## Security Considerations + +### Protecting Test Endpoints +```ruby +# config/initializers/cypress_on_rails.rb +CypressOnRails.configure do |c| + # Only enable in test/development + c.use_middleware = !Rails.env.production? + + # Add authentication + c.before_request = lambda { |request| + # IP whitelist for CI + allowed_ips = ['127.0.0.1', '::1'] + allowed_ips += ENV['ALLOWED_CI_IPS'].split(',') if ENV['ALLOWED_CI_IPS'] + + unless allowed_ips.include?(request.ip) + return [403, {}, ['Forbidden']] + end + + # Token authentication + body = JSON.parse(request.body.string) + expected_token = ENV.fetch('CYPRESS_SECRET_TOKEN', 'development-token') + + if body['auth_token'] != expected_token + return [401, {}, ['Unauthorized']] + end + + nil + } +end +``` + +### Environment Variables +```bash +# .env.test +CYPRESS_SECRET_TOKEN=secure-random-token-here +CYPRESS_BASE_URL=http://localhost:5017 +CYPRESS_RAILS_HOST=localhost +CYPRESS_RAILS_PORT=5017 + +# Never commit real credentials +DATABASE_URL=postgresql://localhost/myapp_test +REDIS_URL=redis://localhost:6379/1 +``` + +### Sanitizing Test Data +```ruby +# e2e/app_commands/factory_bot.rb +class SafeFactoryBot + BLOCKED_FACTORIES = %w[admin super_admin payment_method].freeze + + def self.create(factory_name, *args) + raise "Factory '#{factory_name}' is blocked in tests" if BLOCKED_FACTORIES.include?(factory_name.to_s) + + FactoryBot.create(factory_name, *args) + end +end +``` + +## Debugging Strategies + +### Verbose Logging +```ruby +# config/initializers/cypress_on_rails.rb +CypressOnRails.configure do |c| + c.logger = Logger.new(STDOUT) + c.logger.level = ENV['DEBUG'] ? Logger::DEBUG : Logger::INFO +end + +# e2e/app_commands/debug.rb +def perform + logger.debug "Current user count: #{User.count}" + logger.debug "Environment: #{Rails.env}" + logger.debug "Database: #{ActiveRecord::Base.connection.current_database}" + + result = yield if block_given? + + logger.debug "Result: #{result.inspect}" + result +end +``` + +### Screenshot on Failure +```js +// cypress/support/index.js +Cypress.on('fail', (error, runnable) => { + // Take screenshot before failing + cy.screenshot(`failed-${runnable.title}`, { capture: 'fullPage' }); + + // Log additional debugging info + cy.task('log', { + test: runnable.title, + error: error.message, + url: cy.url(), + timestamp: new Date().toISOString() + }); + + throw error; +}); +``` + +### Interactive Debugging +```js +// Add debugger statements +it('debug this test', () => { + cy.appFactories([['create', 'user']]); + + cy.visit('/users'); + debugger; // Cypress will pause here in open mode + + cy.get('[data-cy=user-list]').should('exist'); +}); + +// Or use cy.pause() +it('pause execution', () => { + cy.visit('/users'); + cy.pause(); // Manually resume in Cypress UI + cy.get('[data-cy=user-list]').should('exist'); +}); +``` + +## Common Patterns + +### Authentication Flow +```js +// cypress/support/commands.js +Cypress.Commands.add('login', (email, password) => { + cy.session([email, password], () => { + cy.visit('/login'); + cy.get('[data-cy=email]').type(email); + cy.get('[data-cy=password]').type(password); + cy.get('[data-cy=submit]').click(); + cy.url().should('include', '/dashboard'); + }); +}); + +// Usage +beforeEach(() => { + cy.login('user@example.com', 'password'); +}); +``` + +### File Upload Testing +```js +it('uploads a file', () => { + cy.fixture('document.pdf', 'base64').then(fileContent => { + cy.get('[data-cy=file-input]').attachFile({ + fileContent, + fileName: 'document.pdf', + mimeType: 'application/pdf', + encoding: 'base64' + }); + }); + + cy.get('[data-cy=upload-button]').click(); + cy.contains('File uploaded successfully'); +}); +``` + +### API Mocking +```js +it('handles API errors gracefully', () => { + // Mock failed API response + cy.intercept('POST', '/api/users', { + statusCode: 500, + body: { error: 'Internal Server Error' } + }).as('createUser'); + + cy.visit('/users/new'); + cy.get('[data-cy=submit]').click(); + + cy.wait('@createUser'); + cy.contains('Something went wrong'); +}); +``` + +### Testing Async Operations +```js +it('waits for async operations', () => { + // Start a background job + cy.appEval(` + ImportJob.perform_later('large_dataset.csv') + `); + + cy.visit('/imports'); + + // Poll for completion + cy.get('[data-cy=import-status]', { timeout: 30000 }) + .should('contain', 'Completed'); +}); +``` + +## Summary + +Following these best practices will help you: +- Write more maintainable and reliable tests +- Improve test performance and reduce flakiness +- Better organize your test code +- Secure your test infrastructure +- Debug issues more effectively + +Remember: E2E tests should focus on critical user journeys. Use unit and integration tests for comprehensive coverage of edge cases. \ No newline at end of file diff --git a/docs/DX_IMPROVEMENTS.md b/docs/DX_IMPROVEMENTS.md new file mode 100644 index 0000000..2a4c8d8 --- /dev/null +++ b/docs/DX_IMPROVEMENTS.md @@ -0,0 +1,163 @@ +# Developer Experience Improvements + +Based on analysis of user issues and feedback, here are the key improvements made to cypress-playwright-on-rails to enhance developer experience. + +## 🎯 Issues Addressed + +### 1. Manual Server Management (#152, #153) +**Previous Pain Point:** Users had to manually start Rails server in a separate terminal. + +**Solution Implemented:** +- ✅ Added rake tasks: `cypress:open`, `cypress:run`, `playwright:open`, `playwright:run` +- ✅ Automatic server lifecycle management +- ✅ Dynamic port selection +- ✅ Server hooks for customization + +### 2. Playwright Feature Parity (#169) +**Previous Pain Point:** Playwright users lacked documentation and helper functions. + +**Solution Implemented:** +- ✅ Comprehensive [Playwright Guide](PLAYWRIGHT_GUIDE.md) +- ✅ Complete helper functions in examples +- ✅ Migration guide from Cypress to Playwright +- ✅ Playwright-specific rake tasks + +### 3. VCR Configuration Confusion (#175, #160) +**Previous Pain Point:** VCR integration was poorly documented and error-prone. + +**Solution Implemented:** +- ✅ Detailed [VCR Integration Guide](VCR_GUIDE.md) +- ✅ Troubleshooting for common VCR errors +- ✅ GraphQL-specific VCR configuration +- ✅ Examples for both insert/eject and use_cassette modes + +### 4. Test Environment Issues (#157, #118) +**Previous Pain Point:** Confusion about running in test vs development environment. + +**Solution Implemented:** +- ✅ Clear documentation in [Troubleshooting Guide](TROUBLESHOOTING.md) +- ✅ Environment configuration examples +- ✅ Support for `CYPRESS_RAILS_HOST` and `CYPRESS_RAILS_PORT` +- ✅ Guidance on enabling file watching in test environment + +### 5. Database Management (#155, #114) +**Previous Pain Point:** Database cleaning issues and lack of transactional support. + +**Solution Implemented:** +- ✅ Transactional test mode with automatic rollback +- ✅ Smart database cleaning strategies +- ✅ ApplicationRecord error handling +- ✅ Rails transactional fixtures support + +### 6. Authentication & Security (#137) +**Previous Pain Point:** No built-in way to secure test endpoints. + +**Solution Implemented:** +- ✅ `before_request` hook for authentication +- ✅ Security best practices documentation +- ✅ IP whitelisting examples +- ✅ Token-based authentication examples + +## 📊 Impact Summary + +### Before These Improvements +- 😤 Manual server management required +- 📖 Sparse documentation +- 🔍 Issues buried in GitHub +- 🐛 Common errors without solutions +- 🎭 Playwright as second-class citizen + +### After These Improvements +- 🚀 One-command test execution +- 📚 Comprehensive documentation +- 🛠 Solutions for all common issues +- ✨ Feature parity for Playwright +- 🔒 Security best practices included + +## 🗺 Documentation Structure + +``` +docs/ +├── BEST_PRACTICES.md # Patterns and recommendations +├── TROUBLESHOOTING.md # Solutions to common issues +├── PLAYWRIGHT_GUIDE.md # Complete Playwright documentation +├── VCR_GUIDE.md # VCR integration details +└── DX_IMPROVEMENTS.md # This file +``` + +## 🚀 Quick Wins for New Users + +1. **Start Testing in 30 Seconds** + ```bash + gem 'cypress-on-rails' + bundle install + rails g cypress_on_rails:install + rails cypress:open # Done! + ``` + +2. **Switch from cypress-rails** + - Drop-in replacement with same commands + - Migration guide in CHANGELOG + +3. **Debug Failures Easily** + - Comprehensive troubleshooting guide + - Common errors with solutions + - Stack Overflow-style Q&A format + +## 🔮 Future Improvements + +Based on remaining open issues, consider implementing: + +1. **Parallel Testing Support (#119)** + - Native parallel execution + - Automatic database partitioning + - CI-specific optimizations + +2. **Better Error Messages** + - Contextual help in error output + - Links to relevant documentation + - Suggested fixes + +3. **Interactive Setup Wizard** + - Guided installation process + - Framework detection + - Automatic configuration + +4. **Performance Monitoring** + - Test execution metrics + - Slow test detection + - Optimization suggestions + +## 💡 Developer Experience Principles + +These improvements follow key DX principles: + +1. **Zero to Testing Fast** - Minimize time to first test +2. **Pit of Success** - Make the right thing the easy thing +3. **Progressive Disclosure** - Simple things simple, complex things possible +4. **Excellent Error Messages** - Every error should suggest a solution +5. **Documentation as Code** - Keep docs next to implementation +6. **Community Driven** - Address real user pain points + +## 📈 Metrics of Success + +Improvements can be measured by: +- ⬇️ Reduced issue creation for solved problems +- ⬇️ Decreased time to first successful test +- ⬆️ Increased adoption rate +- ⬆️ Higher user satisfaction +- 🔄 More contributions from community + +## 🤝 Contributing + +To continue improving developer experience: + +1. **Report Issues** with detailed reproduction steps +2. **Suggest Improvements** via GitHub discussions +3. **Share Solutions** that worked for you +4. **Contribute Examples** to documentation +5. **Help Others** in Slack/forums + +## Conclusion + +These documentation and feature improvements directly address the most common pain points users face. By providing comprehensive guides, troubleshooting resources, and automated solutions, we've significantly improved the developer experience for both new and existing users of cypress-playwright-on-rails. \ No newline at end of file diff --git a/docs/PLAYWRIGHT_GUIDE.md b/docs/PLAYWRIGHT_GUIDE.md new file mode 100644 index 0000000..3c3184d --- /dev/null +++ b/docs/PLAYWRIGHT_GUIDE.md @@ -0,0 +1,554 @@ +# Complete Playwright Guide + +This guide provides comprehensive documentation for using Playwright with cypress-playwright-on-rails. + +## Table of Contents +- [Installation](#installation) +- [Basic Setup](#basic-setup) +- [Commands and Helpers](#commands-and-helpers) +- [Factory Bot Integration](#factory-bot-integration) +- [Fixtures and Scenarios](#fixtures-and-scenarios) +- [Database Management](#database-management) +- [Advanced Configuration](#advanced-configuration) +- [Migration from Cypress](#migration-from-cypress) + +## Installation + +### 1. Add the gem to your Gemfile +```ruby +group :test, :development do + gem 'cypress-on-rails', '~> 1.0' +end +``` + +### 2. Install with Playwright framework +```bash +bundle install +bin/rails g cypress_on_rails:install --framework playwright + +# Or with custom folder +bin/rails g cypress_on_rails:install --framework playwright --install_folder=spec/e2e +``` + +### 3. Install Playwright +```bash +# Using yarn +yarn add -D @playwright/test + +# Using npm +npm install --save-dev @playwright/test + +# Install browsers +npx playwright install +``` + +## Basic Setup + +### Directory Structure +``` +e2e/ +├── playwright/ +│ ├── e2e/ # Test files +│ │ └── example.spec.js +│ ├── support/ +│ │ └── on-rails.js # Helper functions +│ ├── e2e_helper.rb # Ruby helper +│ └── app_commands/ # Ruby commands +│ ├── clean.rb +│ ├── factory_bot.rb +│ └── scenarios/ +│ └── basic.rb +└── playwright.config.js # Playwright configuration +``` + +### Playwright Configuration +```js +// playwright.config.js +module.exports = { + testDir: './e2e/playwright/e2e', + timeout: 30000, + use: { + baseURL: process.env.BASE_URL || 'http://localhost:5017', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } } + ] +}; +``` + +## Commands and Helpers + +### Complete on-rails.js Helper File +```js +// e2e/playwright/support/on-rails.js +const { request } = require('@playwright/test'); + +const API_PREFIX = ''; // or '/api' if configured + +async function appCommands(body) { + const context = await request.newContext(); + const response = await context.post(`${API_PREFIX}/__e2e__/command`, { + data: body, + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Command failed: ${response.status()} - ${text}`); + } + + return response.json(); +} + +async function app(name, commandOptions = {}) { + const result = await appCommands({ + name, + options: commandOptions + }); + return result[0]; +} + +async function appScenario(name, options = {}) { + return app(`scenarios/${name}`, options); +} + +async function appFactories(factories) { + return app('factory_bot', factories); +} + +async function appFixtures() { + return app('activerecord_fixtures'); +} + +async function appClean() { + return app('clean'); +} + +async function appEval(code) { + return app('eval', { code }); +} + +module.exports = { + app, + appCommands, + appScenario, + appFactories, + appFixtures, + appClean, + appEval +}; +``` + +### Using Helpers in Tests +```js +// e2e/playwright/e2e/user.spec.js +const { test, expect } = require('@playwright/test'); +const { app, appFactories, appScenario, appClean } = require('../support/on-rails'); + +test.describe('User Management', () => { + test.beforeEach(async () => { + await appClean(); + }); + + test('create and view user', async ({ page }) => { + // Create user with factory bot + const users = await appFactories([ + ['create', 'user', { name: 'John Doe', email: 'john@example.com' }] + ]); + + await page.goto(`/users/${users[0].id}`); + await expect(page.locator('h1')).toContainText('John Doe'); + }); + + test('load scenario', async ({ page }) => { + await appScenario('user_with_posts'); + await page.goto('/users'); + await expect(page.locator('.user-count')).toContainText('5 users'); + }); +}); +``` + +## Factory Bot Integration + +### Creating Records +```js +test('factory bot examples', async ({ page }) => { + // Single record + const user = await appFactories([ + ['create', 'user', { name: 'Alice' }] + ]); + + // Multiple records + const posts = await appFactories([ + ['create_list', 'post', 3, { published: true }] + ]); + + // With traits + const adminUser = await appFactories([ + ['create', 'user', 'admin', { name: 'Admin User' }] + ]); + + // With associations + const postWithComments = await appFactories([ + ['create', 'post', 'with_comments', { comment_count: 5 }] + ]); + + // Building without saving + const userData = await appFactories([ + ['build', 'user', { name: 'Not Saved' }] + ]); +}); +``` + +### Using Attributes For +```js +test('get factory attributes', async ({ page }) => { + const attributes = await appFactories([ + ['attributes_for', 'user'] + ]); + + // Use attributes to fill form + await page.fill('[name="user[name]"]', attributes[0].name); + await page.fill('[name="user[email]"]', attributes[0].email); +}); +``` + +## Fixtures and Scenarios + +### Loading Rails Fixtures +```js +test('load fixtures', async ({ page }) => { + await appFixtures(); + + await page.goto('/products'); + // Fixtures are loaded +}); +``` + +### Creating Scenarios +```ruby +# e2e/playwright/app_commands/scenarios/complex_setup.rb +# Create a complex test scenario +5.times do |i| + user = User.create!( + name: "User #{i}", + email: "user#{i}@example.com" + ) + + 3.times do |j| + user.posts.create!( + title: "Post #{j} by User #{i}", + content: "Content for post #{j}", + published: j.even? + ) + end +end + +# Add some comments +Post.published.each do |post| + 2.times do + post.comments.create!( + author: "Commenter", + content: "Great post!" + ) + end +end +``` + +Using scenarios in tests: +```js +test('complex scenario', async ({ page }) => { + await appScenario('complex_setup'); + + await page.goto('/posts'); + await expect(page.locator('.post')).toHaveCount(15); + await expect(page.locator('.published')).toHaveCount(7); +}); +``` + +## Database Management + +### Cleaning Between Tests +```js +// Global setup +test.beforeEach(async () => { + await appClean(); +}); + +// Or selectively +test('with fresh database', async ({ page }) => { + await appClean(); + await app('load_seed'); // Optionally load seeds + + // Your test here +}); +``` + +### Custom Clean Commands +```ruby +# e2e/playwright/app_commands/clean.rb +if defined?(DatabaseCleaner) + DatabaseCleaner.strategy = :truncation + DatabaseCleaner.clean +else + # Manual cleaning + tables = ActiveRecord::Base.connection.tables + tables.delete('schema_migrations') + tables.delete('ar_internal_metadata') + + tables.each do |table| + ActiveRecord::Base.connection.execute("DELETE FROM #{table}") + end +end + +# Reset sequences for PostgreSQL +if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' + ActiveRecord::Base.connection.tables.each do |table| + ActiveRecord::Base.connection.reset_pk_sequence!(table) + end +end + +Rails.cache.clear if Rails.cache +``` + +## Advanced Configuration + +### Running Custom Ruby Code +```js +test('execute ruby code', async ({ page }) => { + // Run arbitrary Ruby code + const result = await appEval(` + User.count + `); + console.log('User count:', result); + + // More complex evaluation + const stats = await appEval(` + { + users: User.count, + posts: Post.count, + latest_user: User.last&.name + } + `); +}); +``` + +### Authentication for Commands +```js +// e2e/playwright/support/authenticated-on-rails.js +const TOKEN = process.env.CYPRESS_SECRET_TOKEN; + +async function authenticatedCommand(name, options = {}) { + const context = await request.newContext(); + const response = await context.post('/__e2e__/command', { + data: { + name, + options, + cypress_token: TOKEN + }, + headers: { + 'Content-Type': 'application/json' + } + }); + + if (response.status() === 401) { + throw new Error('Authentication failed'); + } + + return response.json(); +} +``` + +### Parallel Testing +```js +// playwright.config.js +module.exports = { + workers: 4, // Run 4 tests in parallel + fullyParallel: true, + + use: { + // Each worker gets unique database + baseURL: process.env.BASE_URL || 'http://localhost:5017', + }, + + globalSetup: './global-setup.js', + globalTeardown: './global-teardown.js' +}; + +// global-setup.js +module.exports = async config => { + // Setup databases for parallel workers + for (let i = 0; i < config.workers; i++) { + process.env[`TEST_ENV_NUMBER_${i}`] = i.toString(); + } +}; +``` + +## Migration from Cypress + +### Command Comparison + +| Cypress | Playwright | +|---------|------------| +| `cy.app('clean')` | `await app('clean')` | +| `cy.appFactories([...])` | `await appFactories([...])` | +| `cy.appScenario('name')` | `await appScenario('name')` | +| `cy.visit('/path')` | `await page.goto('/path')` | +| `cy.contains('text')` | `await expect(page.locator('text')).toBeVisible()` | +| `cy.get('.class')` | `page.locator('.class')` | +| `cy.click()` | `await locator.click()` | + +### Converting Test Files +```js +// Cypress version +describe('Test', () => { + beforeEach(() => { + cy.app('clean'); + cy.appFactories([ + ['create', 'user', { name: 'Test' }] + ]); + }); + + it('works', () => { + cy.visit('/users'); + cy.contains('Test'); + }); +}); + +// Playwright version +const { test, expect } = require('@playwright/test'); +const { app, appFactories } = require('../support/on-rails'); + +test.describe('Test', () => { + test.beforeEach(async () => { + await app('clean'); + await appFactories([ + ['create', 'user', { name: 'Test' }] + ]); + }); + + test('works', async ({ page }) => { + await page.goto('/users'); + await expect(page.locator('text=Test')).toBeVisible(); + }); +}); +``` + +## Running Tests + +### Using Rake Tasks +```bash +# Open Playwright UI +bin/rails playwright:open + +# Run tests headless +bin/rails playwright:run +``` + +### Manual Execution +```bash +# Start Rails server +CYPRESS=1 bin/rails server -p 5017 + +# In another terminal +npx playwright test + +# With specific browser +npx playwright test --project=chromium + +# With UI mode +npx playwright test --ui + +# Debug mode +npx playwright test --debug +``` + +### CI Configuration +```yaml +# .github/workflows/playwright.yml +name: Playwright Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - run: yarn install + - run: npx playwright install --with-deps + + - run: bundle exec rails db:create db:schema:load + env: + RAILS_ENV: test + + - run: bundle exec rails playwright:run + env: + RAILS_ENV: test + + - uses: actions/upload-artifact@v3 + if: failure() + with: + name: playwright-traces + path: test-results/ +``` + +## Best Practices + +1. **Always clean between tests** to ensure isolation +2. **Use Page Object Model** for complex applications +3. **Leverage Playwright's auto-waiting** instead of explicit waits +4. **Run tests in parallel** for faster CI +5. **Use fixtures for static data**, factories for dynamic data +6. **Commit test recordings** for debugging failures + +## Debugging + +### Enable Debug Mode +```bash +# Run with debug +PWDEBUG=1 npx playwright test + +# This will: +# - Open browser in headed mode +# - Open Playwright Inspector +# - Pause at the start of each test +``` + +### Using Traces +```js +// Enable traces for debugging +const { chromium } = require('playwright'); + +test('debug this', async ({ page }, testInfo) => { + // Start tracing + await page.context().tracing.start({ + screenshots: true, + snapshots: true + }); + + // Your test + await page.goto('/'); + + // Save trace + await page.context().tracing.stop({ + path: `trace-${testInfo.title}.zip` + }); +}); +``` + +View traces: +```bash +npx playwright show-trace trace-debug-this.zip +``` \ No newline at end of file diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..8205a0e --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,351 @@ +# Troubleshooting Guide + +This guide addresses common issues and questions when using cypress-playwright-on-rails. + +## Table of Contents +- [VCR Integration Issues](#vcr-integration-issues) +- [Playwright Support](#playwright-support) +- [Test Environment Configuration](#test-environment-configuration) +- [Database and Transaction Issues](#database-and-transaction-issues) +- [Authentication and Security](#authentication-and-security) +- [Performance and Parallel Testing](#performance-and-parallel-testing) +- [Common Errors](#common-errors) + +## VCR Integration Issues + +### Issue: "No route matches [POST] '/api/__e2e__/vcr/insert'" (#175) + +**Problem:** VCR middleware is not properly configured or mounted. + +**Solution:** +1. Ensure VCR middleware is enabled in `config/initializers/cypress_on_rails.rb`: +```ruby +CypressOnRails.configure do |c| + c.use_vcr_middleware = !Rails.env.production? && ENV['CYPRESS'].present? + + c.vcr_options = { + hook_into: :webmock, + default_cassette_options: { record: :once }, + cassette_library_dir: Rails.root.join('spec/fixtures/vcr_cassettes') + } +end +``` + +2. Add to your `cypress/support/index.js`: +```js +import 'cypress-on-rails/support/index' +``` + +3. Make sure your API prefix matches: +```ruby +c.api_prefix = '/api' # If your app uses /api prefix +``` + +### Using VCR with GraphQL (#160) + +For GraphQL operations with `use_cassette`: +```ruby +CypressOnRails.configure do |c| + c.use_vcr_use_cassette_middleware = !Rails.env.production? && ENV['CYPRESS'].present? + # Note: Don't enable both VCR middlewares simultaneously +end +``` + +Add to `cypress/support/commands.js`: +```js +Cypress.Commands.add('mockGraphQL', () => { + cy.on('window:before:load', (win) => { + const originalFetch = win.fetch; + const fetch = (path, options, ...rest) => { + if (options && options.body) { + try { + const body = JSON.parse(options.body); + if (body.operationName) { + return originalFetch(`${path}?operation=${body.operationName}`, options, ...rest); + } + } catch (e) { + return originalFetch(path, options, ...rest); + } + } + return originalFetch(path, options, ...rest); + }; + cy.stub(win, 'fetch', fetch); + }); +}); +``` + +## Playwright Support + +### Loading Fixtures in Playwright (#169) + +While Cypress has `cy.appFixtures()`, Playwright requires a different approach: + +**Solution 1: Create helper functions** +```js +// spec/playwright/support/on-rails.js +async function appFixtures() { + const response = await page.request.post('/__e2e__/command', { + data: { + name: 'activerecord_fixtures', + options: {} + } + }); + return response.json(); +} + +// Use in tests +test('load fixtures', async ({ page }) => { + await appFixtures(); + await page.goto('/'); +}); +``` + +**Solution 2: Use Factory Bot instead** +```js +// spec/playwright/support/factories.js +async function appFactories(factories) { + const response = await page.request.post('/__e2e__/command', { + data: { + name: 'factory_bot', + options: factories + } + }); + return response.json(); +} + +// Use in tests +test('create data', async ({ page }) => { + await appFactories([ + ['create', 'user', { name: 'Test User' }] + ]); + await page.goto('/users'); +}); +``` + +## Test Environment Configuration + +### Running Tests in Test Environment with Change Detection (#157) + +**Problem:** Running in development mode has different configuration than test mode. + +**Solution 1: Configure test environment with file watching** +```ruby +# config/environments/test.rb +if ENV['CYPRESS'].present? + # Enable file watching in test environment for Cypress + config.file_watcher = ActiveSupport::FileUpdateChecker + config.cache_classes = false + config.reload_classes_only_on_change = true +end +``` + +**Solution 2: Use custom environment** +```bash +# Create config/environments/cypress.rb based on test.rb +cp config/environments/test.rb config/environments/cypress.rb + +# Modify cypress.rb to enable reloading +# Run with: +RAILS_ENV=cypress CYPRESS=1 bin/rails server +``` + +### Headless Mode Configuration (#118) + +To run Cypress in truly headless mode: +```bash +# For CI/headless execution +bin/rails cypress:run + +# Or manually: +CYPRESS=1 bin/rails server -p 5017 & +yarn cypress run --headless --project ./e2e +``` + +## Database and Transaction Issues + +### ApplicationRecord MySQL Error (#155) + +**Problem:** ApplicationRecord being queried as a table. + +**Solution:** Exclude ApplicationRecord from logging: +```ruby +# spec/e2e/app_commands/log_fail.rb +def perform + load "#{Rails.root}/db/seeds.rb" if options && options['load_seeds'] + + descendants = ActiveRecord::Base.descendants + # Exclude abstract classes + descendants.reject! { |model| model.abstract_class? || model == ApplicationRecord } + + descendants.each_with_object({}) do |model, result| + result[model.name] = model.limit(100).map(&:attributes) + rescue => e + result[model.name] = { error: e.message } + end +end +``` + +### Using Rails Transactional Fixtures (#114) + +Instead of database_cleaner, use Rails built-in transactional fixtures: + +```ruby +# spec/e2e/app_commands/clean.rb +require 'active_record/test_fixtures' + +class TransactionalClean + include ActiveRecord::TestFixtures + + def perform + setup_fixtures + yield if block_given? + ensure + teardown_fixtures + end +end + +# Use with new rake tasks: +CypressOnRails.configure do |c| + c.transactional_server = true # Enables automatic rollback +end +``` + +## Authentication and Security + +### Authenticating Commands (#137) + +Protect your commands with authentication: + +```ruby +# config/initializers/cypress_on_rails.rb +CypressOnRails.configure do |c| + c.before_request = lambda { |request| + body = JSON.parse(request.body.string) + + # Option 1: Token-based auth + if body['cypress_token'] != ENV.fetch('CYPRESS_SECRET_TOKEN') + return [401, {}, ['unauthorized']] + end + + # Option 2: Warden/Devise auth + # if !request.env['warden'].authenticate(:secret_key) + # return [403, {}, ['forbidden']] + # end + + nil # Continue with command execution + } +end +``` + +In Cypress tests: +```js +Cypress.Commands.overwrite('app', (originalFn, name, options) => { + return originalFn(name, { + ...options, + cypress_token: Cypress.env('SECRET_TOKEN') + }); +}); +``` + +## Performance and Parallel Testing + +### Parallel Test Execution (#119) + +While not built-in, you can achieve parallel testing: + +**Option 1: Using cypress-parallel** +```bash +yarn add -D cypress-parallel + +# In package.json +"scripts": { + "cy:parallel": "cypress-parallel -s cy:run -t 4" +} +``` + +**Option 2: Database partitioning** +```ruby +# config/initializers/cypress_on_rails.rb +if ENV['CYPRESS_PARALLEL_ID'].present? + # Use different database per parallel process + config.database_name = "test_cypress_#{ENV['CYPRESS_PARALLEL_ID']}" +end +``` + +**Option 3: CircleCI Parallelization** +```yaml +# .circleci/config.yml +jobs: + cypress: + parallelism: 4 + steps: + - run: + command: | + TESTFILES=$(circleci tests glob "e2e/**/*.cy.js" | circleci tests split) + yarn cypress run --spec $TESTFILES +``` + +## Common Errors + +### Webpack Compilation Error (#146) + +**Error:** "Module not found: Error: Can't resolve 'cypress-factory'" + +**Solution:** This is usually a path issue. Check: +1. Your support file imports the correct path: +```js +// cypress/support/index.js +import './on-rails' // Not 'cypress-factory' +``` + +2. Ensure the file exists at the expected location +3. Clear Cypress cache if needed: +```bash +yarn cypress cache clear +yarn install +``` + +### Server Not Starting + +If rake tasks fail to start the server: +```ruby +# Check for port conflicts +lsof -i :3001 + +# Use a different port +CYPRESS_RAILS_PORT=5017 bin/rails cypress:open + +# Or configure in initializer +CypressOnRails.configure do |c| + c.server_port = 5017 +end +``` + +### State Not Resetting Between Tests + +Ensure clean state: +```js +// cypress/support/index.js +beforeEach(() => { + cy.app('clean'); + cy.app('load_seed'); // Optional +}); + +// Or use state reset endpoint +beforeEach(() => { + cy.request('POST', '/cypress_rails_reset_state'); +}); +``` + +## Getting Help + +If you encounter issues not covered here: + +1. Check existing [GitHub issues](https://github.com/shakacode/cypress-playwright-on-rails/issues) +2. Search the [Slack channel](https://join.slack.com/t/reactrails/shared_invite/enQtNjY3NTczMjczNzYxLTlmYjdiZmY3MTVlMzU2YWE0OWM0MzNiZDI0MzdkZGFiZTFkYTFkOGVjODBmOWEyYWQ3MzA2NGE1YWJjNmVlMGE) +3. Post in the [forum](https://forum.shakacode.com/c/cypress-on-rails/55) +4. Create a new issue with: + - Your Rails version + - cypress-on-rails version + - Minimal reproduction steps + - Full error messages and stack traces \ No newline at end of file diff --git a/docs/VCR_GUIDE.md b/docs/VCR_GUIDE.md new file mode 100644 index 0000000..911ae48 --- /dev/null +++ b/docs/VCR_GUIDE.md @@ -0,0 +1,499 @@ +# VCR Integration Guide + +Complete guide for recording and replaying HTTP interactions in your tests using VCR with cypress-playwright-on-rails. + +## Table of Contents +- [Overview](#overview) +- [Installation](#installation) +- [Configuration](#configuration) +- [Insert/Eject Mode](#inserteject-mode) +- [Use Cassette Mode](#use-cassette-mode) +- [GraphQL Integration](#graphql-integration) +- [Advanced Usage](#advanced-usage) +- [Troubleshooting](#troubleshooting) + +## Overview + +VCR (Video Cassette Recorder) records your test suite's HTTP interactions and replays them during future test runs for fast, deterministic tests. This is particularly useful for: +- Testing against third-party APIs +- Avoiding rate limits +- Testing without internet connection +- Ensuring consistent test data +- Speeding up test execution + +## Installation + +### 1. Add required gems +```ruby +# Gemfile +group :test, :development do + gem 'vcr' + gem 'webmock' + gem 'cypress-on-rails', '~> 1.0' +end +``` + +### 2. Install npm package (optional, for enhanced features) +```bash +yarn add -D cypress-on-rails +# or +npm install --save-dev cypress-on-rails +``` + +## Configuration + +### Basic VCR Setup + +```ruby +# config/initializers/cypress_on_rails.rb +CypressOnRails.configure do |c| + # Enable VCR middleware + c.use_vcr_middleware = !Rails.env.production? && ENV['CYPRESS'].present? + + # VCR configuration options + c.vcr_options = { + # HTTP library to hook into + hook_into: :webmock, + + # Default recording mode + default_cassette_options: { + record: :once, # :once, :new_episodes, :none, :all + match_requests_on: [:method, :uri, :body], + allow_unused_http_interactions: false + }, + + # Where to save cassettes + cassette_library_dir: Rails.root.join('spec/fixtures/vcr_cassettes'), + + # Configure which hosts to ignore + ignore_hosts: ['localhost', '127.0.0.1', '0.0.0.0'], + + # Filter sensitive data + filter_sensitive_data: { + '' => ENV['EXTERNAL_API_KEY'], + '' => ENV['AUTH_TOKEN'] + }, + + # Preserve exact body bytes for binary data + preserve_exact_body_bytes: true, + + # Allow HTTP connections when no cassette + allow_http_connections_when_no_cassette: false + } +end +``` + +### Cypress Setup + +```js +// cypress/support/index.js +import 'cypress-on-rails/support/index' + +// Optional: Configure VCR commands +Cypress.Commands.add('vcrInsert', (name, options = {}) => { + cy.app('vcr_insert_cassette', { name, ...options }); +}); + +Cypress.Commands.add('vcrEject', () => { + cy.app('vcr_eject_cassette'); +}); +``` + +### Clean Command Setup + +```ruby +# e2e/app_commands/clean.rb +# Ensure cassettes are ejected between tests +VCR.eject_cassette if VCR.current_cassette +VCR.turn_off! +WebMock.disable! if defined?(WebMock) + +# Your existing clean logic... +DatabaseCleaner.clean +``` + +## Insert/Eject Mode + +Insert/eject mode gives you explicit control over when to start and stop recording. + +### Configuration +```ruby +CypressOnRails.configure do |c| + c.use_vcr_middleware = !Rails.env.production? && ENV['CYPRESS'].present? + # Don't enable use_cassette mode +end +``` + +### Basic Usage +```js +describe('External API Tests', () => { + afterEach(() => { + cy.vcr_eject_cassette(); + }); + + it('fetches weather data', () => { + // Start recording + cy.vcr_insert_cassette('weather_api', { + record: 'new_episodes' + }); + + cy.visit('/weather'); + cy.contains('Current Temperature'); + + // Recording continues until ejected + }); + + it('handles API errors', () => { + // Use pre-recorded cassette + cy.vcr_insert_cassette('weather_api_error', { + record: 'none' // Only replay, don't record + }); + + cy.visit('/weather?city=invalid'); + cy.contains('City not found'); + }); +}); +``` + +### Advanced Options +```js +cy.vcr_insert_cassette('api_calls', { + record: 'new_episodes', // Recording mode + match_requests_on: ['method', 'uri', 'body'], // Request matching + erb: true, // Enable ERB in cassettes + allow_playback_repeats: true, // Allow multiple replays + exclusive: true, // Disallow other cassettes + serialize_with: 'json', // Use JSON format + preserve_exact_body_bytes: true, // For binary data + decode_compressed_response: true // Handle gzipped responses +}); +``` + +## Use Cassette Mode + +Use cassette mode automatically wraps each request with VCR.use_cassette. + +### Configuration +```ruby +CypressOnRails.configure do |c| + # Use this instead of use_vcr_middleware + c.use_vcr_use_cassette_middleware = !Rails.env.production? && ENV['CYPRESS'].present? + + c.vcr_options = { + hook_into: :webmock, + default_cassette_options: { + record: :once, + match_requests_on: [:method, :uri] + }, + cassette_library_dir: Rails.root.join('spec/fixtures/vcr_cassettes') + } +end +``` + +### How It Works +Each request is automatically wrapped with `VCR.use_cassette`. The cassette name is derived from the request URL or operation name. + +### Directory Structure +``` +spec/fixtures/vcr_cassettes/ +├── api/ +│ ├── users/ +│ │ └── index.yml +│ └── products/ +│ ├── index.yml +│ └── show.yml +└── graphql/ + ├── GetUser.yml + └── CreatePost.yml +``` + +## GraphQL Integration + +GraphQL requires special handling due to all requests going to the same endpoint. + +### Setup for GraphQL + +```js +// cypress/support/commands.js +Cypress.Commands.add('mockGraphQL', () => { + cy.on('window:before:load', (win) => { + const originalFetch = win.fetch; + const fetch = (path, options, ...rest) => { + if (options && options.body) { + try { + const body = JSON.parse(options.body); + // Add operation name to URL for VCR matching + if (body.operationName) { + return originalFetch( + `${path}?operation=${body.operationName}`, + options, + ...rest + ); + } + } catch (e) { + return originalFetch(path, options, ...rest); + } + } + return originalFetch(path, options, ...rest); + }; + cy.stub(win, 'fetch', fetch); + }); +}); + +// cypress/support/index.js +beforeEach(() => { + cy.mockGraphQL(); // Enable GraphQL operation tracking +}); +``` + +### GraphQL Test Example +```js +it('queries user data', () => { + // Cassette will be saved as vcr_cassettes/graphql/GetUser.yml + cy.visit('/profile'); + + // The GraphQL query with operationName: 'GetUser' + // will be automatically recorded + + cy.contains('John Doe'); +}); +``` + +### Custom GraphQL Matching +```ruby +# config/initializers/cypress_on_rails.rb +c.vcr_options = { + match_requests_on: [:method, :uri, + lambda { |req1, req2| + # Custom matching for GraphQL requests + if req1.uri.path == '/graphql' && req2.uri.path == '/graphql' + body1 = JSON.parse(req1.body) + body2 = JSON.parse(req2.body) + + # Match by operation name and variables + body1['operationName'] == body2['operationName'] && + body1['variables'] == body2['variables'] + else + true + end + } + ] +} +``` + +## Advanced Usage + +### Dynamic Cassette Names +```js +// Use test context for cassette names +it('fetches user data', function() { + const cassetteName = `${this.currentTest.parent.title}_${this.currentTest.title}` + .replace(/\s+/g, '_') + .toLowerCase(); + + cy.vcr_insert_cassette(cassetteName, { record: 'once' }); + + cy.visit('/users'); + // Test continues... +}); +``` + +### Conditional Recording +```js +const shouldRecord = Cypress.env('RECORD_VCR') === 'true'; + +cy.vcr_insert_cassette('api_calls', { + record: shouldRecord ? 'new_episodes' : 'none' +}); +``` + +### Multiple Cassettes +```js +it('combines multiple API sources', () => { + // Stack multiple cassettes + cy.vcr_insert_cassette('weather_api'); + cy.vcr_insert_cassette('news_api'); + + cy.visit('/dashboard'); + + // Both APIs will be recorded + + // Eject in reverse order + cy.vcr_eject_cassette(); // Ejects news_api + cy.vcr_eject_cassette(); // Ejects weather_api +}); +``` + +### Custom Matchers +```ruby +# e2e/app_commands/vcr_custom.rb +VCR.configure do |c| + # Custom request matcher + c.register_request_matcher :uri_ignoring_params do |req1, req2| + URI(req1.uri).host == URI(req2.uri).host && + URI(req1.uri).path == URI(req2.uri).path + end +end + +# Use in test +VCR.use_cassette('api_call', + match_requests_on: [:method, :uri_ignoring_params] +) +``` + +### Filtering Sensitive Data +```ruby +VCR.configure do |c| + # Filter authorization headers + c.filter_sensitive_data('') do |interaction| + interaction.request.headers['Authorization']&.first + end + + # Filter API keys from URLs + c.filter_sensitive_data('') do |interaction| + URI(interaction.request.uri).query + &.match(/api_key=([^&]+)/) + &.captures + &.first + end + + # Filter response tokens + c.filter_sensitive_data('') do |interaction| + JSON.parse(interaction.response.body)['token'] rescue nil + end +end +``` + +## Troubleshooting + +### Issue: "No route matches [POST] '/api/__e2e__/vcr/insert'" + +**Solution:** Ensure VCR middleware is enabled: +```ruby +# config/initializers/cypress_on_rails.rb +c.use_vcr_middleware = !Rails.env.production? && ENV['CYPRESS'].present? +``` + +And that the API prefix matches: +```ruby +c.api_prefix = '/api' # If your app uses /api prefix +``` + +### Issue: "VCR::Errors::UnhandledHTTPRequestError" + +**Cause:** Request not matching any cassette. + +**Solutions:** +1. Re-record the cassette: +```js +cy.vcr_insert_cassette('my_cassette', { record: 'new_episodes' }); +``` + +2. Adjust matching criteria: +```ruby +c.vcr_options = { + default_cassette_options: { + match_requests_on: [:method, :host, :path] # Ignore query params + } +} +``` + +3. Allow new requests: +```ruby +c.vcr_options = { + default_cassette_options: { + record: 'new_episodes', # Record new requests + allow_unused_http_interactions: true + } +} +``` + +### Issue: "Cassette not found" + +**Solution:** Check the cassette path: +```ruby +# Verify the directory exists +c.vcr_options = { + cassette_library_dir: Rails.root.join('spec/fixtures/vcr_cassettes') +} +``` + +Create the directory if needed: +```bash +mkdir -p spec/fixtures/vcr_cassettes +``` + +### Issue: "WebMock::NetConnectNotAllowedError" + +**Cause:** HTTP connection attempted without cassette. + +**Solutions:** +1. Insert a cassette before the request: +```js +cy.vcr_insert_cassette('api_calls'); +``` + +2. Allow connections to specific hosts: +```ruby +WebMock.disable_net_connect!( + allow_localhost: true, + allow: ['chromedriver.storage.googleapis.com'] +) +``` + +3. Disable WebMock for specific tests: +```js +cy.app('eval', { code: 'WebMock.disable!' }); +// Run test +cy.app('eval', { code: 'WebMock.enable!' }); +``` + +### Issue: Binary/Encoded Response Issues + +**Solution:** Configure VCR to handle binary data: +```ruby +c.vcr_options = { + preserve_exact_body_bytes: true, + decode_compressed_response: true +} +``` + +### Issue: Timestamps in Recordings + +**Solution:** Filter dynamic timestamps: +```ruby +VCR.configure do |c| + c.before_record do |interaction| + # Normalize timestamps in responses + if interaction.response.headers['date'] + interaction.response.headers['date'] = ['2024-01-01 00:00:00'] + end + end +end +``` + +## Best Practices + +1. **Organize cassettes by feature**: Use subdirectories for different features +2. **Use descriptive names**: Make cassette names self-documenting +3. **Commit cassettes to version control**: Share recordings with team +4. **Periodically refresh cassettes**: Re-record to catch API changes +5. **Filter sensitive data**: Never commit real API keys or tokens +6. **Use appropriate record modes**: + - `:once` for stable APIs + - `:new_episodes` during development + - `:none` for CI/production +7. **Document external dependencies**: List which APIs are being mocked +8. **Handle errors gracefully**: Record both success and error responses + +## Summary + +VCR integration with cypress-playwright-on-rails provides powerful HTTP mocking capabilities. Choose between: +- **Insert/Eject mode**: For explicit control over recording +- **Use Cassette mode**: For automatic recording, especially with GraphQL + +Remember to: +- Configure VCR appropriately for your needs +- Filter sensitive data +- Organize cassettes logically +- Keep cassettes up to date \ No newline at end of file diff --git a/lib/cypress_on_rails/server.rb b/lib/cypress_on_rails/server.rb index dff8817..00168ef 100644 --- a/lib/cypress_on_rails/server.rb +++ b/lib/cypress_on_rails/server.rb @@ -102,9 +102,9 @@ def spawn_server 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 diff --git a/lib/cypress_on_rails/state_reset_middleware.rb b/lib/cypress_on_rails/state_reset_middleware.rb index 3851b1c..f149ed7 100644 --- a/lib/cypress_on_rails/state_reset_middleware.rb +++ b/lib/cypress_on_rails/state_reset_middleware.rb @@ -17,13 +17,13 @@ def call(env) 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 @@ -40,13 +40,13 @@ def reset_application_state 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