Skip to content
10 changes: 9 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
14 changes: 8 additions & 6 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
4 changes: 2 additions & 2 deletions e2e/src/config/TestConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
4 changes: 2 additions & 2 deletions e2e/src/pages/AppCatalogPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
58 changes: 54 additions & 4 deletions e2e/src/pages/BasePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`
);
Expand Down Expand Up @@ -117,19 +127,31 @@ export abstract class BasePage {
protected async takeScreenshot(filename: string, context: LogContext = {}): Promise<void> {
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}`, {
...context,
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);
}
}

Expand Down Expand Up @@ -198,6 +220,34 @@ export abstract class BasePage {
}
}

/**
* Clean up any open modals or dialogs
*/
async cleanupModals(): Promise<void> {
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
*/
Expand Down
23 changes: 14 additions & 9 deletions e2e/src/utils/Logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 3 additions & 11 deletions e2e/tests/foundry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`;
Expand All @@ -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', () => {
Expand Down
Loading