From 0a254e8c47155265d8dcc12ac00c34943b108e52 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 12:48:12 -0800 Subject: [PATCH 1/8] Add required build dependencies --- package.json | 2 ++ yarn.lock | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index fe379db647..59585343e3 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,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/yarn.lock b/yarn.lock index 683e4f3554..63efb688c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18389,6 +18389,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 +18449,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" @@ -22166,6 +22195,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 +23720,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: From f5ca91bc77f30ff17d8b377633cd64735bfa0b89 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 13:28:52 -0800 Subject: [PATCH 2/8] Add Playwright test infrastructure with configurable user permissions - Add Playwright as dev dependency - Create reusable login helpers for authentication flows - Add database setup utilities for creating test users - Implement configurable permission system via command-line args - Add comprehensive README with usage examples Key features: - setupAndLogin() - one-stop function to create user and login - ensureTestUser() - creates users with specified permissions - No hardcoded convention domains (must be specified per test) - Default: creates regular users with no special permissions - Tests can grant specific permissions as needed Usage: await setupAndLogin(page, 'mycon.test', ['update_convention']); Co-Authored-By: Claude Sonnet 4.5 --- package.json | 1 + playwright-tests/README.md | 250 +++++++++++++++++++ playwright-tests/helpers/create_test_user.rb | 89 +++++++ playwright-tests/helpers/database-setup.ts | 102 ++++++++ playwright-tests/helpers/login.ts | 103 ++++++++ playwright.config.ts | 22 ++ yarn.lock | 55 ++++ 7 files changed, 622 insertions(+) create mode 100644 playwright-tests/README.md create mode 100755 playwright-tests/helpers/create_test_user.rb create mode 100644 playwright-tests/helpers/database-setup.ts create mode 100644 playwright-tests/helpers/login.ts create mode 100644 playwright.config.ts diff --git a/package.json b/package.json index 59585343e3..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", 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..dd58b4119d --- /dev/null +++ b/playwright-tests/helpers/database-setup.ts @@ -0,0 +1,102 @@ +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'; + + const rubyScript = ` + email = '${userEmail.replace(/'/g, "\\'")}' + user = User.find_by(email: email) + + if user + user.destroy! + puts "Deleted test user: #{email}" + else + puts "Test user not found: #{email}" + end + `; + + try { + const command = `bundle exec rails runner -e "${rubyScript}"`; + 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 63efb688c3..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" @@ -19383,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" @@ -22086,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" From e3705350a85af00b74966df5b91476ac6fbd0410 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 13:30:31 -0800 Subject: [PATCH 3/8] Document Playwright test infrastructure and ignore test results - Add /test-results and /playwright-report to .gitignore - Add comprehensive Playwright section to CLAUDE.md with: - Quick start guide with code examples - Documentation of key helpers (setupAndLogin, ensureTestUser, login) - Permission system explanation - Environment variables reference - Running tests commands - Best practices Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 2 + CLAUDE.md | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 4 deletions(-) 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. From 69647e975401bbbabad492988b1694b9f331b452 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 13:31:47 -0800 Subject: [PATCH 4/8] Don't try to add a default automation action, because it doesn't actually work --- .../SignupRoundsAdmin/CreateNewSignupRoundForm.tsx | 11 ----------- 1 file changed, 11 deletions(-) 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) => ( From aa149e7c9c7317f843c4af93df4bc636e3ee5df0 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 13:56:18 -0800 Subject: [PATCH 5/8] Fix infinite loop in ScheduledValuePreview calendar rendering The while loop on line 214 was checking currentWeek.length < 7 but pushing elements to weekPreview. This caused an infinite loop that crashed the browser tab when rendering signup rounds with certain date configurations. Changed the loop condition to check weekPreview.length instead, which properly increments as elements are added. Co-Authored-By: Claude Sonnet 4.5 --- app/javascript/UIComponents/ScheduledValuePreview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}); } From 397900089db0da59b7202383f6e1aa33ee5271a5 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 13:59:21 -0800 Subject: [PATCH 6/8] Potential fix for code scanning alert no. 4: Incomplete string escaping or encoding Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- playwright-tests/helpers/database-setup.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/playwright-tests/helpers/database-setup.ts b/playwright-tests/helpers/database-setup.ts index dd58b4119d..c2576b6f4b 100644 --- a/playwright-tests/helpers/database-setup.ts +++ b/playwright-tests/helpers/database-setup.ts @@ -72,8 +72,11 @@ export async function ensureTestUser( 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 = '${userEmail.replace(/'/g, "\\'")}' + email = '${escapedEmailForRuby}' user = User.find_by(email: email) if user @@ -85,7 +88,9 @@ export async function cleanupTestUser(email?: string): Promise { `; try { - const command = `bundle exec rails runner -e "${rubyScript}"`; + // Escape double quotes so the script is safe inside a double-quoted shell argument + const shellSafeRubyScript = rubyScript.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' }, From 99b12dd5933108d79da4ed935276b54f9e5cef8f Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 14:06:44 -0800 Subject: [PATCH 7/8] Try to make the base coverage available by archiving it --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From a3b1dd4fdc38f7c542c1f6a5f1522efcb72c7387 Mon Sep 17 00:00:00 2001 From: Nat Budin Date: Sat, 31 Jan 2026 14:12:22 -0800 Subject: [PATCH 8/8] Potential fix for code scanning alert no. 5: Incomplete string escaping or encoding Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- playwright-tests/helpers/database-setup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playwright-tests/helpers/database-setup.ts b/playwright-tests/helpers/database-setup.ts index c2576b6f4b..0445643c24 100644 --- a/playwright-tests/helpers/database-setup.ts +++ b/playwright-tests/helpers/database-setup.ts @@ -88,8 +88,8 @@ export async function cleanupTestUser(email?: string): Promise { `; try { - // Escape double quotes so the script is safe inside a double-quoted shell argument - const shellSafeRubyScript = rubyScript.replace(/"/g, '\\"'); + // 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