diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 1a215827..d3a4f47e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -19,7 +19,7 @@ on: jobs: e2e: - timeout-minutes: 30 + timeout-minutes: 20 runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.57.0-noble @@ -59,23 +59,35 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' + cache: 'npm' + + - name: Restore Next.js build cache + uses: actions/cache@v4 + with: + path: packages/web/.next/cache + key: nextjs-${{ runner.os }}-${{ hashFiles('packages/web/**/*.ts', 'packages/web/**/*.tsx', 'packages/web/**/*.css') }} + restore-keys: | + nextjs-${{ runner.os }}- - name: Install dependencies run: npm ci --legacy-peer-deps - - name: Build shared-schema - run: npm run build --workspace=@boardsesh/shared-schema + - name: Build shared-schema and crypto + run: | + npm run build --workspace=@boardsesh/shared-schema & + npm run build --workspace=@boardsesh/crypto & + wait - name: Build db package run: npm run build --workspace=@boardsesh/db - - name: Build backend - run: npm run build --workspace=boardsesh-backend - - - name: Build web application - run: npm run build --workspace=@boardsesh/web + - name: Build backend and web application + run: | + npm run build --workspace=boardsesh-backend & + BACKEND_PID=$! + npm run build --workspace=@boardsesh/web + wait $BACKEND_PID env: - # Provide minimal env vars needed for build NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main @@ -90,9 +102,8 @@ jobs: - name: Start backend and web server, then run E2E tests run: | npm run start --workspace=boardsesh-backend & - npx wait-on http-get://localhost:8080/health --timeout 30000 npm run start --workspace=@boardsesh/web & - npx wait-on http://localhost:3000 --timeout 60000 + npx wait-on http-get://localhost:8080/health http://localhost:3000 --timeout 60000 npm run test:e2e --workspace=@boardsesh/web env: PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000 diff --git a/.gitignore b/.gitignore index 7345c4b5..0dd054ba 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ node_modules # testing coverage +**/e2e/.auth/ # next.js .next/ diff --git a/packages/web/e2e/activity-feed-authenticated.authenticated.spec.ts b/packages/web/e2e/activity-feed-authenticated.authenticated.spec.ts new file mode 100644 index 00000000..1aca999a --- /dev/null +++ b/packages/web/e2e/activity-feed-authenticated.authenticated.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; + +/** + * Authenticated E2E tests for the home page sessions feed. + * + * These tests use the pre-authenticated storageState from auth.setup.ts, + * so no login flow is needed in beforeEach. + */ + +test.describe('Sessions Feed - Authenticated', () => { + test('renders personalized feed without sign-in alert', async ({ page }) => { + await page.goto('/'); + + // The Sessions tab should be active by default + const sessionsTab = page.getByRole('tab', { name: 'Sessions' }); + await expect(sessionsTab).toBeVisible({ timeout: 15000 }); + await expect(sessionsTab).toHaveAttribute('aria-selected', 'true'); + + // Wait for feed items to render + const feedItems = page.locator('[data-testid="activity-feed-item"]'); + await expect(feedItems.first()).toBeVisible({ timeout: 30000 }); + + // Should NOT show the "Sign in" alert + await expect(page.getByText('Sign in to see a personalized feed')).not.toBeVisible(); + }); + + test('infinite scroll pagination works with authenticated feed', async ({ page }) => { + await page.goto('/'); + + // Wait for initial items to render + const feedItems = page.locator('[data-testid="activity-feed-item"]'); + await expect(feedItems.first()).toBeVisible({ timeout: 30000 }); + + const initialCount = await feedItems.count(); + expect(initialCount).toBeGreaterThan(0); + + // Scroll the sentinel element into view to trigger loading more + const sentinel = page.locator('[data-testid="activity-feed-sentinel"]'); + await sentinel.scrollIntoViewIfNeeded(); + + // Wait for more items to appear + await expect(async () => { + const newCount = await feedItems.count(); + expect(newCount).toBeGreaterThan(initialCount); + }).toPass({ timeout: 15000 }); + }); +}); diff --git a/packages/web/e2e/activity-feed-infinite-scroll.spec.ts b/packages/web/e2e/activity-feed-infinite-scroll.spec.ts index 2f561014..5d4167f6 100644 --- a/packages/web/e2e/activity-feed-infinite-scroll.spec.ts +++ b/packages/web/e2e/activity-feed-infinite-scroll.spec.ts @@ -152,48 +152,5 @@ test.describe('Tab Navigation', () => { }); }); -test.describe('Sessions Feed - Authenticated', () => { - test.beforeEach(async ({ page }) => { - // Log in via the auth login form - await page.goto('/auth/login'); - await page.getByLabel('Email').fill('test@boardsesh.com'); - await page.getByLabel('Password').fill('test'); - await page.getByRole('button', { name: 'Login' }).click(); - - // Wait for redirect to home page after login - await page.waitForURL('/', { timeout: 15000 }); - }); - - test('renders personalized feed without sign-in alert', async ({ page }) => { - // The Sessions tab should be active by default - const sessionsTab = page.getByRole('tab', { name: 'Sessions' }); - await expect(sessionsTab).toBeVisible({ timeout: 15000 }); - await expect(sessionsTab).toHaveAttribute('aria-selected', 'true'); - - // Wait for feed items to render - const feedItems = page.locator('[data-testid="activity-feed-item"]'); - await expect(feedItems.first()).toBeVisible({ timeout: 30000 }); - - // Should NOT show the "Sign in" alert - await expect(page.getByText('Sign in to see a personalized feed')).not.toBeVisible(); - }); - - test('infinite scroll pagination works with authenticated feed', async ({ page }) => { - // Wait for initial items to render - const feedItems = page.locator('[data-testid="activity-feed-item"]'); - await expect(feedItems.first()).toBeVisible({ timeout: 30000 }); - - const initialCount = await feedItems.count(); - expect(initialCount).toBeGreaterThan(0); - - // Scroll the sentinel element into view to trigger loading more - const sentinel = page.locator('[data-testid="activity-feed-sentinel"]'); - await sentinel.scrollIntoViewIfNeeded(); - - // Wait for more items to appear - await expect(async () => { - const newCount = await feedItems.count(); - expect(newCount).toBeGreaterThan(initialCount); - }).toPass({ timeout: 15000 }); - }); -}); +// Authenticated tests moved to activity-feed-authenticated.authenticated.spec.ts +// They use Playwright's storageState for pre-authenticated sessions. diff --git a/packages/web/e2e/auth.setup.ts b/packages/web/e2e/auth.setup.ts new file mode 100644 index 00000000..fd91c7de --- /dev/null +++ b/packages/web/e2e/auth.setup.ts @@ -0,0 +1,22 @@ +import { test as setup, expect } from '@playwright/test'; +import path from 'path'; + +const authFile = path.join(__dirname, '.auth', 'user.json'); + +setup('authenticate', async ({ page }) => { + const testEmail = process.env.TEST_USER_EMAIL; + const testPassword = process.env.TEST_USER_PASSWORD; + + setup.skip(!testEmail || !testPassword, 'Set TEST_USER_EMAIL and TEST_USER_PASSWORD env vars'); + + await page.goto('/auth/login'); + await page.getByLabel('Email').fill(testEmail!); + await page.getByLabel('Password').fill(testPassword!); + await page.getByRole('button', { name: 'Login' }).click(); + + // Wait for redirect to home page after login + await page.waitForURL('/', { timeout: 15000 }); + + // Save signed-in state + await page.context().storageState({ path: authFile }); +}); diff --git a/packages/web/e2e/help-screenshots-authenticated.authenticated.spec.ts b/packages/web/e2e/help-screenshots-authenticated.authenticated.spec.ts new file mode 100644 index 00000000..2274662b --- /dev/null +++ b/packages/web/e2e/help-screenshots-authenticated.authenticated.spec.ts @@ -0,0 +1,101 @@ +/** + * Authenticated Help Page Screenshot Generation Tests + * + * These tests use the pre-authenticated storageState from auth.setup.ts, + * so no login flow is needed in beforeEach. + * + * Run with 1Password CLI: + * TEST_USER_EMAIL=$(op read "op://Boardsesh/Boardsesh local/username") \ + * TEST_USER_PASSWORD=$(op read "op://Boardsesh/Boardsesh local/password") \ + * npx playwright test e2e/help-screenshots-authenticated.authenticated.spec.ts + */ +import { test, expect } from '@playwright/test'; + +const SCREENSHOT_DIR = 'public/help'; +const boardUrl = '/kilter/original/12x12-square/screw_bolt/40/list'; + +test.describe('Help Page Screenshots - Authenticated', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + const testEmail = process.env.TEST_USER_EMAIL; + const testPassword = process.env.TEST_USER_PASSWORD; + + test.skip(!testEmail || !testPassword, 'Set TEST_USER_EMAIL and TEST_USER_PASSWORD env vars to run authenticated tests'); + + test.beforeEach(async ({ page }) => { + await page.goto(boardUrl); + await page.waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) + .catch(() => page.waitForLoadState('domcontentloaded')); + }); + + test('personal progress filters', async ({ page }) => { + // Open search drawer to show filters including personal progress + await page.locator('#onboarding-search-button').click(); + await page.getByText('Grade').first().waitFor({ state: 'visible' }); + + // Expand the Progress section + await page.getByText('Progress').click(); + + await page.screenshot({ path: `${SCREENSHOT_DIR}/personal-progress.png` }); + }); + + test('party mode active session', async ({ page, context }) => { + test.slow(); // WebSocket connection setup can be slow in CI + + // Grant geolocation permission so session creation doesn't wait for permission prompt + await context.grantPermissions(['geolocation']); + + // First add a climb to queue so the queue bar appears + const climbCard = page.locator('#onboarding-climb-card'); + await climbCard.dblclick(); + + // Wait for queue bar + const queueBar = page.locator('[data-testid="queue-control-bar"]'); + await expect(queueBar).toBeVisible({ timeout: 10000 }); + + // Open party mode drawer + await page.locator('[data-testid="queue-control-bar"]').getByLabel('Party Mode').click(); + await page.locator('[data-swipeable-drawer="true"]:visible').first().waitFor({ timeout: 10000 }); + + // Start a party session + await page.getByRole('button', { name: 'Start Party Mode' }).click(); + + // Wait for session to be active - WebSocket connection needs time to establish + await page.waitForSelector('button:has-text("Leave")', { state: 'visible', timeout: 30000 }); + + await page.screenshot({ path: `${SCREENSHOT_DIR}/party-mode-active.png` }); + + // Leave the session to clean up + await page.getByRole('button', { name: 'Leave' }).click(); + }); + + test('hold classification wizard', async ({ page }) => { + // Open user drawer and click Classify Holds + await page.getByLabel('User menu').click(); + await page.getByText('Classify Holds').waitFor({ state: 'visible' }); + await page.getByText('Classify Holds').click(); + + // Wait for wizard content to load + await page.waitForSelector('.MuiRating-root, .MuiLinearProgress-root', { state: 'visible', timeout: 10000 }); + + await page.screenshot({ path: `${SCREENSHOT_DIR}/hold-classification.png` }); + }); + + test('settings aurora sync', async ({ page }) => { + // Navigate to settings page + await page.goto('/settings'); + // Wait for settings page content to load + await page.waitForSelector('.MuiCard-root', { state: 'visible' }); + + // Scroll to Board Accounts section + await page.evaluate(() => { + const heading = Array.from(document.querySelectorAll('h4, .MuiCardHeader-title')) + .find(el => el.textContent?.includes('Board Accounts')); + if (heading) { + heading.scrollIntoView({ behavior: 'instant', block: 'start' }); + } + }); + + await page.screenshot({ path: `${SCREENSHOT_DIR}/settings-aurora.png` }); + }); +}); diff --git a/packages/web/e2e/help-screenshots.spec.ts b/packages/web/e2e/help-screenshots.spec.ts index 3c296210..1ac50351 100644 --- a/packages/web/e2e/help-screenshots.spec.ts +++ b/packages/web/e2e/help-screenshots.spec.ts @@ -116,103 +116,5 @@ test.describe('Help Page Screenshots', () => { }); }); -// Authenticated tests - requires TEST_USER_EMAIL and TEST_USER_PASSWORD env vars -test.describe('Help Page Screenshots - Authenticated', () => { - test.use({ viewport: { width: 390, height: 844 } }); - - const testEmail = process.env.TEST_USER_EMAIL; - const testPassword = process.env.TEST_USER_PASSWORD; - - test.skip(!testEmail || !testPassword, 'Set TEST_USER_EMAIL and TEST_USER_PASSWORD env vars to run authenticated tests'); - - test.beforeEach(async ({ page }) => { - await page.goto(boardUrl); - await page.waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) - .catch(() => page.waitForLoadState('domcontentloaded')); - - // Login via user drawer - await page.getByLabel('User menu').click(); - await page.getByRole('button', { name: 'Sign in' }).waitFor({ state: 'visible' }); - await page.getByRole('button', { name: 'Sign in' }).click(); - await page.waitForSelector('input#login_email', { state: 'visible' }); - - // Fill login form - await page.locator('input#login_email').fill(testEmail!); - await page.locator('input#login_password').fill(testPassword!); - await page.locator('button[type="submit"]').filter({ hasText: 'Login' }).click(); - - // Wait for login to complete - auth modal should close - await page.waitForSelector('input#login_email', { state: 'hidden', timeout: 10000 }); - }); - - test('personal progress filters', async ({ page }) => { - // Open search drawer to show filters including personal progress - await page.locator('#onboarding-search-button').click(); - await page.getByText('Grade').first().waitFor({ state: 'visible' }); - - // Expand the Progress section - await page.getByText('Progress').click(); - - await page.screenshot({ path: `${SCREENSHOT_DIR}/personal-progress.png` }); - }); - - test('party mode active session', async ({ page, context }) => { - test.slow(); // WebSocket connection setup can be slow in CI - - // Grant geolocation permission so session creation doesn't wait for permission prompt - await context.grantPermissions(['geolocation']); - - // First add a climb to queue so the queue bar appears - const climbCard = page.locator('#onboarding-climb-card'); - await climbCard.dblclick(); - - // Wait for queue bar - const queueBar = page.locator('[data-testid="queue-control-bar"]'); - await expect(queueBar).toBeVisible({ timeout: 10000 }); - - // Open party mode drawer - await page.locator('[data-testid="queue-control-bar"]').getByLabel('Party Mode').click(); - await page.locator('[data-swipeable-drawer="true"]:visible').first().waitFor({ timeout: 10000 }); - - // Start a party session - await page.getByRole('button', { name: 'Start Party Mode' }).click(); - - // Wait for session to be active - WebSocket connection needs time to establish - await page.waitForSelector('button:has-text("Leave")', { state: 'visible', timeout: 30000 }); - - await page.screenshot({ path: `${SCREENSHOT_DIR}/party-mode-active.png` }); - - // Leave the session to clean up - await page.getByRole('button', { name: 'Leave' }).click(); - }); - - test('hold classification wizard', async ({ page }) => { - // Open user drawer and click Classify Holds - await page.getByLabel('User menu').click(); - await page.getByText('Classify Holds').waitFor({ state: 'visible' }); - await page.getByText('Classify Holds').click(); - - // Wait for wizard content to load - await page.waitForSelector('.MuiRating-root, .MuiLinearProgress-root', { state: 'visible', timeout: 10000 }); - - await page.screenshot({ path: `${SCREENSHOT_DIR}/hold-classification.png` }); - }); - - test('settings aurora sync', async ({ page }) => { - // Navigate to settings page - await page.goto('/settings'); - // Wait for settings page content to load - await page.waitForSelector('.MuiCard-root', { state: 'visible' }); - - // Scroll to Board Accounts section - await page.evaluate(() => { - const heading = Array.from(document.querySelectorAll('h4, .MuiCardHeader-title')) - .find(el => el.textContent?.includes('Board Accounts')); - if (heading) { - heading.scrollIntoView({ behavior: 'instant', block: 'start' }); - } - }); - - await page.screenshot({ path: `${SCREENSHOT_DIR}/settings-aurora.png` }); - }); -}); +// Authenticated tests moved to help-screenshots-authenticated.authenticated.spec.ts +// They use Playwright's storageState for pre-authenticated sessions. diff --git a/packages/web/playwright.config.ts b/packages/web/playwright.config.ts index 100af287..5fa45b46 100644 --- a/packages/web/playwright.config.ts +++ b/packages/web/playwright.config.ts @@ -1,4 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +const authFile = path.join(__dirname, 'e2e', '.auth', 'user.json'); /** * See https://playwright.dev/docs/test-configuration. @@ -10,8 +13,8 @@ export default defineConfig({ /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ + retries: process.env.CI ? 1 : 0, + /* CI workers: 2 keeps server load manageable (board pages are heavy queries) */ workers: process.env.CI ? 2 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: process.env.CI ? 'github' : 'html', @@ -26,19 +29,26 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + // Auth setup - runs once before authenticated tests + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + testIgnore: [/auth\.setup\.ts/, /\.authenticated\.spec\.ts/], + }, + // Project for tests that need authentication + { + name: 'chromium-authenticated', + use: { + ...devices['Desktop Chrome'], + storageState: authFile, + }, + dependencies: ['setup'], + testMatch: /\.authenticated\.spec\.ts/, }, - // Uncomment to test on more browsers - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, ], /* Run your local dev server before starting the tests */