diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5e6de09d..39c2f8e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,9 @@ jobs: needs: tests_e2e_netlify_prepare name: Run end-to-end tests on Netlify PR preview runs-on: ubuntu-latest - timeout-minutes: 3 + container: + image: mcr.microsoft.com/playwright:v1.40.0-jammy + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -41,10 +43,17 @@ jobs: node-version: lts/* - name: Install dependencies run: npm ci - - name: Install Playwright browsers - run: npx playwright install --with-deps + - name: Verify network connectivity + run: | + echo "Testing connection to Netlify preview..." + curl -v https://deploy-preview-${{ env.GITHUB_PR_NUMBER }}--eventua11y.netlify.app/ + echo "Testing DNS resolution..." + nslookup deploy-preview-${{ env.GITHUB_PR_NUMBER }}--eventua11y.netlify.app - name: Run tests - run: npx playwright test env: PLAYWRIGHT_TEST_BASE_URL: 'https://deploy-preview-${{ env.GITHUB_PR_NUMBER }}--eventua11y.netlify.app/' - DEBUG: pw:api + DEBUG: 'pw:api,pw:browser*,pw:protocol*' + CI: 'true' + run: | + echo "Starting Playwright tests..." + npx playwright test --project=chromium --retries=5 --reporter=list diff --git a/playwright.config.js b/playwright.config.js index 881c9f64..b6e8d5a0 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -10,8 +10,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, + // Increase retries for better reliability + retries: process.env.CI ? 3 : 1, // Opt out of parallel tests on CI. workers: process.env.CI ? 1 : undefined, @@ -25,12 +25,60 @@ export default defineConfig({ // Collect trace when retrying the failed test. trace: 'on-first-retry', + + // More reasonable timeouts for CI + actionTimeout: process.env.CI ? 45000 : 30000, + navigationTimeout: process.env.CI ? 45000 : 30000, + + // Add explicit test timeout + testTimeout: process.env.CI ? 90000 : 60000, + + // Enable screenshot on failure + screenshot: 'only-on-failure', + + // Enable video recording for failed tests + video: 'retain-on-failure', + + // Add viewport size + viewport: { width: 1280, height: 720 }, + + // Add automatic waiting + waitForNavigation: 'networkidle', + + // CI-specific browser launch options + launchOptions: process.env.CI + ? { + args: ['--no-sandbox', '--disable-setuid-sandbox'], + } + : undefined, + + // Use persistent context in CI to reduce browser startup overhead + contextOptions: process.env.CI + ? { + acceptDownloads: false, + strictSelectors: true, + } + : undefined, }, // Configure projects for major browsers. projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + // Additional chromium-specific settings for CI + launchOptions: process.env.CI + ? { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-gpu', + ], + headless: true, + } + : undefined, + }, }, { name: 'firefox', @@ -48,6 +96,7 @@ export default defineConfig({ command: 'netlify dev', port: 8888, reuseExistingServer: !process.env.CI, + timeout: 60000, // Reduced server startup timeout } : undefined, }); diff --git a/tests/events.spec.ts b/tests/events.spec.ts index 3132d897..f27b3afc 100644 --- a/tests/events.spec.ts +++ b/tests/events.spec.ts @@ -2,41 +2,65 @@ import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; -test.beforeEach(async ({ page, baseURL }) => { - await page.goto(baseURL); - // await page.waitForLoadState('networkidle'); - const filterDrawer = page.locator('#filter-drawer'); - const isVisible = await filterDrawer.isVisible(); - if (isVisible) { - await page.keyboard.press('Escape'); - await expect(filterDrawer).not.toBeVisible(); - } - await page.waitForSelector('#upcoming-events'); -}); +test.describe('Events page', () => { + test.beforeEach(async ({ page, baseURL }) => { + await page.goto(baseURL); + await page.waitForLoadState('networkidle'); -test('has title', async ({ page }) => { - await expect(page).toHaveTitle(/Eventua11y/); -}); + // Clear any open drawers + const filterDrawer = page.locator('#filter-drawer'); + await filterDrawer.waitFor({ state: 'attached', timeout: 5000 }); + const isVisible = await filterDrawer.isVisible(); + if (isVisible) { + await page.keyboard.press('Escape'); + await expect(filterDrawer).not.toBeVisible(); + } -test('header is visible', async ({ page }) => { - await expect(page.locator('#global-header')).toBeVisible(); -}); + // Wait for main content + await Promise.all([ + page.waitForSelector('#upcoming-events', { state: 'visible' }), + page.waitForSelector('#global-header', { state: 'visible' }), + page.waitForSelector('#global-footer', { state: 'visible' }), + ]); + }); -test('Today heading is visible', async ({ page }) => { - await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible(); -}); + test('has title', async ({ page }) => { + await expect(page).toHaveTitle(/Eventua11y/, { timeout: 10000 }); + }); -test('footer is visible', async ({ page }) => { - await expect(page.locator('#global-footer')).toBeVisible(); -}); + test('header is visible', async ({ page }) => { + const header = page.locator('#global-header'); + await expect(header).toBeVisible(); + await expect(header).toBeInViewport(); + }); -test('has no accessibility violations', async ({ page }) => { - const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); - expect(accessibilityScanResults.violations).toEqual([]); -}); + test('Today heading is visible', async ({ page }) => { + const heading = page.getByRole('heading', { name: 'Today' }); + await expect(heading).toBeVisible(); + await expect(heading).toBeInViewport(); + }); + + test('footer is visible', async ({ page }) => { + const footer = page.locator('#global-footer'); + await expect(footer).toBeVisible(); + }); + + test('has no accessibility violations', async ({ page }) => { + // Wait for dynamic content to load + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#upcoming-events .event', { state: 'visible' }); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .exclude('#filter-drawer') // Exclude drawer to avoid false positives + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); -test('has at least one upcoming event', async ({ page }) => { - const upcomingEvents = page.locator('.event'); - const countOfEvents = await upcomingEvents.count(); - await expect(countOfEvents).toBeGreaterThan(0); + test('has at least one upcoming event', async ({ page }) => { + const upcomingEvents = page.locator('.event'); + await upcomingEvents.first().waitFor({ state: 'visible' }); + const countOfEvents = await upcomingEvents.count(); + await expect(countOfEvents).toBeGreaterThan(0); + }); }); diff --git a/tests/filters.spec.ts b/tests/filters.spec.ts index 3e109eb5..a19cf78c 100644 --- a/tests/filters.spec.ts +++ b/tests/filters.spec.ts @@ -1,125 +1,113 @@ import { test, expect } from '@playwright/test'; -test.beforeEach(async ({ page, baseURL }) => { - await page.goto(baseURL); - // Wait for hydration indicators - await page.waitForSelector('#upcoming-events'); - await page.waitForSelector('#filters'); - await page.waitForSelector('.filters__count:not(:empty)'); - await page.waitForSelector('#open-filter-drawer:not([disabled])'); - await page.waitForSelector('#upcoming-events'); - await page.waitForSelector('#filters'); -}); - -test('filter button is visible', async ({ page }) => { - // Wait for page ready - await page.waitForLoadState('domcontentloaded'); - - // Locate and verify button - const filterButton = page.getByRole('button', { name: 'Filter' }); - await filterButton.waitFor({ state: 'visible', timeout: 5000 }); - await expect(filterButton).toBeVisible(); - await expect(filterButton).toBeEnabled(); -}); - -test('filter drawer opens when filter button is clicked', async ({ page }) => { - // Wait for initial page load - await page.waitForLoadState('domcontentloaded'); - - // Get filter button and wait for it to be ready - const filterButton = page.getByRole('button', { name: 'Filter' }); - await filterButton.waitFor({ state: 'visible', timeout: 5000 }); - - // Click and wait for drawer - await filterButton.click(); - - // Get drawer and verify state - const drawer = page.locator('#filter-drawer'); - await expect(drawer).toHaveAttribute('open', ''); - await expect(drawer).toBeVisible(); - - // Optional: Wait for transition - await page.waitForTimeout(300); -}); - -test('filter drawer closes when close button is clicked', async ({ page }) => { - // Open drawer - const filterButton = page.getByRole('button', { name: 'Filter' }); - await filterButton.waitFor({ state: 'visible' }); - await filterButton.click({ force: true }); - - // Wait for drawer to be visible - const drawer = page.locator('#filter-drawer'); - await expect(drawer).toBeVisible(); - - // Close drawer - const closeButton = page.getByRole('button', { name: 'Close' }); - await closeButton.waitFor({ state: 'visible' }); - await closeButton.click({ force: true }); - - // Verify drawer is closed - await expect(drawer).not.toBeVisible(); - - // Optional: Wait for animation - await page.waitForTimeout(300); -}); - -test('filter drawer closes when esc key is pressed', async ({ page }) => { - // Initial page load - await page.waitForLoadState('domcontentloaded'); - - // Get and click filter button - const filterButton = page.getByRole('button', { name: 'Filter' }); - await filterButton.waitFor({ state: 'visible' }); - await filterButton.click(); - - // Verify drawer opens - const drawer = page.locator('#filter-drawer'); - await expect(drawer).toHaveAttribute('open', ''); - await expect(drawer).toBeVisible(); - - // Press escape and wait for transition - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - - // Verify drawer closes - await expect(drawer).not.toHaveAttribute('open'); - await expect(drawer).not.toBeVisible(); -}); - -test('reset button appears when filters are applied', async ({ page }) => { - // Setup - await page.waitForLoadState('domcontentloaded'); - - // Open drawer - const filterButton = page.getByRole('button', { name: 'Filter' }); - await filterButton.waitFor({ state: 'visible' }); - await filterButton.click(); - - // Verify drawer opens - const drawer = page.locator('#filter-drawer'); - await expect(drawer).toBeVisible(); - - // Verify reset button initially hidden - const resetButton = page.getByRole('button', { name: 'Reset Filters' }); - await expect(resetButton).not.toBeVisible(); - - // Change filter and wait for update - const onlineCheckbox = page.getByRole('checkbox', { name: 'Online' }); - await onlineCheckbox.waitFor({ state: 'visible' }); - await onlineCheckbox.check({ force: true }); - await page.waitForTimeout(300); - - // Verify reset button appears - await expect(resetButton.first()).toBeVisible({ timeout: 5000 }); -}); - -// Reset button clears filters -test('reset button clears filters', async ({ page }) => { - await page.getByRole('button', { name: 'Filter' }).click(); - await page - .getByRole('checkbox', { name: 'Not accepting talks' }, { exact: true }) - .check({ force: true }); - await page.getByTestId('drawer-reset').click({ force: true }); - await expect(page.getByTestId('drawer-reset')).not.toBeVisible(); +test.describe('Filters functionality', () => { + test.beforeEach(async ({ page, baseURL }) => { + await page.goto(baseURL); + + // Wait for all critical components and network activity + await Promise.all([ + page.waitForLoadState('networkidle'), + page.waitForLoadState('domcontentloaded'), + page.waitForSelector('#upcoming-events', { state: 'visible' }), + page.waitForSelector('#filters', { state: 'visible' }), + page.waitForSelector('#open-filter-drawer:not([disabled])', { + state: 'visible', + timeout: 5000 + }), + ]); + }); + + const openFilterDrawer = async (page) => { + const filterButton = page.getByRole('button', { name: 'Filter' }); + await filterButton.waitFor({ state: 'visible', timeout: 5000 }); + await filterButton.click(); + + const drawer = page.locator('#filter-drawer'); + await drawer.waitFor({ state: 'visible', timeout: 5000 }); + + // Wait for drawer animation and ensure content is actually visible + await page.waitForSelector('#filter-drawer[open]', { state: 'visible', timeout: 5000 }); + await page.waitForTimeout(300); // Standard animation duration + return drawer; + }; + + test('filter button is visible and interactive', async ({ page }) => { + const filterButton = page.getByRole('button', { name: 'Filter' }); + await expect(filterButton).toBeVisible(); + await expect(filterButton).toBeEnabled(); + }); + + test('filter drawer opens when filter button is clicked', async ({ + page, + }) => { + const drawer = await openFilterDrawer(page); + await expect(drawer).toHaveAttribute('open', ''); + await expect(drawer).toBeVisible(); + }); + + test('filter drawer closes when close button is clicked', async ({ + page, + }) => { + const drawer = await openFilterDrawer(page); + + const closeButton = page.getByRole('button', { + name: /Show \d+ of \d+ events/, + }); + await closeButton.waitFor({ state: 'visible', timeout: 5000 }); + await closeButton.click(); + + // Wait for drawer animation + await page.waitForTimeout(300); + await expect(drawer).not.toBeVisible(); + await expect(drawer).not toHaveAttribute('open'); + }); + + test('filter drawer closes when esc key is pressed', async ({ page }) => { + const drawer = await openFilterDrawer(page); + + await page.keyboard.press('Escape'); + // Wait for drawer animation + await page.waitForTimeout(300); + await expect(drawer).not.toBeVisible(); + await expect(drawer).not toHaveAttribute('open'); + }); + + test('reset button appears when filters are applied', async ({ page }) => { + await openFilterDrawer(page); + + const resetButton = page.getByTestId('drawer-reset'); + await expect(resetButton).not toBeVisible(); + + // Use radio button text content to find and click it + await page.getByText('Online', { exact: true }).first().click(); + await page.waitForTimeout(100); // Wait for state update + + await resetButton.scrollIntoViewIfNeeded(); + await expect(resetButton).toBeVisible(); + }); + + test('reset button clears filters', async ({ page }) => { + await openFilterDrawer(page); + + // Click the "Not accepting talks" radio using text content + await page + .getByText('Not accepting talks', { exact: true }) + .first() + .click(); + await page.waitForTimeout(100); // Wait for state update + + const resetButton = page.getByTestId('drawer-reset'); + await resetButton.scrollIntoViewIfNeeded(); + await resetButton.waitFor({ state: 'visible' }); + await resetButton.click(); + + // Wait for button to disappear (confirms reset completed) + await expect(resetButton).not toBeVisible(); + + // Wait for and verify that all radio groups show "No preference" as selected + const noPreferenceRadios = page.locator('sl-radio[value="any"]'); + for (const radio of await noPreferenceRadios.all()) { + await expect(radio).toHaveAttribute('aria-checked', 'true'); + } + }); }); diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts index 402e2e99..6ba255a6 100644 --- a/tests/theme.spec.ts +++ b/tests/theme.spec.ts @@ -2,25 +2,44 @@ import { test, expect } from '@playwright/test'; test.describe('Theme Switching', () => { test.beforeEach(async ({ context, page }) => { - // Set initial state + // Reset storage state await context.addInitScript(() => { window.localStorage.clear(); }); await context.clearCookies(); - // Reset system preference + // Reset system preference and load page await page.emulateMedia({ colorScheme: 'light' }); - - // Load page await page.goto('/'); + + // Wait for critical components to be ready + await Promise.all([ + page.waitForLoadState('networkidle'), + page.waitForLoadState('domcontentloaded'), + page.waitForSelector('#upcoming-events', { state: 'visible' }), + page.waitForSelector('#theme-selector-button', { state: 'visible' }), + ]); + + // Ensure clean state with more reasonable drawer handling const filterDrawer = page.locator('#filter-drawer'); - const isVisible = await filterDrawer.isVisible(); - if (isVisible) { - await page.keyboard.press('Escape'); - await expect(filterDrawer).not.toBeVisible(); + try { + await filterDrawer.waitFor({ state: 'attached', timeout: 5000 }); + if (await filterDrawer.isVisible()) { + await page.keyboard.press('Escape'); + await filterDrawer.waitFor({ state: 'hidden', timeout: 2000 }); + } + } catch (e) { + // If drawer timeout occurs, it's likely already hidden + console.log('Filter drawer not found or already hidden'); } }); + const switchTheme = async (page, themeId) => { + await page.click('#theme-selector-button'); + await page.waitForSelector('#theme-selector sl-menu', { state: 'visible' }); + await page.click(`#${themeId}`); + }; + test('should start with system theme', async ({ page }) => { await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); await expect( @@ -32,23 +51,23 @@ test.describe('Theme Switching', () => { context, page, }) => { - // Switch theme - await page.click('#theme-selector-button'); - await page.click('#light-mode'); + await switchTheme(page, 'light-mode'); - // Verify initial change await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); await expect( page.locator('#theme-selector-button sl-icon[label="Light mode"]') ).toBeVisible(); - // Verify persistence + // Test persistence in new page const newPage = await context.newPage(); await newPage.goto('/'); + await newPage.waitForLoadState('networkidle'); + await expect(newPage.locator('html')).toHaveAttribute( 'data-theme', 'light' ); + await newPage.close(); }); test('should switch to dark theme and persist', async ({ @@ -56,24 +75,21 @@ test.describe('Theme Switching', () => { page, browser, }) => { - // Switch theme - await page.click('#theme-selector-button'); - await page.click('#dark-mode'); + await switchTheme(page, 'dark-mode'); - // Verify initial change await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect( page.locator('#theme-selector-button sl-icon[label="Dark mode"]') ).toBeVisible(); - // Get storage state + // Test persistence with storage state const storageState = await context.storageState(); - - // Create new context with storage state const newContext = await browser.newContext({ storageState }); const newPage = await newContext.newPage(); await newPage.goto('/'); + await newPage.waitForLoadState('networkidle'); + await expect(newPage.locator('html')).toHaveAttribute('data-theme', 'dark'); await newContext.close(); }); @@ -82,32 +98,36 @@ test.describe('Theme Switching', () => { page, }) => { // Test dark preference + await switchTheme(page, 'system-default'); await page.emulateMedia({ colorScheme: 'dark' }); - await page.click('#theme-selector-button'); - await page.click('#system-default'); + await page.reload(); + await page.waitForLoadState('networkidle'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); // Test light preference await page.emulateMedia({ colorScheme: 'light' }); await page.reload(); + await page.waitForLoadState('networkidle'); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); }); test('should handle theme selector interactions correctly', async ({ page, }) => { + const menu = page.locator('#theme-selector sl-menu'); + // Test menu opening await page.click('#theme-selector-button'); - await expect(page.locator('#theme-selector sl-menu')).toBeVisible(); + await expect(menu).toBeVisible(); // Test click outside - await page.click('body'); - await expect(page.locator('#theme-selector sl-menu')).not.toBeVisible(); + await page.click('body', { position: { x: 0, y: 0 } }); + await expect(menu).not.toBeVisible(); // Test keyboard interaction await page.click('#theme-selector-button'); - await expect(page.locator('#theme-selector sl-menu')).toBeVisible(); + await expect(menu).toBeVisible(); await page.keyboard.press('Escape'); - await expect(page.locator('#theme-selector sl-menu')).not.toBeVisible(); + await expect(menu).not.toBeVisible(); }); });