diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cd46a03b7..d062d35772 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -325,16 +325,23 @@ jobs: path: vitest-coverage - name: Merge coverage reports run: npx cobertura-merge -o merged-coverage.xml package1=minitest-coverage/coverage.xml package2=vitest-coverage/cobertura-coverage.xml package3=minitest-system-coverage/coverage.xml + - name: Archive merged coverage report + uses: actions/upload-artifact@v5 + if: always() + with: + name: coverage-main + path: merged-coverage.xml - name: Generate Coverage Report uses: clearlyip/code-coverage-report-action@v6 id: code_coverage_report_action + if: ${{ github.actor != 'dependabot[bot]'}} with: filename: "merged-coverage.xml" fail_on_negative_difference: true artifact_download_workflow_names: "ci,cron" only_list_changed_files: true - name: Add Coverage PR Comment - if: github.event_name == 'pull_request' + if: steps.code_coverage_report_action.outputs.file != '' && github.event_name == 'pull_request' && (success() || failure()) uses: marocchino/sticky-pull-request-comment@v2 with: recreate: true diff --git a/.gitignore b/.gitignore index 98f48d5468..69374fe71c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ /doc-site/schema /test/html_reports /test/reports +/test-results +/playwright-report /stats.json *.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md index 3136ffaf31..32ed3b64d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ This document provides essential context about the Intercode codebase for AI ass ## Project Overview Intercode is a convention management system built with: + - **Backend**: Ruby on Rails with GraphQL API - **Frontend**: React with TypeScript - **Routing**: React Router v7 @@ -46,6 +47,7 @@ export const action: ActionFunction = async ({ context, r ``` **Key points:** + - Always use `LoaderFunction` or `ActionFunction` as the type - Get client with `context.get(apolloClientContext)` - Import `apolloClientContext` from `'AppContexts'` @@ -72,6 +74,7 @@ function MyComponent() { ``` **Key points:** + - Use `useApolloClient()` hook from `'@apollo/client/react'` - Call the hook inside the component/hook function body - Never try to use `useApolloClient()` in loaders or actions (they're not React components) @@ -79,11 +82,13 @@ function MyComponent() { ### Common Mistakes to Avoid ❌ **Don't**: Import a global client instance + ```typescript import { client } from 'useIntercodeApolloClient'; // This no longer exists ``` ❌ **Don't**: Use `useApolloClient()` in loaders/actions + ```typescript export const loader: LoaderFunction = async () => { const client = useApolloClient(); // Error: hooks can't be used here @@ -91,6 +96,7 @@ export const loader: LoaderFunction = async () => { ``` ❌ **Don't**: Try to access `client` directly in loaders without getting it from context + ```typescript export const loader: LoaderFunction = async () => { const { data } = await client.query(...); // Error: client is not defined @@ -118,6 +124,7 @@ export const loader: LoaderFunction = async () => { ### Route Structure Routes follow a file-based convention similar to Remix/React Router v7: + - `route.tsx` or `index.tsx`: Default route component - `$id.ts`: Dynamic route segment - `loaders.ts`: Loader functions for the route @@ -251,10 +258,7 @@ import { useAppDateTimeFormat } from './TimeUtils'; function MyComponent() { const format = useAppDateTimeFormat(); - const formatted = format( - DateTime.fromISO(isoString, { zone: timezoneName }), - 'longWeekdayDateTimeWithZone' - ); + const formatted = format(DateTime.fromISO(isoString, { zone: timezoneName }), 'longWeekdayDateTimeWithZone'); } ``` @@ -269,12 +273,111 @@ const formattedPrice = formatMoney(priceInCents); ## Testing Considerations When modifying loader/action patterns: + 1. Ensure loaders use `LoaderFunction` 2. Ensure actions use `ActionFunction` 3. Always get the client from context in loaders/actions 4. Run `yarn run tsc --noEmit` to check for TypeScript errors 5. Test actual navigation flows to ensure data loading works +## End-to-End Testing with Playwright + +The project includes Playwright test infrastructure for browser-based end-to-end tests. The helpers are located in `playwright-tests/` and handle authentication, user creation, and permissions. + +### Quick Start + +```typescript +import { test, expect } from '@playwright/test'; +import { setupAndLogin } from './helpers/login'; + +test('can access admin page', async ({ page }) => { + const conventionDomain = 'myconvention.intercode.test'; + + await page.goto(`https://${conventionDomain}:5050/admin`); + + // Creates test user with admin permissions and logs in + await setupAndLogin(page, conventionDomain, ['update_convention']); + + await expect(page.locator('h1')).toBeVisible(); +}); +``` + +### Key Helpers + +**`setupAndLogin(page, conventionDomain, permissions?)`** + +- Creates a test user in the database +- Grants specified permissions (default: none) +- Logs in via the UI +- Reloads the page to ensure auth state is picked up + +**`ensureTestUser(conventionDomain, permissions?)`** + +- Creates/updates a test user via Rails +- Grants permissions via staff positions +- Returns credentials for manual login + +**`login(page, credentials)`** + +- Handles the UI login flow only +- Waits for login modal, fills credentials, submits + +### Permission System + +Tests must explicitly request permissions. Common permissions: + +- `update_convention` - Admin access to convention settings +- `read_schedule` - View schedules +- `update_events` - Manage events +- `manage_signups` - Manage user signups +- `read_reports` - View reports + +See `config/permission_names.json` for all available permissions. + +### Examples + +```typescript +// Regular user (no special permissions) +await setupAndLogin(page, 'mycon.test'); + +// Admin user +await setupAndLogin(page, 'mycon.test', ['update_convention']); + +// Multiple permissions +await setupAndLogin(page, 'mycon.test', ['update_events', 'read_schedule', 'manage_signups']); +``` + +### Environment Variables + +- `TEST_EMAIL` - Email for test user (default: `playwright-test@example.com`) +- `TEST_PASSWORD` - Password (default: `TestPassword123!`) +- `RAILS_ENV` - Rails environment (default: `development`) + +### Running Tests + +```bash +# Run all tests +yarn playwright test + +# Run specific test file +yarn playwright test my-test.spec.ts + +# Run with visible browser +yarn playwright test --headed + +# Debug mode +yarn playwright test --debug +``` + +### Best Practices + +1. **Always specify convention domain** - No hardcoded defaults +2. **Request minimum permissions** - Only grant what the test needs +3. **Test users are persistent** - Created once and reused across runs +4. **Use the UI for login** - Tests use actual login flow, not session manipulation + +See `playwright-tests/README.md` for comprehensive documentation. + ## Build and Development ```bash @@ -297,14 +400,17 @@ yarn test ## Common Errors and Solutions ### "Cannot find name 'client'" in loader/action + **Cause**: Trying to use a global `client` variable that doesn't exist. **Solution**: Get client from context using `context.get(apolloClientContext)`. ### "useApolloClient is defined but never used" in file with loader + **Cause**: File has loader/action that needs context-based client, not hook-based. **Solution**: Remove `useApolloClient` import, add `apolloClientContext` import, update loader signature. ### "Property 'instance' does not exist on type 'typeof AuthenticityTokensManager'" + **Cause**: Incorrect usage of AuthenticityTokensManager. **Solution**: Use `AuthenticityTokensContext` with `useContext` hook instead. diff --git a/app/javascript/SignupRoundsAdmin/CreateNewSignupRoundForm.tsx b/app/javascript/SignupRoundsAdmin/CreateNewSignupRoundForm.tsx index 71acae7744..896edb3531 100644 --- a/app/javascript/SignupRoundsAdmin/CreateNewSignupRoundForm.tsx +++ b/app/javascript/SignupRoundsAdmin/CreateNewSignupRoundForm.tsx @@ -8,8 +8,6 @@ import { ErrorDisplay, FormGroupWithLabel } from '@neinteractiveliterature/litfo import DateTimeInput from '../BuiltInFormControls/DateTimeInput'; import MaximumEventSignupsInput from './MaximumEventSignupsInput'; -import { SignupAutomationMode, SignupRoundAutomationAction } from 'graphqlTypes.generated'; - type CreateNewSignupRoundFormProps = { onCancel: () => void; }; @@ -34,15 +32,6 @@ export default function CreateNewSignupRoundForm({ onCancel }: CreateNewSignupRo
{t('signups.signupRounds.addNewSignupRound')}
- {(id) => ( diff --git a/app/javascript/UIComponents/ScheduledValuePreview.tsx b/app/javascript/UIComponents/ScheduledValuePreview.tsx index a3304c0fc8..83cdfd6839 100644 --- a/app/javascript/UIComponents/ScheduledValuePreview.tsx +++ b/app/javascript/UIComponents/ScheduledValuePreview.tsx @@ -211,8 +211,8 @@ function ScheduledValuePreviewCalendar({ } if (currentWeek.length > 0) { const weekPreview = [...currentWeek]; - while (currentWeek.length < 7) { - weekPreview.push(); + while (weekPreview.length < 7) { + weekPreview.push(); } weekPreviews.push({weekPreview}); } diff --git a/package.json b/package.json index fe379db647..1de76c8ebd 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "@graphql-codegen/typescript-operations": "5.0.4", "@graphql-codegen/typescript-react-apollo": "4.3.3", "@graphql-eslint/eslint-plugin": "4.4.0", + "@playwright/test": "^1.58.1", "@prettier/plugin-ruby": "4.0.4", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-node-resolve": "^16.0.0", @@ -209,6 +210,8 @@ "husky": "9.1.7", "jsdom": "^27.0.0", "lint-staged": "16.2.6", + "node-addon-api": "^8.5.0", + "node-gyp": "^12.2.0", "prettier": "3.6.2", "react-test-renderer": "19.2.0", "rollup": "^4.29.2", diff --git a/playwright-tests/README.md b/playwright-tests/README.md new file mode 100644 index 0000000000..833e3ac9df --- /dev/null +++ b/playwright-tests/README.md @@ -0,0 +1,250 @@ +# Playwright Test Helpers + +This directory contains reusable helpers for Playwright end-to-end tests. + +## Setup + +Playwright is already installed. To install browsers: + +```bash +yarn playwright install chromium +``` + +## Files + +- `helpers/login.ts` - Login utilities for authentication flows +- `helpers/database-setup.ts` - Database helpers for creating test users +- `helpers/create_test_user.rb` - Ruby script that creates/updates test users + +## Quick Start + +```typescript +import { test, expect } from '@playwright/test'; +import { setupAndLogin } from './helpers/login'; + +test('can access admin page', async ({ page }) => { + // Navigate to the page + const conventionDomain = 'alarpfestival2026.intercode.test'; + await page.goto(`https://${conventionDomain}:5050/signup_rounds`); + + // This creates a test user and logs in + // For admin pages, grant the necessary permissions + await setupAndLogin(page, conventionDomain, ['update_convention']); + + // Page reloads after login to pick up auth state + await expect(page.locator('h1')).toContainText('Signup Rounds'); +}); +``` + +## Helpers + +### `setupAndLogin(page, conventionDomain, permissions?)` + +One-stop function that: + +1. Creates/updates a test user in the database +2. Grants the user staff permissions for the specified convention (if any) +3. Waits for the login modal and fills in credentials +4. Submits the login form +5. Waits for the modal to close +6. Reloads the page to ensure auth state is picked up + +**Parameters:** + +- `page` - Playwright Page object +- `conventionDomain` - **Required** convention domain (e.g., `'myconvention.intercode.test'`) +- `permissions` - Optional array of permission names (default: `[]` - no special permissions) + +```typescript +// Regular user (no special permissions) +const credentials = await setupAndLogin(page, 'myconvention.intercode.test'); + +// Admin user +const credentials = await setupAndLogin(page, 'myconvention.intercode.test', ['update_convention']); + +// User with specific permissions +const credentials = await setupAndLogin(page, 'myconvention.intercode.test', ['read_schedule', 'update_events']); + +// credentials contains: { email, password, firstName, lastName, conventionDomain } +``` + +### `login(page, credentials)` + +Just handles the UI login flow without creating a user: + +```typescript +await login(page, { email: 'user@example.com', password: 'password' }); +``` + +### `ensureTestUser(conventionDomain, permissions?)` + +Creates/updates a test user in the database via Rails. + +**Parameters:** + +- `conventionDomain` - **Required** convention domain (e.g., `'convention.intercode.test'`) +- `permissions` - Optional array of permission names (default: `[]` - no permissions) + +```typescript +// Create user with specific permissions +const creds = await ensureTestUser('convention.intercode.test', ['update_convention']); + +// Create user without any permissions +const creds = await ensureTestUser('convention.intercode.test'); +``` + +**Environment Variables:** + +- `TEST_EMAIL` - Email for the test user (default: `playwright-test@example.com`) +- `TEST_PASSWORD` - Password (default: `TestPassword123!`) +- `RAILS_ENV` - Rails environment (default: `development`) + +### `cleanupTestUser(email?)` + +Removes a test user from the database: + +```typescript +await cleanupTestUser('playwright-test@example.com'); +``` + +## Individual Login Functions + +For more control, use the granular functions: + +```typescript +import { waitForLoginModal, fillLoginForm, submitLoginForm, waitForLoginModalToClose } from './helpers/login'; + +// Wait for modal to appear +await waitForLoginModal(page); + +// Fill in credentials +await fillLoginForm(page, { email, password }); + +// Submit the form +await submitLoginForm(page); + +// Wait for it to close (and close any success notifications) +await waitForLoginModalToClose(page); +``` + +## Example Tests + +### Regular User Test (No Admin Permissions) + +Create a file `playwright-tests/my-test.spec.ts`: + +```typescript +import { test, expect } from '@playwright/test'; +import { setupAndLogin } from './helpers/login'; + +test('regular user can view homepage', async ({ page }) => { + const conventionDomain = 'myconvention.intercode.test'; + + await page.goto(`https://${conventionDomain}:5050/`); + + // Login as regular user (no special permissions - this is the default) + await setupAndLogin(page, conventionDomain); + + await expect(page.locator('h1')).toBeVisible(); +}); +``` + +### Admin Test with Permissions + +```typescript +test('admin can access admin page', async ({ page }) => { + const conventionDomain = 'myconvention.intercode.test'; + + await page.goto(`https://${conventionDomain}:5050/admin`); + + // Login with admin permission + await setupAndLogin(page, conventionDomain, ['update_convention']); + + await expect(page.locator('h1')).toContainText('Admin'); +}); +``` + +### Test with Custom Permissions + +```typescript +test('can manage events', async ({ page }) => { + const conventionDomain = 'myconvention.intercode.test'; + + await page.goto(`https://${conventionDomain}:5050/events`); + + // Login with specific permissions + await setupAndLogin(page, conventionDomain, ['update_events', 'update_event_categories', 'read_schedule']); + + // User now has permission to manage events + await page.click('button:has-text("Create Event")'); +}); +``` + +### Test with Multiple Permissions + +```typescript +test('staff member can manage multiple areas', async ({ page }) => { + const conventionDomain = 'myconvention.intercode.test'; + + await page.goto(`https://${conventionDomain}:5050/`); + + // Login with multiple permissions + await setupAndLogin(page, conventionDomain, ['update_convention', 'read_reports', 'manage_signups']); + + // Test staff functionality + await expect(page.locator('.admin-menu')).toBeVisible(); +}); +``` + +Run tests: + +```bash +yarn playwright test my-test.spec.ts +``` + +## Configuration + +See `playwright.config.ts` in the project root for Playwright settings. + +## Available Permissions + +Common permissions you can grant to test users: + +- `update_convention` - Can update convention settings (required for most admin pages) +- `read_schedule` - Can view the schedule +- `update_events` - Can create/edit events +- `update_event_categories` - Can manage event categories +- `read_reports` - Can view reports +- `manage_signups` - Can manage user signups +- `read_user_con_profiles` - Can view user profiles +- `update_user_con_profiles` - Can edit user profiles + +To find all available permissions, check `config/permission_names.json` in the project root or inspect the `Permission` model. + +## Troubleshooting + +**"Your account is not authorized to view this page"** + +- The test user was created but the page still shows unauthorized. This can happen if: + - The permissions weren't created correctly (check with `bundle exec rails console` and inspect the user) + - The page auth check runs before permissions are loaded + - Try adding `await page.reload()` after login + +**Login modal doesn't appear** + +- Make sure you're navigating to a page that requires authentication +- Check that the development server is running + +**Test times out** + +- Increase timeout in `playwright.config.ts` +- Use `page.pause()` to debug interactively +- Check browser DevTools by running with `--headed` flag: `yarn playwright test --headed` + +## Tips + +- Test users are persistent across runs - they're created once and reused +- Login happens via the UI (not session manipulation) for more realistic testing +- Use `--headed` to watch tests run in a real browser +- Use `--debug` to step through tests interactively +- The test user gets staff permissions (update_convention) automatically diff --git a/playwright-tests/helpers/create_test_user.rb b/playwright-tests/helpers/create_test_user.rb new file mode 100755 index 0000000000..2f3f18bd8e --- /dev/null +++ b/playwright-tests/helpers/create_test_user.rb @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Creates or updates a test user for Playwright tests +# Usage: rails runner playwright-tests/helpers/create_test_user.rb \ +# [email] [password] [convention_domain] [permission1] [permission2] ... +# +# Examples: +# # Grant update_convention permission +# rails runner create_test_user.rb user@example.com password123 mycon.test update_convention +# +# # Grant multiple permissions +# rails runner create_test_user.rb user@example.com password123 mycon.test update_convention read_schedule +# +# # No permissions (just create the user) +# rails runner create_test_user.rb user@example.com password123 mycon.test + +email = ARGV[0] || ENV["TEST_EMAIL"] || "playwright-test@example.com" +password = ARGV[1] || ENV["TEST_PASSWORD"] || "TestPassword123!" +convention_domain = ARGV[2] || raise("Convention domain is required") +# Any additional arguments are treated as permissions +requested_permissions = ARGV[3..] || [] + +# Find or create the user +user = User.find_or_initialize_by(email: email) + +if user.new_record? + user.first_name = "Playwright" + user.last_name = "Test" + user.password = password + user.password_confirmation = password + user.save! + puts "Created new test user: #{email}" +else + # Update password in case it changed + user.password = password + user.password_confirmation = password + user.save! + puts "Updated existing test user: #{email}" +end + +# Find the convention +convention = Convention.find_by(domain: convention_domain) + +if convention.nil? + puts "WARNING: Convention with domain #{convention_domain} not found" + puts "Available conventions:" + Convention.limit(5).each { |c| puts " - #{c.domain}" } + exit 0 +end + +# Ensure user has a profile for this convention +profile = UserConProfile.find_or_initialize_by(user_id: user.id, convention_id: convention.id) + +if profile.new_record? + profile.first_name = user.first_name + profile.last_name = user.last_name + profile.save! + puts "Created convention profile for #{email}" +else + puts "Convention profile already exists for #{email}" +end + +# Grant permissions if any were requested +if requested_permissions.any? + staff_position = convention.staff_positions.find_or_create_by!(name: "Playwright Test Staff") + + # Create each requested permission + requested_permissions.each do |permission_name| + if staff_position.permissions.exists?(permission: permission_name) + puts "Staff position already has #{permission_name} permission" + else + staff_position.permissions.create!(permission: permission_name) + puts "Granted #{permission_name} permission to staff position" + end + end + + # Associate the user with the staff position + if profile.staff_positions.include?(staff_position) + puts "User already has staff permissions" + else + profile.staff_positions << staff_position + puts "Granted staff permissions to #{email}" + end +else + puts "No permissions requested - user created without staff position" +end + +puts "✓ Test user ready: #{email}" diff --git a/playwright-tests/helpers/database-setup.ts b/playwright-tests/helpers/database-setup.ts new file mode 100644 index 0000000000..0445643c24 --- /dev/null +++ b/playwright-tests/helpers/database-setup.ts @@ -0,0 +1,107 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; + +const execAsync = promisify(exec); + +export interface TestUserCredentials { + email: string; + password: string; + firstName: string; + lastName: string; + conventionDomain: string; +} + +/** + * Creates or finds a test user in the database via Rails console + * Returns the user credentials that can be used for login + * + * @param conventionDomain - The convention domain (e.g., 'myconvention.intercode.test') + * @param permissions - Array of permission names to grant (e.g., ['update_convention', 'read_schedule']) + * If not specified, user is created without any permissions + */ +export async function ensureTestUser( + conventionDomain: string, + permissions: string[] = [], +): Promise { + const email = process.env.TEST_EMAIL || 'playwright-test@example.com'; + const password = process.env.TEST_PASSWORD || 'TestPassword123!'; + const firstName = 'Playwright'; + const lastName = 'Test'; + + try { + // Path to the Ruby script + const scriptPath = path.join(__dirname, 'create_test_user.rb'); + + // Build command with permissions as additional arguments + const permissionsArgs = permissions.map((p) => `"${p}"`).join(' '); + const command = `bundle exec rails runner ${scriptPath} "${email}" "${password}" "${conventionDomain}" ${permissionsArgs}`; + + const { stdout, stderr } = await execAsync(command, { + cwd: path.resolve(__dirname, '../../'), // Project root + env: { ...process.env, RAILS_ENV: process.env.RAILS_ENV || 'development' }, + }); + + if (stdout) { + console.log(stdout); + } + + if (stderr && !stderr.includes('warning')) { + console.error('Rails stderr:', stderr); + } + + return { + email, + password, + firstName, + lastName, + conventionDomain, + }; + } catch (error) { + const err = error as { message: string; stdout?: string; stderr?: string }; + console.error('Failed to create test user:', err.message); + if (err.stdout) console.log('stdout:', err.stdout); + if (err.stderr) console.error('stderr:', err.stderr); + throw new Error(`Failed to setup test user: ${err.message}`); + } +} + +/** + * Cleans up the test user from the database + */ +export async function cleanupTestUser(email?: string): Promise { + const userEmail = email || process.env.TEST_EMAIL || 'playwright-test@example.com'; + + // Escape for safe inclusion in a Ruby single-quoted string: first backslashes, then single quotes + const escapedEmailForRuby = userEmail.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + + const rubyScript = ` + email = '${escapedEmailForRuby}' + user = User.find_by(email: email) + + if user + user.destroy! + puts "Deleted test user: #{email}" + else + puts "Test user not found: #{email}" + end + `; + + try { + // Escape backslashes and double quotes so the script is safe inside a double-quoted shell argument + const shellSafeRubyScript = rubyScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const command = `bundle exec rails runner -e "${shellSafeRubyScript}"`; + const { stdout } = await execAsync(command, { + cwd: path.resolve(__dirname, '../../'), // Project root + env: { ...process.env, RAILS_ENV: process.env.RAILS_ENV || 'development' }, + }); + + if (stdout) { + console.log(stdout); + } + } catch (error) { + const err = error as { message: string }; + console.error('Failed to cleanup test user:', err.message); + // Don't throw - cleanup failures shouldn't break tests + } +} diff --git a/playwright-tests/helpers/login.ts b/playwright-tests/helpers/login.ts new file mode 100644 index 0000000000..4558de3f6b --- /dev/null +++ b/playwright-tests/helpers/login.ts @@ -0,0 +1,103 @@ +import { Page } from '@playwright/test'; +import { ensureTestUser, TestUserCredentials } from './database-setup'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export async function waitForLoginModal(page: Page, timeout = 5000): Promise { + console.log('Waiting for login modal...'); + await page.waitForSelector('.modal.show', { timeout }); + console.log('Login modal visible'); +} + +export async function fillLoginForm(page: Page, credentials: LoginCredentials): Promise { + console.log(`Filling login form with email: ${credentials.email}`); + await page.fill('input[type="email"]', credentials.email); + await page.fill('input[type="password"]', credentials.password); +} + +export async function submitLoginForm(page: Page): Promise { + console.log('Submitting login form...'); + await page.click('input[type="submit"], button[type="submit"]'); +} + +export async function waitForLoginModalToClose(page: Page, timeout = 10000): Promise { + console.log('Waiting for login modal to close...'); + try { + await page.waitForSelector('.modal.show', { state: 'hidden', timeout }); + console.log('Login modal closed'); + + // Sometimes there's a success notification modal - close it if present + const remainingModal = page.locator('.modal.show'); + const isVisible = await remainingModal.isVisible().catch(() => false); + if (isVisible) { + console.log('Closing success notification...'); + // Click the X button or anywhere on the backdrop + const closeButton = page.locator('.modal.show button[data-bs-dismiss="modal"], .modal.show .btn-close'); + if (await closeButton.isVisible().catch(() => false)) { + await closeButton.click(); + } else { + // Click outside the modal on the backdrop + await page.locator('.modal-backdrop').click({ force: true }); + } + await page.waitForSelector('.modal.show', { state: 'hidden', timeout: 5000 }); + } + } catch (e) { + console.error('Login modal did not close within timeout'); + throw e; + } +} + +/** + * Complete login flow using provided credentials + */ +export async function login(page: Page, credentials: LoginCredentials): Promise { + await waitForLoginModal(page); + await fillLoginForm(page, credentials); + await submitLoginForm(page); + await waitForLoginModalToClose(page); + + // Reload the page to ensure auth state is picked up + console.log('Reloading page to refresh auth state...'); + await page.reload({ waitUntil: 'networkidle' }); + + console.log('Login complete'); +} + +/** + * Setup a test user in the database and log in with it + * This is the recommended way to handle authentication in tests + * + * @param page - Playwright page object + * @param conventionDomain - The convention domain (e.g., 'myconvention.intercode.test') + * @param permissions - Array of permission names to grant (e.g., ['update_convention', 'read_schedule']) + * Default: [] (no special permissions - regular user) + * @returns The test user credentials that were used + * + * @example + * test('my test', async ({ page }) => { + * await page.goto('https://example.intercode.test:5050/some-page'); + * + * // Regular user (no special permissions) + * const credentials = await setupAndLogin(page, 'example.intercode.test'); + * + * // With admin permissions + * const credentials = await setupAndLogin(page, 'example.intercode.test', ['update_convention']); + * + * // With custom permissions + * const credentials = await setupAndLogin(page, 'example.intercode.test', ['read_schedule', 'update_events']); + * }); + */ +export async function setupAndLogin( + page: Page, + conventionDomain: string, + permissions: string[] = [], +): Promise { + console.log('Setting up test user...'); + const credentials = await ensureTestUser(conventionDomain, permissions); + console.log('Test user ready, logging in...'); + await login(page, credentials); + return credentials; +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..5641b83cf1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './playwright-tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: 'html', + use: { + baseURL: 'https://alarpfestival2026.intercode.test:5050', + trace: 'on-first-retry', + ignoreHTTPSErrors: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + timeout: 30000, +}); diff --git a/yarn.lock b/yarn.lock index 683e4f3554..07e91576ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6440,6 +6440,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.58.1": + version: 1.58.1 + resolution: "@playwright/test@npm:1.58.1" + dependencies: + playwright: "npm:1.58.1" + bin: + playwright: cli.js + checksum: 10c0/ca32be812c6f86b2247109eaecd2fed452414debee05b4b0d690a3397f6bd08a56e0b2484f74d20fa0e7494508ee1cbdcbc27864acd5093e34c3f94d0e278188 + languageName: node + linkType: hard + "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -14228,6 +14239,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -14238,6 +14259,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -18389,6 +18419,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^8.5.0": + version: 8.5.0 + resolution: "node-addon-api@npm:8.5.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/e4de0b4e70998fed7ef41933946f60565fc3a17cb83b7d626a0c0bb1f734cf7852e0e596f12681e7c8ed424163ee3cdbb4f0abaa9cc269d03f48834c263ba162 + languageName: node + linkType: hard + "node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" @@ -18440,6 +18479,26 @@ __metadata: languageName: node linkType: hard +"node-gyp@npm:^12.2.0": + version: 12.2.0 + resolution: "node-gyp@npm:12.2.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + make-fetch-happen: "npm:^15.0.0" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/3ed046746a5a7d90950cd8b0547332b06598443f31fe213ef4332a7174c7b7d259e1704835feda79b87d3f02e59d7791842aac60642ede4396ab25fdf0f8f759 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 12.1.0 resolution: "node-gyp@npm:12.1.0" @@ -19354,6 +19413,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.58.1": + version: 1.58.1 + resolution: "playwright-core@npm:1.58.1" + bin: + playwright-core: cli.js + checksum: 10c0/2c12755579148cbd13811cc1a01e9693432f0e4595c76ebb02d2e1b4ee7286719c6769fdb26cda61f218bc49b7ddd4de5d856abbd034acde4ff3dbeee93e4773 + languageName: node + linkType: hard + +"playwright@npm:1.58.1": + version: 1.58.1 + resolution: "playwright@npm:1.58.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.58.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/29cb2b34ad80f9dc1b27d26d8cf56e0964d7787e0beb18b25fd9d087a09ce56a359779104d2a1717d08789c2f2713928ef59140b2905e6ef00b2cb6df58bb107 + languageName: node + linkType: hard + "popmotion@npm:11.0.3": version: 11.0.3 resolution: "popmotion@npm:11.0.3" @@ -22057,6 +22140,7 @@ __metadata: "@lezer/highlight": "npm:^1.2.1" "@lezer/lr": "npm:^1.4.2" "@neinteractiveliterature/litform": "npm:0.35.0" + "@playwright/test": "npm:^1.58.1" "@popperjs/core": "npm:^2.11.8" "@prettier/plugin-ruby": "npm:4.0.4" "@rails/activestorage": "npm:8.1.100" @@ -22166,6 +22250,8 @@ __metadata: md5: "npm:2.3.0" mini-css-extract-plugin: "npm:2.9.4" minimist: "npm:1.2.8" + node-addon-api: "npm:^8.5.0" + node-gyp: "npm:^12.2.0" ol: "npm:^10.2.1" path-browserify: "npm:1.0.1" path-complete-extname: "npm:1.0.0" @@ -23689,7 +23775,7 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.5.2": +"tar@npm:^7.5.2, tar@npm:^7.5.4": version: 7.5.7 resolution: "tar@npm:7.5.7" dependencies: