diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 66e1f81..5bd3bdc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -97,7 +97,7 @@ jobs: directory: e2e - name: Run Playwright tests - run: npx dotenvx run -- npx playwright test + run: npx dotenvx run --quiet -- npx playwright test working-directory: e2e - name: Upload Playwright report @@ -108,6 +108,14 @@ jobs: path: e2e/playwright-report/ retention-days: 30 + - name: Upload test results and screenshots + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-test-results-${{ env.APP_NAME }} + path: e2e/test-results/ + retention-days: 30 + - name: Delete app from Falcon if: always() run: | diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6d66718..0b731d3 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -9,12 +9,15 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@dotenvx/dotenvx": "^1.47.3", - "otpauth": "^9.4.0" + "@dotenvx/dotenvx": "1.47.3", + "otpauth": "9.4.0" }, "devDependencies": { - "@playwright/test": "^1.53.2", - "@types/node": "^24.0.10" + "@playwright/test": "1.53.2", + "@types/node": "24.0.10" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@dotenvx/dotenvx": { diff --git a/e2e/package.json b/e2e/package.json index a432b9c..838d87c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -14,11 +14,11 @@ "node": ">=22.0.0" }, "dependencies": { - "@dotenvx/dotenvx": "^1.47.3", - "otpauth": "^9.4.0" + "@dotenvx/dotenvx": "1.47.3", + "otpauth": "9.4.0" }, "devDependencies": { - "@playwright/test": "^1.53.2", - "@types/node": "^24.0.10" + "@playwright/test": "1.53.2", + "@types/node": "24.0.10" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index fa9a5f6..1a59bbe 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -2,25 +2,27 @@ import { defineConfig, devices } from '@playwright/test'; import { AuthFile } from './constants/AuthFile'; if (!process.env.CI) { - require("dotenv").config({ path: ".env" }); + require("dotenv").config({ path: ".env", quiet: true }); } export default defineConfig({ testDir: './tests', fullyParallel: false, // for more controlled test execution forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 1, // Allow 1 retry locally for better reliability workers: process.env.CI ? 1 : undefined, - timeout: 60 * 1000, // 60 seconds for entire test + timeout: process.env.CI ? 60 * 1000 : 45 * 1000, // Enhanced timeout hierarchy expect: { - timeout: 10 * 1000, // 10 seconds for assertions + timeout: process.env.CI ? 10 * 1000 : 8 * 1000, // for assertions }, reporter: 'html', use: { testIdAttribute: 'data-test-selector', trace: 'on-first-retry', - actionTimeout: 15 * 1000, // 15 seconds for actions - navigationTimeout: 30 * 1000, // 30 seconds for navigation + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: process.env.CI ? 15 * 1000 : 12 * 1000, // Optimized timeouts + navigationTimeout: process.env.CI ? 30 * 1000 : 25 * 1000, // for navigation }, projects: [ diff --git a/e2e/src/config/TestConfig.ts b/e2e/src/config/TestConfig.ts index d2ce293..f573c2c 100644 --- a/e2e/src/config/TestConfig.ts +++ b/e2e/src/config/TestConfig.ts @@ -107,8 +107,8 @@ export class TestConfig { return { path: this.screenshotPath, fullPage: true, - type: 'png' as const, - quality: this.isCI ? 80 : 100 + type: 'png' as const + // Note: quality parameter is not supported for PNG screenshots }; } diff --git a/e2e/src/pages/AppCatalogPage.ts b/e2e/src/pages/AppCatalogPage.ts index f638433..501d61d 100644 --- a/e2e/src/pages/AppCatalogPage.ts +++ b/e2e/src/pages/AppCatalogPage.ts @@ -214,8 +214,8 @@ export class AppCatalogPage extends BasePage { `1. In LOCAL environment: The app needs to be manually deployed first using the Foundry CLI`, `2. In CI environment: The app deployment step may have failed\n`, `To fix this locally:`, - `- Run: foundry app deploy`, - `- Then run: foundry app release`, + `- Run: foundry apps deploy`, + `- Then run: foundry apps release`, `- Make sure your APP_NAME in .env matches your deployed app name\n`, `Current APP_NAME from .env: ${appName}` ].join('\n'); diff --git a/e2e/src/pages/BasePage.ts b/e2e/src/pages/BasePage.ts index 5ddde77..8a0347e 100644 --- a/e2e/src/pages/BasePage.ts +++ b/e2e/src/pages/BasePage.ts @@ -51,6 +51,7 @@ export abstract class BasePage { /** * Click an element with smart waiting and retry + * Enhanced with automatic force click fallback for complex UI interactions */ protected async smartClick( locator: Locator | string, @@ -72,7 +73,16 @@ export abstract class BasePage { timeout: actualTimeout, description }); - await element.click({ force: options.force, timeout: actualTimeout }); + + try { + // First attempt: normal click + await element.click({ timeout: actualTimeout }); + } catch (error) { + // Second attempt: force click to handle element interception + this.logger.debug(`Normal click failed for ${description}, retrying with force: true`); + await element.click({ force: true, timeout: actualTimeout }); + this.logger.debug(`Force click succeeded for ${description}`); + } }, `Click ${description}` ); @@ -117,11 +127,22 @@ export abstract class BasePage { protected async takeScreenshot(filename: string, context: LogContext = {}): Promise { try { const screenshotConfig = config.getScreenshotConfig(); - const fullPath = `${screenshotConfig.path}/${filename}`; + + // Ensure the directory exists + const fs = require('fs'); + const path = require('path'); + const screenshotDir = screenshotConfig.path; + if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); + } + + // Create full path for the screenshot file + const fullPath = path.join(screenshotDir, filename); await this.page.screenshot({ path: fullPath, - ...screenshotConfig + fullPage: screenshotConfig.fullPage, + type: screenshotConfig.type }); this.logger.debug(`Screenshot saved: ${filename}`, { @@ -129,7 +150,8 @@ export abstract class BasePage { path: fullPath }); } catch (error) { - this.logger.warn(`Failed to take screenshot: ${filename}`, error instanceof Error ? error : undefined, context); + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to take screenshot: ${filename} - ${errorMessage}`, error instanceof Error ? error : undefined, context); } } @@ -198,6 +220,34 @@ export abstract class BasePage { } } + /** + * Clean up any open modals or dialogs + */ + async cleanupModals(): Promise { + try { + const closeButtons = [ + this.page.getByRole('button', { name: /close|dismiss|cancel/i }), + this.page.locator('[data-testid*="close"], [aria-label*="close"]'), + this.page.locator('.modal-close, [class*="close"]') + ]; + + for (const closeButton of closeButtons) { + if (await this.elementExists(closeButton, 1000)) { + try { + await closeButton.click({ timeout: 2000, force: true }); + await this.page.waitForTimeout(500); + } catch { + // Continue to next strategy + } + } + } + + await this.page.keyboard.press('Escape'); + } catch { + // Modal cleanup should never fail tests + } + } + /** * Abstract method for page-specific verification */ diff --git a/e2e/src/utils/Logger.ts b/e2e/src/utils/Logger.ts index 27f00e4..02271dd 100644 --- a/e2e/src/utils/Logger.ts +++ b/e2e/src/utils/Logger.ts @@ -146,16 +146,21 @@ export class Logger { private log(level: LogLevel, message: string, context: LogContext = {}): void { const timestamp = new Date().toISOString(); - const logEntry = { - timestamp, - level, - message, - ...context - }; - // In CI, use structured JSON logging for better parsing - if (this.isCI && level !== 'step') { - console.log(JSON.stringify(logEntry)); + // In CI, be much less verbose with plain text output + if (this.isCI) { + // Only log errors, warnings, and final test results in CI + if (level === 'error' || + (level === 'warn' && !message.includes('App page loaded but no content detected')) || + (level === 'info' && ( + message.includes('✅ Test passed') || + message.includes('❌ Test failed') || + message.includes('E2E Test Config:') + ))) { + // Use plain text in CI for better readability + console.log(message); + } + // Completely suppress 'step' level in CI } else { // In local development, use human-readable format console.log(message); diff --git a/e2e/tests/foundry.spec.ts b/e2e/tests/foundry.spec.ts index 9d2095b..094cd5c 100644 --- a/e2e/tests/foundry.spec.ts +++ b/e2e/tests/foundry.spec.ts @@ -13,7 +13,6 @@ test.describe('Foundry Tutorial Quickstart E2E Tests', () => { // Global setup for the entire test suite test.beforeAll(async () => { - config.logSummary(); logger.info('Starting Foundry Tutorial Quickstart E2E test suite'); // Log test environment info @@ -25,7 +24,7 @@ test.describe('Foundry Tutorial Quickstart E2E Tests', () => { }); // Clean up after each test - test.afterEach(async ({ page }, testInfo) => { + test.afterEach(async ({ page, appCatalogPage }, testInfo) => { // Take screenshot on failure for debugging if (testInfo.status !== testInfo.expectedStatus) { const screenshotPath = `test-failure-${testInfo.title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}.png`; @@ -41,15 +40,8 @@ test.describe('Foundry Tutorial Quickstart E2E Tests', () => { logger.success(`Test passed: ${testInfo.title}`, { duration: testInfo.duration }); } - // Clear any lingering modals or dialogs - try { - const modalCloseButton = page.getByRole('button', { name: /close|dismiss|cancel/i }); - if (await modalCloseButton.isVisible({ timeout: 1000 })) { - await modalCloseButton.click({ timeout: 2000 }); - } - } catch { - // Ignore if no modals to close - } + // Enhanced modal cleanup + await appCatalogPage.cleanupModals(); }); test.describe('App Installation and Basic Navigation', () => {