diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f16e433cf..a4167ea0e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,8 @@ "service": "app", "runServices": [ "db", - "redis" + "redis", + "selenium" ], "workspaceFolder": "/workspace", "containerEnv": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 3a48dc7a8..f622e3686 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -10,6 +10,7 @@ x-rails-env: &rails_env POSTGRES_PASSWORD: postgres BUNDLE_PATH: /bundle REDIS_URL: redis://redis:6379/1 + SELENIUM_REMOTE_URL: http://selenium:4444 services: app: @@ -28,6 +29,7 @@ services: depends_on: - db - redis + - selenium worker: build: @@ -59,6 +61,14 @@ services: environment: <<: *db_env + selenium: + image: selenium/standalone-chromium:latest + ports: + - "4444:4444" + - "7900:7900" + shm_size: 2gb + restart: unless-stopped + volumes: postgres-data: redis-data: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bd4be1e07..1c098fd39 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -16,7 +16,7 @@ Purpose: provide short, actionable guidance so Copilot suggestions match project ### Testing - `bin/rails test` - Run all tests - `bin/rails test:db` - Run tests with database reset -- `bin/rails test:system` - Run system tests only (use sparingly - they take longer) +- `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests only (use sparingly - they take longer) - `bin/rails test test/models/account_test.rb` - Run specific test file - `bin/rails test test/models/account_test.rb:42` - Run specific test at line @@ -37,7 +37,7 @@ Purpose: provide short, actionable guidance so Copilot suggestions match project - `bin/setup` - Initial project setup (installs dependencies, prepares database) ## Pre-PR workflow (run locally before opening PR) -- Tests: bin/rails test (all), bin/rails test:system (when applicable) +- Tests: bin/rails test (all), DISABLE_PARALLELIZATION=true bin/rails test:system (when applicable) - Linters: bin/rubocop -f github -a; bundle exec erb_lint ./app/**/*.erb -a - Security: bin/brakeman --no-pager diff --git a/CLAUDE.md b/CLAUDE.md index 1e2bcc321..ddd38afd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing - `bin/rails test` - Run all tests - `bin/rails test:db` - Run tests with database reset -- `bin/rails test:system` - Run system tests only (use sparingly - they take longer) +- `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests only (use sparingly - they take longer) - `bin/rails test test/models/account_test.rb` - Run specific test file - `bin/rails test test/models/account_test.rb:42` - Run specific test at line +#### System Tests in the Dev Container +When running inside the Dev Container, the `SELENIUM_REMOTE_URL` environment variable is automatically set to the bundled `selenium/standalone-chromium` service. System tests will connect to that remote browser — no local Chrome installation is required. + +```bash +DISABLE_PARALLELIZATION=true bin/rails test:system +``` + +To watch the browser live, open `http://localhost:7900` or `http://localhost:4444` in your host browser (password: `secret`). + ### Linting & Formatting - `bin/rubocop` - Run Ruby linter - `npm run lint` - Check JavaScript/TypeScript code @@ -38,7 +47,7 @@ ALWAYS run these commands before opening a pull request: 1. **Tests** (Required): - `bin/rails test` - Run all tests (always required) - - `bin/rails test:system` - Run system tests (only when applicable, they take longer) + - `DISABLE_PARALLELIZATION=true bin/rails test:system` - Run system tests (only when applicable, they take longer) 2. **Linting** (Required): - `bin/rubocop -f github -a` - Ruby linting with auto-correct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c44b64524..a97e19f33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,9 @@ In general, _full features_ that get us closer to [our 🔜 Vision](https://gith To get setup for local development, you have two options: 1. [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) with VSCode (see the `.devcontainer` folder) + - A `selenium/standalone-chrome` service is included in the Dev Container setup, so **system tests work out of the box** — no local Chrome required. + - Run system tests: `DISABLE_PARALLELIZATION=true bin/rails test:system` + - Watch the browser live at `http://localhost:7900` or `http://localhost:4444` (password: `secret`) 2. Local Development - [Mac Setup Guide](https://github.com/we-promise/sure/wiki/Mac-Dev-Setup-Guide) - [Linux Setup Guide](https://github.com/we-promise/sure/wiki/Linux-Dev-Setup-Guide) diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index a32c04b49..69724cda0 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,11 +1,38 @@ require "test_helper" +require "socket" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase setup do Capybara.default_max_wait_time = 5 + + if ENV["SELENIUM_REMOTE_URL"].present? + server_port = ENV.fetch("CAPYBARA_SERVER_PORT", 30_000 + (Process.pid % 1000)).to_i + app_host = ENV["CAPYBARA_APP_HOST"].presence || IPSocket.getaddress(Socket.gethostname) + + Capybara.server_host = "0.0.0.0" + Capybara.server_port = server_port + Capybara.always_include_port = true + Capybara.app_host = "http://#{app_host}:#{server_port}" + end end - driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : ENV.fetch("E2E_BROWSER", :chrome).to_sym, screen_size: [ 1400, 1400 ] + if ENV["SELENIUM_REMOTE_URL"].present? + Capybara.register_driver :selenium_remote_chrome do |app| + options = Selenium::WebDriver::Chrome::Options.new + options.add_argument("--window-size=1400,1400") + + Capybara::Selenium::Driver.new( + app, + browser: :remote, + url: ENV["SELENIUM_REMOTE_URL"], + capabilities: options + ) + end + + driven_by :selenium_remote_chrome, screen_size: [ 1400, 1400 ] + else + driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : ENV.fetch("E2E_BROWSER", :chrome).to_sym, screen_size: [ 1400, 1400 ] + end private diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index d994881c5..099e55f6b 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -60,6 +60,7 @@ class SettingsTest < ApplicationSystemTestCase click_button "Generate new code" assert_selector 'span[data-clipboard-target="source"]', visible: true, count: 1 # invite code copy widget copy_button = find('button[data-action="clipboard#copy"]', match: :first) # Find the first copy button (adjust if needed) + page.execute_script("Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.resolve() }, writable: true, configurable: true })") # Mock clipboard API due to browser security restrictions in tests copy_button.click assert_selector 'span[data-clipboard-target="iconSuccess"]', visible: true, count: 1 # text copied and icon changed to checkmark end diff --git a/test/test_helper.rb b/test/test_helper.rb index 5a227e964..5af3466ff 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,11 +23,26 @@ require "minitest/autorun" require "mocha/minitest" require "aasm/minitest" require "webmock/minitest" +require "uri" VCR.configure do |config| config.cassette_library_dir = "test/vcr_cassettes" config.hook_into :webmock config.ignore_localhost = true + config.ignore_request do |request| + selenium_remote_url = ENV["SELENIUM_REMOTE_URL"] + next false if selenium_remote_url.blank? + + request_uri = URI(request.uri) + selenium_uri = URI(selenium_remote_url) + + request_uri.host.present? && + selenium_uri.host.present? && + request_uri.host == selenium_uri.host && + request_uri.port == selenium_uri.port + rescue URI::InvalidURIError + false + end config.default_cassette_options = { erb: true } config.filter_sensitive_data("") { ENV["OPENAI_ACCESS_TOKEN"] } config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] }