From 7d48a325a78e603ae430af6c73d39ac1c7d2eac8 Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 23 Apr 2026 08:46:20 -0600 Subject: [PATCH 1/2] Migrate e2e tests to @crowdstrike/foundry-playwright --- e2e/package-lock.json | 84 +++-- e2e/package.json | 6 +- e2e/playwright.config.ts | 60 +-- e2e/src/authenticate.cjs | 106 ------ e2e/src/config/TestConfig.ts | 147 -------- e2e/src/fixtures.ts | 61 +-- e2e/src/pages/AppCatalogPage.ts | 350 ------------------ e2e/src/pages/AppManagerPage.ts | 66 ---- e2e/src/pages/BasePage.ts | 252 ------------- e2e/src/pages/FoundryHomePage.ts | 33 -- e2e/src/pages/HostPanelExtensionPage.ts | 2 +- e2e/src/pages/SocketNavigationPage.ts | 255 ------------- e2e/src/pages/UserPreferencesExtensionPage.ts | 2 +- e2e/src/pages/WorkflowsPage.ts | 248 ------------- e2e/src/utils.cjs | 43 --- e2e/src/utils/Logger.ts | 192 ---------- e2e/src/utils/SmartWaiter.ts | 212 ----------- e2e/tests/app-install.setup.ts | 17 - e2e/tests/app-uninstall.teardown.ts | 7 - e2e/tests/authenticate.setup.ts | 22 -- 20 files changed, 77 insertions(+), 2088 deletions(-) delete mode 100644 e2e/src/authenticate.cjs delete mode 100644 e2e/src/config/TestConfig.ts delete mode 100644 e2e/src/pages/AppCatalogPage.ts delete mode 100644 e2e/src/pages/AppManagerPage.ts delete mode 100644 e2e/src/pages/BasePage.ts delete mode 100644 e2e/src/pages/FoundryHomePage.ts delete mode 100644 e2e/src/pages/SocketNavigationPage.ts delete mode 100644 e2e/src/pages/WorkflowsPage.ts delete mode 100644 e2e/src/utils.cjs delete mode 100644 e2e/src/utils/Logger.ts delete mode 100644 e2e/src/utils/SmartWaiter.ts delete mode 100644 e2e/tests/app-install.setup.ts delete mode 100644 e2e/tests/app-uninstall.teardown.ts delete mode 100644 e2e/tests/authenticate.setup.ts diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 87e5849..6e3981a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -9,21 +9,33 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@dotenvx/dotenvx": "1.52.0", - "otpauth": "9.5.0" + "@crowdstrike/foundry-playwright": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz" }, "devDependencies": { - "@playwright/test": "1.57.0", - "@types/node": "25.1.0" + "@types/node": "latest" }, "engines": { "node": ">=22.0.0" } }, + "node_modules/@crowdstrike/foundry-playwright": { + "version": "0.5.0", + "resolved": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz", + "integrity": "sha512-fZRbY32K0TJXFVgRr8hkq957CJr5iGlcgbcx8GqsSeVn4rBmd6HRU+HoiRgEGtCIi0bDaWvFcCLsrlLnZOlzYA==", + "license": "MIT", + "dependencies": { + "@dotenvx/dotenvx": "^1.61.0", + "@playwright/test": "^1.59.1", + "otpauth": "^9.5.0" + }, + "engines": { + "node": ">=24" + } + }, "node_modules/@dotenvx/dotenvx": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", - "integrity": "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==", + "version": "1.61.2", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.2.tgz", + "integrity": "sha512-MgqO42rQW1AQds4PWL87XwkESIY1BW2BcpVM8Vq69EAN/KL24KmBBo2v+ED4bje+cMiruQD20lC3x6nX/4ZtBg==", "license": "BSD-3-Clause", "dependencies": { "commander": "^11.1.0", @@ -33,8 +45,9 @@ "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", - "picomatch": "^4.0.2", - "which": "^4.0.0" + "picomatch": "^4.0.4", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" @@ -97,13 +110,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "dev": true, + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -239,7 +251,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -398,13 +409,12 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -417,10 +427,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -486,6 +495,33 @@ "engines": { "node": "^16.13.0 || >=18.0.0" } + }, + "node_modules/yocto-spinner": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", + "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/e2e/package.json b/e2e/package.json index 242bf8d..0054e39 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -15,11 +15,9 @@ "node": ">=22.0.0" }, "dependencies": { - "@dotenvx/dotenvx": "1.52.0", - "otpauth": "9.5.0" + "@crowdstrike/foundry-playwright": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz" }, "devDependencies": { - "@playwright/test": "1.57.0", - "@types/node": "25.1.0" + "@types/node": "latest" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f54362b..cb07192 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,60 +1,4 @@ -import { defineConfig, devices } from '@playwright/test'; -import { AuthFile } from './constants/AuthFile'; -import dotenv from 'dotenv'; +import { defineFoundryConfig } from '@crowdstrike/foundry-playwright'; -if (!process.env.CI) { - dotenv.config({ path: ".env", quiet: true }); -} +export default defineFoundryConfig(); -export default defineConfig({ - testDir: './tests', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - timeout: process.env.CI ? 60 * 1000 : 45 * 1000, - expect: { - timeout: process.env.CI ? 10 * 1000 : 8 * 1000, - }, - reporter: 'html', - use: { - testIdAttribute: 'data-test-selector', - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: process.env.CI ? 'off' : 'retain-on-failure', - actionTimeout: process.env.CI ? 15 * 1000 : 10 * 1000, - navigationTimeout: process.env.CI ? 30 * 1000 : 20 * 1000, - }, - - projects: [ - { - name: 'setup', - testMatch: /authenticate.setup.ts/, - }, - { - name: 'app-install', - testMatch: /app-install.setup.ts/, - use: { - ...devices['Desktop Chrome'], - storageState: AuthFile - }, - dependencies: ["setup"] - }, - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - storageState: AuthFile - }, - dependencies: ["setup", "app-install"] - }, - { - name: 'app-uninstall', - testMatch: /app-uninstall.teardown.ts/, - use: { - ...devices['Desktop Chrome'], - storageState: AuthFile - }, - dependencies: ["chromium"] - }, - ], -}); diff --git a/e2e/src/authenticate.cjs b/e2e/src/authenticate.cjs deleted file mode 100644 index c87e804..0000000 --- a/e2e/src/authenticate.cjs +++ /dev/null @@ -1,106 +0,0 @@ -'use strict'; - -const { expect } = require('@playwright/test'); -const { getTotp, getUserCredentials } = require('./utils.cjs'); - -/** - * Utility method using Playwright to execute the API request(s) for "standard" falcon console authentication - * @param {import('@playwright/test').APIRequestContext} request - * @param {{ email: string; password: string; secret?: string}} credentials - */ -async function authenticate(request, { email, password, secret }) { - // get CSRF Token - const csrfResponse = await request.post('/api2/auth/csrf', {}); - let { csrf_token } = await csrfResponse.json(); - - // attempt standard login - const loginResponse = await request.post('/auth/login', { - headers: { - 'x-csrf-token': csrf_token, - }, - data: { - username: email, - password, - }, - }); - - await expect(loginResponse).toBeOK(); - - const loginResult = await loginResponse.json(); - const totpStep = loginResult.steps?.find(({ type }) => type === 'urn:cs:sf:otp-device:totp'); - - // check if account requires a time-based one time passcode (TOTP) authentication step - if (totpStep) { - const { enroll, verify } = totpStep; - - // user account has not completed 2FA enrollment - if (enroll) { - throw new Error( - "You must complete 2FA enrollment for this account and save the account's encrypted `secret` with the account credentials", - ); - } - - // user account is enrolled in 2FA but has no saved TOTP secret - else if (!secret) { - throw new Error( - "You must save this account's encrypted `secret` with the account credentials", - ); - } - - // user account is enrolled in 2FA - else if (verify) { - // refresh csrf token - csrf_token = loginResult.csrf_token; - - await expect(async () => { - // generate passcode using account's secret key - const passcode = getTotp(secret); - - // verify passcode - const verifyResponse = await request.post(`/api2/${verify}`, { - headers: { - 'x-csrf-token': csrf_token, - }, - data: { passcode }, - }); - - await expect(verifyResponse).toBeOK(); - }).toPass(); - // retry passcode generation and verification in the off chance that - // the otpauth library generates a passcode which immediately expires - - // resubmit login with password omitted - const twoFactorLoginResponse = await request.post('/auth/login', { - headers: { - 'x-csrf-token': csrf_token, - }, - data: { username: email }, - }); - - await expect(twoFactorLoginResponse).toBeOK(); - } - } -} - -/** - * Authenticates a user with the specified role and returns the authenticated request context - * @param {import('playwright').APIRequestContext} request - Playwright API request - * @param {string} role - User role to authenticate as - * @returns A request context authenticated with the specified role - * - * @example - * // Authenticate as an admin user - * const authenticatedRequest = await getAuthenticatedRequest(request, 'falcon-admin'); - */ -async function getAuthenticatedRequest(request, role) { - const credentials = await getUserCredentials(role); - - await authenticate(request, credentials); - - return request; -} - -module.exports = { - authenticate, - getAuthenticatedRequest, -}; diff --git a/e2e/src/config/TestConfig.ts b/e2e/src/config/TestConfig.ts deleted file mode 100644 index bb7bef4..0000000 --- a/e2e/src/config/TestConfig.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Centralized configuration management for Foundry E2E tests - * Centralizes all environment variables, validation, and defaults - */ -export class TestConfig { - private static _instance: TestConfig; - - // Core URLs and endpoints - public readonly falconBaseUrl: string; - public readonly apiBaseUrl: string; - - // Authentication - public readonly falconUsername: string; - public readonly falconPassword: string; - public readonly authSecret: string; - - // App configuration - public readonly appName: string; - - // Test configuration - public readonly defaultTimeout: number; - public readonly navigationTimeout: number; - public readonly retryAttempts: number; - public readonly screenshotPath: string; - - // Environment detection - public readonly isCI: boolean; - public readonly isDebugMode: boolean; - - private constructor() { - // Validate all required environment variables first - this.validateEnvironment(); - - // Core URLs - this.falconBaseUrl = process.env.FALCON_BASE_URL || 'https://falcon.us-2.crowdstrike.com'; - this.apiBaseUrl = `${this.falconBaseUrl}/api/v2`; - - // Authentication (required) - this.falconUsername = this.getRequiredEnv('FALCON_USERNAME'); - this.falconPassword = this.getRequiredEnv('FALCON_PASSWORD'); - this.authSecret = this.getRequiredEnv('FALCON_AUTH_SECRET'); - - // App configuration - this.appName = this.getRequiredEnv('APP_NAME'); - - // Test timeouts (configurable defaults - longer in CI due to slower hardware) - this.defaultTimeout = parseInt(process.env.DEFAULT_TIMEOUT || (this.isCI ? '45000' : '30000')); - this.navigationTimeout = parseInt(process.env.NAVIGATION_TIMEOUT || (this.isCI ? '30000' : '15000')); - this.retryAttempts = parseInt(process.env.RETRY_ATTEMPTS || (this.isCI ? '3' : '2')); - - // Paths - this.screenshotPath = process.env.SCREENSHOT_PATH || 'test-results'; - - // Environment detection - this.isCI = !!process.env.CI; - this.isDebugMode = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'debug'; - } - - public static getInstance(): TestConfig { - if (!TestConfig._instance) { - TestConfig._instance = new TestConfig(); - } - return TestConfig._instance; - } - - private validateEnvironment(): void { - const required = [ - 'FALCON_USERNAME', - 'FALCON_PASSWORD', - 'FALCON_AUTH_SECRET', - 'APP_NAME' - ]; - - const missing = required.filter(key => !process.env[key]); - - if (missing.length > 0) { - throw new Error( - `❌ Missing required environment variables: ${missing.join(', ')}\n` + - `Please check your .env file or environment setup.` - ); - } - } - - private getRequiredEnv(key: string): string { - const value = process.env[key]; - if (!value) { - throw new Error(`❌ Required environment variable ${key} is not set`); - } - return value; - } - - /** - * Get environment-aware configuration for Playwright timeouts - */ - public getPlaywrightTimeouts() { - return { - timeout: this.defaultTimeout, - navigationTimeout: this.navigationTimeout, - actionTimeout: this.isCI ? 15000 : 10000, // Longer in CI for slower hardware - }; - } - - /** - * Get screenshot configuration - */ - public getScreenshotConfig() { - return { - path: this.screenshotPath, - fullPage: true, - type: 'png' as const - // Note: quality parameter is not supported for PNG screenshots - }; - } - - /** - * Get retry configuration for flaky operations - */ - public getRetryConfig() { - return { - attempts: this.retryAttempts, - delay: this.isCI ? 2000 : 1000, - backoff: 'exponential' as const - }; - } - - /** - * Log configuration summary (safe for logs) - */ - public logSummary(): void { - if (this.isCI) { - // Very minimal logging in CI - console.log(`E2E Test Config: ${this.isCI ? 'CI' : 'Local'} | ${this.appName}`); - } else { - // Detailed logging for local development - console.log('🔧 Test Configuration:'); - console.log(` Environment: ${this.isCI ? 'CI' : 'Local'}`); - console.log(` Base URL: ${this.falconBaseUrl}`); - console.log(` App Name: ${this.appName}`); - console.log(` Default Timeout: ${this.defaultTimeout}ms`); - console.log(` Retry Attempts: ${this.retryAttempts}`); - console.log(` Debug Mode: ${this.isDebugMode}${this.isDebugMode ? '' : ' (enable with DEBUG=true npm test or npm run test:debug)'}`); - } - } -} - -// Singleton instance export -export const config = TestConfig.getInstance(); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 212ad1b..8992d3d 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,12 +1,9 @@ import { test as baseTest } from '@playwright/test'; -import { FoundryHomePage } from './pages/FoundryHomePage'; -import { AppManagerPage } from './pages/AppManagerPage'; -import { AppCatalogPage } from './pages/AppCatalogPage'; +import { + FoundryHomePage, AppManagerPage, AppCatalogPage, WorkflowsPage, config, +} from '@crowdstrike/foundry-playwright'; import { UserPreferencesExtensionPage } from './pages/UserPreferencesExtensionPage'; import { CollectionsCRUDExtensionPage } from './pages/CollectionsCRUDExtensionPage'; -import { WorkflowsPage } from './pages/WorkflowsPage'; -import { config } from './config/TestConfig'; -import { logger } from './utils/Logger'; type FoundryFixtures = { foundryHomePage: FoundryHomePage; @@ -19,49 +16,13 @@ type FoundryFixtures = { }; export const test = baseTest.extend({ - // Configure page with centralized settings - page: async ({ page }, use) => { - const timeouts = config.getPlaywrightTimeouts(); - page.setDefaultTimeout(timeouts.timeout); - - // Log configuration on first use - if (!process.env.CONFIG_LOGGED) { - config.logSummary(); - process.env.CONFIG_LOGGED = 'true'; - } - - await use(page); - }, - - // Page object fixtures with dependency injection - foundryHomePage: async ({ page }, use) => { - await use(new FoundryHomePage(page)); - }, - - appManagerPage: async ({ page }, use) => { - await use(new AppManagerPage(page)); - }, - - appCatalogPage: async ({ page }, use) => { - await use(new AppCatalogPage(page)); - }, - - userPreferencesExtensionPage: async ({ page }, use) => { - await use(new UserPreferencesExtensionPage(page)); - }, - - collectionsCRUDExtensionPage: async ({ page }, use) => { - await use(new CollectionsCRUDExtensionPage(page)); - }, - - workflowsPage: async ({ page }, use) => { - await use(new WorkflowsPage(page)); - }, - - // App name from centralized config - appName: async ({}, use) => { - await use(config.appName); - }, + foundryHomePage: async ({ page }, use) => { await use(new FoundryHomePage(page)); }, + appManagerPage: async ({ page }, use) => { await use(new AppManagerPage(page)); }, + appCatalogPage: async ({ page }, use) => { await use(new AppCatalogPage(page)); }, + userPreferencesExtensionPage: async ({ page }, use) => { await use(new UserPreferencesExtensionPage(page)); }, + collectionsCRUDExtensionPage: async ({ page }, use) => { await use(new CollectionsCRUDExtensionPage(page)); }, + workflowsPage: async ({ page }, use) => { await use(new WorkflowsPage(page)); }, + appName: async ({}, use) => { await use(config.appName); }, }); -export { expect } from '@playwright/test'; \ No newline at end of file +export { expect } from '@playwright/test'; diff --git a/e2e/src/pages/AppCatalogPage.ts b/e2e/src/pages/AppCatalogPage.ts deleted file mode 100644 index e867188..0000000 --- a/e2e/src/pages/AppCatalogPage.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * AppCatalogPage - App installation and management - */ - -import { Page } from '@playwright/test'; -import { BasePage } from './BasePage'; -import { RetryHandler } from '../utils/SmartWaiter'; -import { config } from '../config/TestConfig'; - -export class AppCatalogPage extends BasePage { - constructor(page: Page) { - super(page, 'AppCatalogPage'); - } - - protected getPagePath(): string { - return '/foundry/app-catalog'; - } - - protected async verifyPageLoaded(): Promise { - await this.waiter.waitForVisible( - this.page.locator('text=App Catalog').or(this.page.locator('text=Apps')), - { description: 'App Catalog page' } - ); - - this.logger.success('App Catalog page loaded successfully'); - } - - /** - * Search for app in catalog and navigate to its page - */ - private async searchAndNavigateToApp(appName: string): Promise { - this.logger.info(`Searching for app '${appName}' in catalog`); - - // Navigate to app catalog with filter query parameter - // Format: filter=name:~'searchterm' - const baseUrl = config.falconBaseUrl || 'https://falcon.us-2.crowdstrike.com'; - const filterParam = encodeURIComponent(`name:~'${appName}'`); - await this.page.goto(`${baseUrl}/foundry/app-catalog?filter=${filterParam}`); - await this.page.waitForLoadState('networkidle'); - - const appLink = this.page.getByRole('link', { name: appName, exact: true }); - - try { - await this.waiter.waitForVisible(appLink, { - description: `App '${appName}' link in catalog`, - timeout: 10000 - }); - this.logger.success(`Found app '${appName}' in catalog`); - await this.smartClick(appLink, `App '${appName}' link`); - await this.page.waitForLoadState('networkidle'); - } catch (error) { - throw new Error(`Could not find app '${appName}' in catalog. Make sure the app is deployed.`); - } - } - - /** - * Check if app is installed - */ - async isAppInstalled(appName: string): Promise { - this.logger.step(`Check if app '${appName}' is installed`); - - // Search for and navigate to the app's catalog page - await this.searchAndNavigateToApp(appName); - - // Check for installation indicators on the app's page - // Simple check: if "Install now" link exists, app is NOT installed - const installLink = this.page.getByRole('link', { name: 'Install now' }); - const hasInstallLink = await this.elementExists(installLink, 3000); - - const isInstalled = !hasInstallLink; - this.logger.info(`App '${appName}' installation status: ${isInstalled ? 'Installed' : 'Not installed'}`); - - return isInstalled; - } - - /** - * Install app if not already installed - */ - async installApp(appName: string): Promise { - this.logger.step(`Install app '${appName}'`); - - const isInstalled = await this.isAppInstalled(appName); - if (isInstalled) { - this.logger.info(`App '${appName}' is already installed`); - return false; - } - - // Click Install now link - this.logger.info('App not installed, looking for Install now link'); - const installLink = this.page.getByRole('link', { name: 'Install now' }); - - await this.waiter.waitForVisible(installLink, { description: 'Install now link' }); - await this.smartClick(installLink, 'Install now link'); - this.logger.info('Clicked Install now, waiting for install page to load'); - - // Wait for URL to change to install page and page to stabilize - await this.page.waitForURL(/\/foundry\/app-catalog\/[^\/]+\/install$/, { timeout: 10000 }); - await this.page.waitForLoadState('networkidle'); - - // Handle permissions dialog - await this.handlePermissionsDialog(); - - // Check for API integration configuration (none for Collections Toolkit) - await this.configureApiIntegrationIfNeeded(); - - // Click final Install app button - await this.clickInstallAppButton(); - - // Wait for installation to complete - await this.waitForInstallation(appName); - - this.logger.success(`App '${appName}' installed successfully`); - return true; - } - - /** - * Handle permissions dialog if present - */ - private async handlePermissionsDialog(): Promise { - const acceptButton = this.page.getByRole('button', { name: /accept.*continue/i }); - - if (await this.elementExists(acceptButton, 3000)) { - this.logger.info('Permissions dialog detected, accepting'); - await this.smartClick(acceptButton, 'Accept and continue button'); - await this.waiter.delay(2000); - } - } - - /** - * Configure API integration if needed - * Collections Toolkit app has no API integrations, so this is a no-op - */ - private async configureApiIntegrationIfNeeded(): Promise { - // No API configuration needed for Collections Toolkit app - } - - /** - * Click the final "Save and install" button - */ - private async clickInstallAppButton(): Promise { - const installButton = this.page.getByRole('button', { name: 'Save and install' }) - .or(this.page.getByRole('button', { name: 'Install app' })); - - await this.waiter.waitForVisible(installButton, { description: 'Install button' }); - - // Wait for button to be enabled - await installButton.waitFor({ state: 'visible', timeout: 10000 }); - await installButton.waitFor({ state: 'attached', timeout: 5000 }); - - // Simple delay for form to enable button - await this.waiter.delay(1000); - - await this.smartClick(installButton, 'Install button'); - this.logger.info('Clicked Save and install button'); - } - - /** - * Wait for installation to complete - */ - private async waitForInstallation(appName: string): Promise { - this.logger.info('Waiting for installation to complete...'); - - // Wait for URL to change or network to settle - await Promise.race([ - this.page.waitForURL(/\/foundry\/(app-catalog|home)/, { timeout: 15000 }), - this.page.waitForLoadState('networkidle', { timeout: 15000 }) - ]).catch(() => {}); - - // Look for first "installing" message - const installingMessage = this.page.getByText(/installing/i).first(); - - try { - await installingMessage.waitFor({ state: 'visible', timeout: 30000 }); - this.logger.success('Installation started - "installing" message appeared'); - } catch (error) { - throw new Error(`Installation failed to start for app '${appName}' - "installing" message never appeared. Installation may have failed immediately.`); - } - - // Wait for second toast with final status (installed or error) - // Match exact toast messages using app name - const installedMessage = this.page.getByText(`${appName} installed`).first(); - const errorMessage = this.page.getByText(`Error installing ${appName}`).first(); - - try { - const result = await Promise.race([ - installedMessage.waitFor({ state: 'visible', timeout: 60000 }).then(() => 'success'), - errorMessage.waitFor({ state: 'visible', timeout: 60000 }).then(() => 'error') - ]); - - if (result === 'error') { - // Get the actual error message from the toast and clean up formatting - const errorText = await errorMessage.textContent(); - const cleanError = errorText?.replace(/\s+/g, ' ').trim() || 'Unknown error'; - throw new Error(`Installation failed for app '${appName}': ${cleanError}`); - } - this.logger.success('Installation completed successfully - "installed" message appeared'); - } catch (error) { - if (error.message.includes('Installation failed')) { - throw error; - } - throw new Error(`Installation status unclear for app '${appName}' - timed out waiting for "installed" or "error" message after 60 seconds`); - } - - // Brief catalog status check (5-10s) - "installed" toast is the real signal - // This is just for logging/verification, not a hard requirement - this.logger.info('Checking catalog status briefly (installation already confirmed by toast)...'); - - // Navigate directly to app catalog with search query - const baseUrl = new URL(this.page.url()).origin; - await this.page.goto(`${baseUrl}/foundry/app-catalog?q=${appName}`); - await this.page.waitForLoadState('networkidle'); - - // Check status a couple times (up to 10 seconds) - const statusText = this.page.locator('[data-test-selector="status-text"]').filter({ hasText: /installed/i }); - const maxAttempts = 2; // 2 attempts = up to 10 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const isVisible = await statusText.isVisible().catch(() => false); - - if (isVisible) { - this.logger.success('Catalog status verified - shows Installed'); - return; - } - - if (attempt < maxAttempts - 1) { - this.logger.info(`Catalog status not yet updated, waiting 5s before refresh (attempt ${attempt + 1}/${maxAttempts})...`); - await this.waiter.delay(5000); - await this.page.reload({ waitUntil: 'domcontentloaded' }); - } - } - - // Don't fail - the "installed" toast is reliable enough - this.logger.info(`Catalog status not updated yet after ${maxAttempts * 5}s, but toast confirmed installation - continuing`); - } - - /** - * Wait for uninstallation to complete - */ - private async waitForUninstallation(appName: string): Promise { - this.logger.info('Waiting for uninstallation to complete...'); - - // Look for first "uninstalling" message - const uninstallingMessage = this.page.getByText(/uninstalling/i).first(); - - try { - await uninstallingMessage.waitFor({ state: 'visible', timeout: 30000 }); - this.logger.success('Uninstallation started - "uninstalling" message appeared'); - } catch (error) { - throw new Error(`Uninstallation failed to start for app '${appName}' - "uninstalling" message never appeared. Uninstallation may have failed immediately.`); - } - - // Wait for second toast with final status (uninstalled or error) - const uninstalledMessage = this.page.getByText(/uninstalled/i).first(); - const errorMessage = this.page.getByText(/error.*uninstall/i).first(); - - try { - await Promise.race([ - uninstalledMessage.waitFor({ state: 'visible', timeout: 60000 }).then(() => 'success'), - errorMessage.waitFor({ state: 'visible', timeout: 60000 }).then(() => 'error') - ]).then(result => { - if (result === 'error') { - throw new Error(`Uninstallation failed for app '${appName}' - error message appeared`); - } - this.logger.success('Uninstallation completed successfully - "uninstalled" message appeared'); - }); - } catch (error) { - if (error.message.includes('Uninstallation failed')) { - throw error; - } - throw new Error(`Uninstallation status unclear for app '${appName}' - timed out waiting for "uninstalled" or "error" message after 60 seconds`); - } - - // Additional wait: toast appears before app is fully uninstalled in backend - // Verify uninstallation status by checking app catalog - this.logger.info('Verifying uninstallation status in app catalog...'); - - // Navigate directly to app catalog with search query - const baseUrl = new URL(this.page.url()).origin; - await this.page.goto(`${baseUrl}/foundry/app-catalog?q=${appName}`); - await this.page.waitForLoadState('networkidle'); - - // Poll for status every 5 seconds (up to 10 seconds) - const statusText = this.page.locator('[data-test-selector="status-text"]').filter({ hasText: /not installed/i }); - const maxAttempts = 2; // 2 attempts = up to 10 seconds - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const isVisible = await statusText.isVisible().catch(() => false); - - if (isVisible) { - this.logger.success('Uninstallation verified - app status shows Not installed in catalog'); - return; - } - - if (attempt < maxAttempts - 1) { - this.logger.info(`Status not yet updated, waiting 5s before refresh (attempt ${attempt + 1}/${maxAttempts})...`); - await this.waiter.delay(5000); - await this.page.reload({ waitUntil: 'domcontentloaded' }); - } - } - - // Don't fail - the "uninstalled" toast is reliable enough - this.logger.info(`Catalog status not updated yet after ${maxAttempts * 5}s, but toast confirmed uninstallation - continuing`); - } - - /** - * Uninstall app - */ - async uninstallApp(appName: string): Promise { - this.logger.step(`Uninstall app '${appName}'`); - - try { - // Search for and navigate to the app's catalog page - await this.searchAndNavigateToApp(appName); - - // Check if app is actually installed by looking for "Install now" link - // If "Install now" link exists, app is NOT installed - const installLink = this.page.getByRole('link', { name: 'Install now' }); - const hasInstallLink = await this.elementExists(installLink, 3000); - - if (hasInstallLink) { - this.logger.info(`App '${appName}' is already uninstalled`); - return; - } - - // Click the 3-dot menu button - const openMenuButton = this.page.getByRole('button', { name: 'Open menu' }); - await this.waiter.waitForVisible(openMenuButton, { description: 'Open menu button' }); - await this.smartClick(openMenuButton, 'Open menu button'); - - // Click "Uninstall app" menuitem - const uninstallMenuItem = this.page.getByRole('menuitem', { name: 'Uninstall app' }); - await this.waiter.waitForVisible(uninstallMenuItem, { description: 'Uninstall app menuitem' }); - await this.smartClick(uninstallMenuItem, 'Uninstall app menuitem'); - - // Confirm uninstallation in modal - const uninstallButton = this.page.getByRole('button', { name: 'Uninstall' }); - await this.waiter.waitForVisible(uninstallButton, { description: 'Uninstall confirmation button' }); - await this.smartClick(uninstallButton, 'Uninstall button'); - - // Wait for uninstallation to complete with sequential toast messages - await this.waitForUninstallation(appName); - - this.logger.success(`App '${appName}' uninstalled successfully`); - - } catch (error) { - this.logger.warn(`Failed to uninstall app '${appName}': ${error.message}`); - throw error; - } - } -} diff --git a/e2e/src/pages/AppManagerPage.ts b/e2e/src/pages/AppManagerPage.ts deleted file mode 100644 index 6fe82dc..0000000 --- a/e2e/src/pages/AppManagerPage.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Page, expect } from '@playwright/test'; -import { BasePage } from './BasePage'; -import { RetryHandler } from '../utils/SmartWaiter'; - -export class AppManagerPage extends BasePage { - constructor(page: Page) { - super(page, 'AppManagerPage'); - } - - protected getPagePath(): string { - return '/foundry/app-manager'; - } - - protected async verifyPageLoaded(): Promise { - await expect(this.page).toHaveTitle('App manager | Foundry | Falcon'); - } - - async findAndNavigateToApp(appName: string): Promise { - this.logger.step(`Find and navigate to app '${appName}'`); - - return RetryHandler.withPlaywrightRetry( - async () => { - const appList = await this.waiter.waitForVisible( - this.page.getByTestId('custom-apps-list'), - { description: 'Custom apps list' } - ); - - const appText = await this.waiter.waitForVisible( - appList.getByText(appName), - { description: `App '${appName}' text` } - ); - - const parent = appText.locator('../../../../..'); - await this.smartClick(parent.locator('button'), 'App menu button'); - - await this.smartClick( - this.page.getByText('View in app catalog'), - 'View in app catalog' - ); - - await expect(this.page).toHaveTitle('App catalog | Foundry | Falcon'); - await this.waiter.waitForPageLoad(); - - // Wait for app to appear in catalog with retry - const appLink = this.page.getByRole('link', { name: appName }); - - if (!(await this.elementExists(appLink, 15000))) { - this.logger.debug(`App '${appName}' not immediately visible, refreshing page...`); - await this.page.reload(); - await this.waiter.waitForPageLoad(); - - await this.waiter.waitForVisible(appLink, { - description: `App link for '${appName}'`, - timeout: 15000 - }); - } - - await appLink.click(); - await this.waiter.waitForPageLoad(); - - this.logger.success(`Successfully navigated to ${appName} from App manager`); - }, - `Find and navigate to ${appName}` - ); - } -} diff --git a/e2e/src/pages/BasePage.ts b/e2e/src/pages/BasePage.ts deleted file mode 100644 index 398f6de..0000000 --- a/e2e/src/pages/BasePage.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { Page, expect, Locator } from '@playwright/test'; -import { config } from '../config/TestConfig'; -import { logger, LogContext } from '../utils/Logger'; -import { SmartWaiter, RetryHandler } from '../utils/SmartWaiter'; - -/** - * Base page class - * Eliminates duplication and provides consistent patterns - */ -export abstract class BasePage { - protected readonly page: Page; - protected readonly waiter: SmartWaiter; - protected readonly logger: ReturnType; - protected readonly pageName: string; - - constructor(page: Page, pageName: string) { - this.page = page; - this.pageName = pageName; - this.waiter = new SmartWaiter(page, pageName); - this.logger = logger.forPage(pageName); - - // Set page-level timeouts from config - const timeouts = config.getPlaywrightTimeouts(); - page.setDefaultTimeout(timeouts.timeout); - } - - /** - * Get the base URL from centralized config - */ - protected getBaseURL(): string { - return config.falconBaseUrl; - } - - /** - * Navigate to a specific path with retry logic - */ - protected async navigateToPath(path: string, description?: string): Promise { - const url = `${this.getBaseURL()}${path}`; - const desc = description || `Navigate to ${path}`; - - this.logger.step(desc, { url }); - - await RetryHandler.withPlaywrightRetry( - async () => { - await this.page.goto(url); - await this.waiter.waitForPageLoad(desc); - }, - desc - ); - } - - /** - * Click an element with smart waiting and retry - */ - protected async smartClick( - locator: Locator | string, - description: string, - options: { timeout?: number; force?: boolean } = {} - ): Promise { - const defaultTimeout = config.getPlaywrightTimeouts().actionTimeout; - const actualTimeout = options.timeout || defaultTimeout; - - this.logger.step(`Click ${description}`, { - element: typeof locator === 'string' ? locator : 'locator', - timeout: actualTimeout, - force: options.force - }); - - await RetryHandler.withPlaywrightRetry( - async () => { - const element = await this.waiter.waitForVisible(locator, { - timeout: actualTimeout, - description - }); - await element.click({ force: options.force, timeout: actualTimeout }); - }, - `Click ${description}` - ); - } - - /** - * Wait for an element and perform actions on it - */ - protected async waitAndAct( - locator: Locator | string, - action: (element: Locator) => Promise, - description: string, - options: { timeout?: number; state?: 'visible' | 'attached' } = {} - ): Promise { - const defaultTimeout = config.getPlaywrightTimeouts().actionTimeout; - const actualTimeout = options.timeout || defaultTimeout; - const state = options.state || 'visible'; - - this.logger.debug(`Wait and act: ${description}`, { timeout: actualTimeout, state }); - - return RetryHandler.withPlaywrightRetry( - async () => { - const element = state === 'visible' - ? await this.waiter.waitForVisible(locator, { timeout: actualTimeout, description }) - : typeof locator === 'string' - ? this.page.locator(locator) - : locator; - - if (state === 'attached') { - await element.waitFor({ state: 'attached', timeout: actualTimeout }); - } - - return await action(element); - }, - description - ); - } - - /** - * Take a screenshot with consistent naming and error handling - */ - protected async takeScreenshot(filename: string, context: LogContext = {}): Promise { - try { - const screenshotConfig = config.getScreenshotConfig(); - - // 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, - fullPage: screenshotConfig.fullPage, - type: screenshotConfig.type - }); - - this.logger.debug(`Screenshot saved: ${filename}`, { - ...context, - path: fullPath - }); - this.logger.success(`Screenshot saved: ${filename}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - this.logger.warn(`Failed to take screenshot: ${filename} - ${errorMessage}`, error instanceof Error ? error : undefined, context); - } - } - - /** - * Verify page URL matches expected pattern - */ - protected async verifyUrl(urlPattern: RegExp, description: string): Promise { - this.logger.step(`Verify URL: ${description}`, { pattern: urlPattern.toString() }); - - await expect(this.page).toHaveURL(urlPattern, { - timeout: config.navigationTimeout - }); - - this.logger.success(`URL verification passed: ${description}`); - } - - /** - * Wait for specific page to be loaded based on URL pattern - */ - protected async waitForPageUrl(urlPattern: RegExp, description: string): Promise { - await this.waiter.waitForCondition( - async () => urlPattern.test(this.page.url()), - description, - { timeout: config.navigationTimeout } - ); - } - - /** - * Check if element exists without throwing - */ - protected async elementExists( - locator: Locator | string, - timeout: number = 3000, - state: 'visible' | 'attached' | 'detached' | 'hidden' = 'visible' - ): Promise { - try { - const element = typeof locator === 'string' ? this.page.locator(locator) : locator; - await element.waitFor({ state, timeout }); - return true; - } catch (error) { - this.logger.debug(`Element not found in expected state '${state}': ${typeof locator === 'string' ? locator : 'locator'}`, error instanceof Error ? error : undefined); - return false; - } - } - - /** - * Clean up any lingering modals or dialogs using semantic locators - */ - async cleanupModals(): Promise { - try { - const modalCloseButton = this.page.getByRole('button', { name: /close|dismiss|cancel/i }); - if (await this.elementExists(modalCloseButton, 1000)) { - await this.smartClick(modalCloseButton, 'Close modal dialog'); - this.logger.debug('Cleaned up lingering modal'); - } - } catch (error) { - // Ignore cleanup errors - they're not critical - this.logger.debug('Modal cleanup completed (no modals found)'); - } - } - - /** - * Execute operation with performance timing - */ - protected async withTiming( - operation: () => Promise, - operationName: string - ): Promise { - const startTime = Date.now(); - - try { - const result = await operation(); - const duration = Date.now() - startTime; - - logger.performance(operationName, duration, { page: this.pageName }); - - return result; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`${operationName} failed after ${duration}ms`, error instanceof Error ? error : undefined); - throw error; - } - } - - /** - * Abstract method for page-specific verification - */ - protected abstract verifyPageLoaded(): Promise; - - /** - * Navigate to this page and verify it loaded - */ - async goto(): Promise { - await this.withTiming( - async () => { - await this.navigateToPath(this.getPagePath()); - await this.verifyPageLoaded(); - }, - `Navigate to ${this.pageName}` - ); - } - - /** - * Abstract method to get the page path - */ - protected abstract getPagePath(): string; -} \ No newline at end of file diff --git a/e2e/src/pages/FoundryHomePage.ts b/e2e/src/pages/FoundryHomePage.ts deleted file mode 100644 index 772f838..0000000 --- a/e2e/src/pages/FoundryHomePage.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Page, expect } from '@playwright/test'; -import { BasePage } from './BasePage'; - -export class FoundryHomePage extends BasePage { - constructor(page: Page) { - super(page, 'FoundryHomePage'); - } - - protected getPagePath(): string { - return '/foundry/home'; - } - - protected async verifyPageLoaded(): Promise { - await expect(this.page).toHaveTitle('Home | Foundry | Falcon'); - } - - async verifyLoaded(): Promise { - await this.verifyPageLoaded(); - this.logger.success('Foundry home page loaded successfully'); - } - - async navigateToAppManager(): Promise { - this.logger.step('Navigate to App manager'); - - await this.smartClick( - this.page.getByRole('link', { name: 'App manager' }), - 'App manager link' - ); - - await expect(this.page).toHaveTitle('App manager | Foundry | Falcon'); - this.logger.success('Navigated to App manager'); - } -} \ No newline at end of file diff --git a/e2e/src/pages/HostPanelExtensionPage.ts b/e2e/src/pages/HostPanelExtensionPage.ts index 4993f91..196f5f2 100644 --- a/e2e/src/pages/HostPanelExtensionPage.ts +++ b/e2e/src/pages/HostPanelExtensionPage.ts @@ -1,5 +1,5 @@ import { Page, expect, FrameLocator } from '@playwright/test'; -import { SocketNavigationPage } from './SocketNavigationPage'; +import { SocketNavigationPage } from '@crowdstrike/foundry-playwright'; /** * Base page object for testing UI extensions in hosts.host.panel socket diff --git a/e2e/src/pages/SocketNavigationPage.ts b/e2e/src/pages/SocketNavigationPage.ts deleted file mode 100644 index 103943c..0000000 --- a/e2e/src/pages/SocketNavigationPage.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { Page, expect } from '@playwright/test'; -import { BasePage } from './BasePage'; - -/** - * Utility page object for navigating to pages with socket extensions - * - * Uses menu-based navigation to ensure reliability when URLs change. - * - * Supports testing Foundry extensions that appear in various sockets: - * - activity.detections.details (Endpoint Detections) - * - xdr.detections.panel (XDR Detections) - * - ngsiem.workbench.details (NGSIEM Incidents) - * - hosts.host.panel (Host Management) - */ -export class SocketNavigationPage extends BasePage { - constructor(page: Page) { - super(page, 'Socket Navigation'); - } - - protected getPagePath(): string { - throw new Error('Socket navigation does not have a direct path - use menu navigation'); - } - - protected async verifyPageLoaded(): Promise { - } - - /** - * Navigate to Endpoint Detections page (activity.detections.details socket) - * Uses menu navigation: Menu → Endpoint security → Monitor → Endpoint detections - */ - async navigateToEndpointDetections(): Promise { - return this.withTiming( - async () => { - this.logger.info('Navigating to Endpoint Detections page'); - - // Open the hamburger menu - use data-test-selector="nav-trigger" to target the specific menu - // (not other Menu buttons that may appear on the page) - const menuButton = this.page.locator('[data-test-selector="nav-trigger"]'); - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - // Click "Endpoint security" - const navigation = this.page.getByRole('navigation'); - const endpointSecurityButton = navigation.getByRole('button', { name: /Endpoint security/ }); - await endpointSecurityButton.click(); - await this.waiter.delay(500); - - // Click "Monitor" to expand submenu (if not already expanded) - const monitorButton = this.page.getByRole('button', { name: /^Monitor$/i }); - const isExpanded = await monitorButton.getAttribute('aria-expanded'); - if (isExpanded !== 'true') { - await monitorButton.click(); - await this.waiter.delay(500); - } - - // Click "Endpoint detections" link - const endpointDetectionsLink = this.page.getByRole('link', { name: /Endpoint detections/i }); - await endpointDetectionsLink.click(); - - // Wait for page to load - await this.page.waitForLoadState('networkidle'); - - // Verify we're on the detections page by looking for the page heading - const pageTitle = this.page.locator('h1, h2').filter({ hasText: /Detections/i }).first(); - await expect(pageTitle).toBeVisible({ timeout: 10000 }); - - this.logger.success('Navigated to Endpoint Detections page'); - }, - 'Navigate to Endpoint Detections' - ); - } - - /** - * Navigate to XDR Detections page (xdr.detections.panel socket) - * Uses menu navigation: Menu → Next-Gen SIEM → appropriate submenu → XDR detections - * Note: Requires XDR SKU - may not be available in all environments - */ - async navigateToXDRDetections(): Promise { - return this.withTiming( - async () => { - this.logger.info('Navigating to XDR Detections page (Incidents)'); - - // Navigate to Foundry home first to ensure menu is available - await this.navigateToPath('/foundry/home', 'Foundry home'); - await this.page.waitForLoadState('networkidle'); - - // Open the hamburger menu - use data-test-selector="nav-trigger" to target the specific menu - // (not other Menu buttons that may appear on the page) - const menuButton = this.page.locator('[data-test-selector="nav-trigger"]'); - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - // Click "Next-Gen SIEM" in the menu (not the home page card) - const ngsiemButton = this.page.getByTestId('popout-button').filter({ hasText: /Next-Gen SIEM/i }); - await ngsiemButton.click(); - await this.waiter.delay(500); - - // Click "Incidents" - use section-link selector to avoid the learn card - - const incidentsLink = this.page.getByTestId('section-link').filter({ hasText: /Incidents/i }); - await incidentsLink.click(); - - await this.page.waitForLoadState('networkidle'); - - const pageTitle = this.page.locator('h1, [role="heading"]').first(); - await expect(pageTitle).toBeVisible({ timeout: 10000 }); - - this.logger.success('Navigated to XDR Detections page (Incidents)'); - }, - 'Navigate to XDR Detections' - ); - } - - /** - * Navigate to NGSIEM Incidents page (ngsiem.workbench.details socket) - * Uses menu navigation: Menu → Next-Gen SIEM → Incidents - */ - async navigateToNGSIEMIncidents(): Promise { - return this.withTiming( - async () => { - this.logger.info('Navigating to NGSIEM Incidents page'); - - // Navigate to Foundry home first to ensure menu is available - await this.navigateToPath('/foundry/home', 'Foundry home'); - await this.page.waitForLoadState('networkidle'); - - // Open the hamburger menu - use data-test-selector="nav-trigger" to target the specific menu - // (not other Menu buttons that may appear on the page) - const menuButton = this.page.locator('[data-test-selector="nav-trigger"]'); - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - // Click "Next-Gen SIEM" in the menu (not the home page card) - const ngsiemButton = this.page.getByTestId('popout-button').filter({ hasText: /Next-Gen SIEM/i }); - await ngsiemButton.click(); - await this.waiter.delay(500); - - // Click "Incidents" - use section-link selector to avoid the learn card - const incidentsLink = this.page.getByTestId('section-link').filter({ hasText: /Incidents/i }); - await incidentsLink.click(); - - await this.page.waitForLoadState('networkidle'); - - const pageTitle = this.page.locator('h1, [role="heading"]').first(); - await expect(pageTitle).toBeVisible({ timeout: 10000 }); - - this.logger.success('Navigated to NGSIEM Incidents page'); - }, - 'Navigate to NGSIEM Incidents' - ); - } - - async openFirstDetection(): Promise { - return this.withTiming( - async () => { - await this.page.waitForLoadState('networkidle'); - - // In the new Endpoint Detections UI, detections are represented as buttons in the table - // Look for process/host information buttons - const firstDetectionButton = this.page.locator('[role="gridcell"] button').first(); - await firstDetectionButton.waitFor({ state: 'visible', timeout: 10000 }); - await firstDetectionButton.click(); - - await this.page.waitForLoadState('networkidle'); - }, - 'Open first detection' - ); - } - - async verifyExtensionInSocket(extensionName: string): Promise { - return this.withTiming( - async () => { - const extension = this.page.getByRole('tab', { name: new RegExp(extensionName, 'i') }); - await expect(extension).toBeVisible({ timeout: 10000 }); - }, - `Verify extension "${extensionName}" in socket` - ); - } - - async clickExtensionTab(extensionName: string): Promise { - return this.withTiming( - async () => { - const extension = this.page.getByRole('tab', { name: new RegExp(extensionName, 'i') }); - await extension.click({ force: true }); - }, - `Click extension tab "${extensionName}"` - ); - } - - /** - * Navigate to Host Management page (hosts.host.panel socket) - * Uses menu navigation: Menu → Endpoint security → Host management - */ - async navigateToHostManagement(): Promise { - return this.withTiming( - async () => { - this.logger.info('Navigating to Host Management page'); - - // Open the hamburger menu - use data-test-selector="nav-trigger" to target the specific menu - // (not other Menu buttons that may appear on the page) - const menuButton = this.page.locator('[data-test-selector="nav-trigger"]'); - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - // Click "Endpoint security" - const navigation = this.page.getByRole('navigation'); - const endpointSecurityButton = navigation.getByRole('button', { name: /Endpoint security/ }); - await endpointSecurityButton.click(); - await this.waiter.delay(500); - - // Click "Host management" link - const hostManagementLink = this.page.getByRole('link', { name: /Host management/i }); - await hostManagementLink.click(); - - // Wait for page to load - await this.page.waitForLoadState('networkidle'); - - // Verify we're on the host management page - const pageTitle = this.page.locator('h1, [role="heading"]').first(); - await expect(pageTitle).toBeVisible({ timeout: 10000 }); - - this.logger.success('Navigated to Host Management page'); - }, - 'Navigate to Host Management' - ); - } - - /** Open first host to show details panel with extensions */ - async openFirstHost(): Promise { - return this.withTiming( - async () => { - await this.page.waitForLoadState('networkidle'); - - // Wait for hosts table to load - await this.page.waitForSelector('table', { state: 'visible', timeout: 10000 }); - - // Click on the first hostname link in the table - const firstHostLink = this.page.locator('table tbody tr:first-child td:first-child a, table tbody tr:first-child button').first(); - await firstHostLink.waitFor({ state: 'visible', timeout: 10000 }); - await firstHostLink.click(); - - // Wait for the side panel to appear (Host Information section) - await this.page.waitForSelector('text=/Host Information|Asset details/i', { state: 'visible', timeout: 15000 }); - await this.page.waitForLoadState('networkidle'); - - this.logger.info('Opened first host details panel'); - }, - 'Open first host' - ); - } -} diff --git a/e2e/src/pages/UserPreferencesExtensionPage.ts b/e2e/src/pages/UserPreferencesExtensionPage.ts index 5298358..2fd8c80 100644 --- a/e2e/src/pages/UserPreferencesExtensionPage.ts +++ b/e2e/src/pages/UserPreferencesExtensionPage.ts @@ -1,6 +1,6 @@ import { Page, expect, FrameLocator } from '@playwright/test'; import { HostPanelExtensionPage } from './HostPanelExtensionPage'; -import { config } from '../config/TestConfig'; +import { config } from '@crowdstrike/foundry-playwright'; /** * Page object for testing the "User Preferences" UI extension diff --git a/e2e/src/pages/WorkflowsPage.ts b/e2e/src/pages/WorkflowsPage.ts deleted file mode 100644 index b5d3d78..0000000 --- a/e2e/src/pages/WorkflowsPage.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Page, expect } from '@playwright/test'; -import { BasePage } from './BasePage'; - -/** - * Page object for Workflow testing - * - * Supports both workflow rendering verification and execution with inputs - */ -export class WorkflowsPage extends BasePage { - constructor(page: Page) { - super(page, 'Workflows'); - } - - protected getPagePath(): string { - return '/workflow/fusion'; - } - - protected async verifyPageLoaded(): Promise { - await expect(this.page.getByRole('heading', { name: /Workflow/i })).toBeVisible({ timeout: 10000 }); - this.logger.success('Workflows page loaded'); - } - - /** - * Navigate to workflows page via Fusion SOAR menu - */ - async navigateToWorkflows(): Promise { - return this.withTiming( - async () => { - this.logger.info('Navigating to Fusion SOAR Workflows'); - - // Navigate to home first - await this.navigateToPath('/foundry/home', 'Foundry Home'); - - // Open hamburger menu - const menuButton = this.page.getByTestId('nav-trigger'); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - // Click Fusion SOAR in the navigation menu (not the home page cards) - const navigation = this.page.locator('nav, [role="navigation"]'); - const fusionSoarButton = navigation.getByRole('button', { name: 'Fusion SOAR', exact: true }); - await fusionSoarButton.click(); - - // Click Workflows link - const workflowsLink = this.page.getByRole('link', { name: 'Workflows' }); - await workflowsLink.click(); - - // Wait for workflows page to load - await this.page.waitForLoadState('networkidle'); - await this.verifyPageLoaded(); - }, - 'Navigate to Workflows' - ); - } - - /** - * Search for a specific workflow by name - */ - async searchWorkflow(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info(`Searching for workflow: ${workflowName}`); - - // Click the "Search workflows" button to open search - const searchButton = this.page.getByRole('button', { name: /search workflows/i }); - await searchButton.click(); - - // Now the search input should appear - const searchBox = this.page.getByRole('searchbox') - .or(this.page.locator('input[type="search"]')) - .or(this.page.locator('input[placeholder*="Search"]')); - - await searchBox.fill(workflowName); - await this.page.keyboard.press('Enter'); - await this.page.waitForLoadState('networkidle'); - - this.logger.success(`Searched for workflow: ${workflowName}`); - }, - `Search for workflow: ${workflowName}` - ); - } - - /** - * Verify a workflow appears in the list - */ - async verifyWorkflowExists(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info(`Verifying workflow exists: ${workflowName}`); - - // Search for the workflow first - await this.searchWorkflow(workflowName); - - // Look for the workflow link in the results - const workflowLink = this.page.getByRole('link', { name: new RegExp(workflowName, 'i') }); - - try { - await expect(workflowLink).toBeVisible({ timeout: 5000 }); - this.logger.success(`Workflow found: ${workflowName}`); - } catch (error) { - this.logger.error(`Workflow not found: ${workflowName}`); - throw error; - } - }, - `Verify workflow exists: ${workflowName}` - ); - } - - /** - * Open a workflow to view its details - */ - async openWorkflow(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info(`Opening workflow: ${workflowName}`); - - // Look for the workflow link directly in the table - const workflowLink = this.page.getByRole('link', { name: new RegExp(workflowName, 'i') }).first(); - await workflowLink.click(); - - // Wait for workflow details to load - await this.page.waitForLoadState('networkidle'); - - this.logger.success(`Opened workflow: ${workflowName}`); - }, - `Open workflow: ${workflowName}` - ); - } - - /** - * Verify workflow renders (shows the workflow canvas/details) - */ - async verifyWorkflowRenders(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info(`Verifying workflow renders: ${workflowName}`); - - await this.openWorkflow(workflowName); - - // Check for workflow canvas or details view - // Workflows typically show a canvas with nodes or a details panel - const hasCanvas = await this.page.locator('[class*="workflow"], [class*="canvas"], [class*="flow"]').isVisible({ timeout: 5000 }).catch(() => false); - - if (hasCanvas) { - this.logger.success(`Workflow renders correctly: ${workflowName}`); - } else { - this.logger.warn(`Workflow page loaded but canvas not detected: ${workflowName}`); - this.logger.info('This is acceptable for E2E - workflow exists and loads'); - } - }, - `Verify workflow renders: ${workflowName}` - ); - } - - /** - * Execute a workflow with optional input parameters - */ - async executeWorkflow(workflowName: string, inputs?: Record): Promise { - return this.withTiming( - async () => { - this.logger.info(`Executing workflow: ${workflowName}`); - - // Ensure we're on the workflows list page, not an individual workflow page - await this.navigateToWorkflows(); - - // Click "Open menu" button for the specific workflow row - const workflowRow = this.page.getByRole('row', { name: new RegExp(workflowName, 'i') }); - const openMenuButton = workflowRow.getByRole('button', { name: /open menu/i }); - await openMenuButton.click(); - - // Click "Execute workflow" option - const executeOption = this.page.getByRole('menuitem', { name: /execute workflow/i }); - await executeOption.click(); - - // Wait for execution modal to appear - await expect(this.page.getByRole('heading', { name: /execute on demand workflow/i })).toBeVisible({ timeout: 5000 }); - this.logger.info('Execution modal opened'); - - // Fill in input parameters if provided - if (inputs && Object.keys(inputs).length > 0) { - this.logger.info(`Filling in ${Object.keys(inputs).length} input parameter(s)`); - for (const [key, value] of Object.entries(inputs)) { - // Look for input field by label or placeholder - const inputField = this.page.getByLabel(new RegExp(key, 'i')) - .or(this.page.getByPlaceholder(new RegExp(key, 'i'))) - .or(this.page.locator(`input[name*="${key}"]`)); - - await inputField.fill(value); - this.logger.info(`Set ${key} = ${value}`); - } - } - - // Click "Execute now" button - const executeButton = this.page.getByRole('button', { name: /execute now/i }); - await executeButton.click(); - - // Wait for execution confirmation - await expect(this.page.getByText(/workflow execution triggered/i)).toBeVisible({ timeout: 10000 }); - this.logger.success(`Workflow execution triggered: ${workflowName}`); - }, - `Execute workflow: ${workflowName}` - ); - } - - /** - * Verify workflow execution completed successfully - * This checks the execution notification or navigates to execution log - */ - async verifyWorkflowExecutionSuccess(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info(`Verifying workflow execution succeeded: ${workflowName}`); - - // Check for the execution triggered notification - const notification = this.page.getByText(/workflow execution triggered/i); - - try { - await expect(notification).toBeVisible({ timeout: 5000 }); - this.logger.success(`Workflow execution confirmed: ${workflowName}`); - - // Optional: Click "View" link to see execution details - const viewLink = this.page.getByRole('link', { name: /^view$/i }); - if (await viewLink.isVisible({ timeout: 2000 })) { - this.logger.info('Execution details view link available'); - } - } catch (error) { - this.logger.error(`Failed to verify workflow execution: ${error.message}`); - throw error; - } - }, - `Verify workflow execution success: ${workflowName}` - ); - } - - /** - * Execute workflow and verify it completes successfully - * Combines executeWorkflow and verifyWorkflowExecutionSuccess - */ - async executeAndVerifyWorkflow(workflowName: string, inputs?: Record): Promise { - return this.withTiming( - async () => { - await this.executeWorkflow(workflowName, inputs); - await this.verifyWorkflowExecutionSuccess(workflowName); - }, - `Execute and verify workflow: ${workflowName}` - ); - } -} diff --git a/e2e/src/utils.cjs b/e2e/src/utils.cjs deleted file mode 100644 index 7f2593d..0000000 --- a/e2e/src/utils.cjs +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; - -const OTPAuth = require('otpauth'); -const dotenv = require('@dotenvx/dotenvx'); - -dotenv.config(); - -/** - * Gets the baseUrl to use for the environment and context the tests are running in - */ -const baseURL = process.env.FALCON_BASE_URL ?? 'https://falcon.us-2.crowdstrike.com/'; - -/** - * @param {string} role - */ -async function getUserCredentials(role) { - let email = process.env.FALCON_USERNAME; - let password = process.env.FALCON_PASSWORD; - let secret = process.env.FALCON_AUTH_SECRET; - - return { email, password, secret }; -} - -/** - * Generates a time-based one-time password - * @param {string} secret - Secret key for 2FA - */ -function getTotp(secret) { - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, - }); - - return totp.generate(); -} - -module.exports = { - baseURL, - getUserCredentials, - getTotp -}; diff --git a/e2e/src/utils/Logger.ts b/e2e/src/utils/Logger.ts deleted file mode 100644 index 02271dd..0000000 --- a/e2e/src/utils/Logger.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Structured logging service for E2E tests - * Provides consistent, searchable, and actionable logging - */ -export interface LogContext { - page?: string; - action?: string; - element?: string; - timeout?: number; - attempt?: number; - [key: string]: any; -} - -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'step'; - -export class Logger { - private static _instance: Logger; - private readonly isCI: boolean; - private readonly isDebugMode: boolean; - private stepCounter = 0; - - private constructor() { - this.isCI = !!process.env.CI; - this.isDebugMode = process.env.DEBUG === 'true'; - } - - public static getInstance(): Logger { - if (!Logger._instance) { - Logger._instance = new Logger(); - } - return Logger._instance; - } - - /** - * Log a test step with clear visual indication - */ - step(page: string, action: string, context: LogContext = {}): void { - this.stepCounter++; - const emoji = this.getStepEmoji(action); - const message = `${emoji} [${this.stepCounter}] ${page}: ${action}`; - - this.log('step', message, { page, action, ...context }); - } - - /** - * Log successful operations - */ - success(message: string, context: LogContext = {}): void { - this.log('info', `✅ ${message}`, context); - } - - /** - * Log warnings (non-blocking issues) - */ - warn(message: string, context: LogContext = {}): void { - this.log('warn', `âš ī¸ ${message}`, context); - } - - /** - * Log errors (blocking issues) - */ - error(message: string, error?: Error, context: LogContext = {}): void { - const errorDetails = error ? ` - ${error.message}` : ''; - this.log('error', `❌ ${message}${errorDetails}`, { - ...context, - stack: error?.stack - }); - } - - /** - * Log debug information (only in debug mode) - */ - debug(message: string, context: LogContext = {}): void { - if (this.isDebugMode) { - this.log('debug', `🔍 DEBUG: ${message}`, context); - } - } - - /** - * Log informational messages - */ - info(message: string, context: LogContext = {}): void { - this.log('info', `â„šī¸ ${message}`, context); - } - - /** - * Log performance metrics - */ - performance(operation: string, duration: number, context: LogContext = {}): void { - const formattedDuration = duration > 1000 - ? `${(duration / 1000).toFixed(2)}s` - : `${duration}ms`; - - this.log('info', `⚡ ${operation} completed in ${formattedDuration}`, { - ...context, - duration, - performance: true - }); - } - - /** - * Log retry attempts - */ - retry(operation: string, attempt: number, maxAttempts: number, error?: Error): void { - const message = `🔄 Retry ${attempt}/${maxAttempts}: ${operation}`; - const level = attempt === maxAttempts ? 'error' : 'warn'; - - this.log(level, message, { - operation, - attempt, - maxAttempts, - isLastAttempt: attempt === maxAttempts, - error: error?.message - }); - } - - /** - * Log test summary information - */ - summary(title: string, items: string[]): void { - this.log('info', `📊 ${title}:`); - items.forEach(item => { - this.log('info', ` ${item}`); - }); - } - - /** - * Create a scoped logger for a specific page - */ - forPage(pageName: string) { - return { - step: (action: string, context: LogContext = {}) => - this.step(pageName, action, context), - success: (message: string, context: LogContext = {}) => - this.success(message, { ...context, page: pageName }), - warn: (message: string, context: LogContext = {}) => - this.warn(message, { ...context, page: pageName }), - error: (message: string, error?: Error, context: LogContext = {}) => - this.error(message, error, { ...context, page: pageName }), - debug: (message: string, context: LogContext = {}) => - this.debug(message, { ...context, page: pageName }), - info: (message: string, context: LogContext = {}) => - this.info(message, { ...context, page: pageName }), - }; - } - - private log(level: LogLevel, message: string, context: LogContext = {}): void { - const timestamp = new Date().toISOString(); - - // 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); - - // Log context details in debug mode - if (this.isDebugMode && Object.keys(context).length > 0) { - console.log(' Context:', JSON.stringify(context, null, 2)); - } - } - } - - private getStepEmoji(action: string): string { - const actionLower = action.toLowerCase(); - - if (actionLower.includes('navigate') || actionLower.includes('goto')) return '🧭'; - if (actionLower.includes('click')) return '👆'; - if (actionLower.includes('type') || actionLower.includes('fill')) return 'âŒ¨ī¸'; - if (actionLower.includes('wait') || actionLower.includes('loading')) return 'âŗ'; - if (actionLower.includes('verify') || actionLower.includes('check')) return '🔍'; - if (actionLower.includes('install') || actionLower.includes('deploy')) return 'đŸ“Ļ'; - if (actionLower.includes('screenshot')) return '📸'; - if (actionLower.includes('menu') || actionLower.includes('button')) return '🔘'; - - return '🔧'; // Default for other actions - } -} - -// Singleton instance export -export const logger = Logger.getInstance(); \ No newline at end of file diff --git a/e2e/src/utils/SmartWaiter.ts b/e2e/src/utils/SmartWaiter.ts deleted file mode 100644 index 08c6dee..0000000 --- a/e2e/src/utils/SmartWaiter.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { Page, Locator, expect } from '@playwright/test'; -import { logger } from './Logger'; -import { config } from '../config/TestConfig'; - -/** - * Waiting and retry utilities - * Eliminates hard-coded timeouts with intelligent waiting strategies - */ - -export interface WaitOptions { - timeout?: number; - retries?: number; - retryDelay?: number; - description?: string; -} - -export interface RetryOptions { - maxAttempts?: number; - delay?: number; - backoff?: 'linear' | 'exponential'; - shouldRetry?: (error: Error) => boolean; -} - -export class SmartWaiter { - constructor(private page: Page, private pageName: string = 'Unknown') {} - - /** - * Wait for an element to be visible with smart retry logic - */ - async waitForVisible( - locator: Locator | string, - options: WaitOptions = {} - ): Promise { - const actualLocator = typeof locator === 'string' - ? this.page.locator(locator) - : locator; - - const { timeout = config.navigationTimeout, description } = options; - const elementDesc = description || 'element'; - - logger.debug(`Waiting for ${elementDesc} to be visible`, { - page: this.pageName, - timeout, - selector: typeof locator === 'string' ? locator : 'locator' - }); - - await actualLocator.waitFor({ - state: 'visible', - timeout - }); - - return actualLocator; - } - - /** - * Wait for page to be fully loaded with network idle - */ - async waitForPageLoad(description: string = 'page load'): Promise { - logger.debug(`Waiting for ${description}`, { page: this.pageName }); - - await Promise.all([ - this.page.waitForLoadState('networkidle'), - this.page.waitForLoadState('domcontentloaded') - ]); - } - - /** - * Wait for a condition to be true with custom polling - */ - async waitForCondition( - condition: () => Promise, - description: string, - options: WaitOptions = {} - ): Promise { - const { timeout = config.defaultTimeout, retryDelay = 500 } = options; - - logger.debug(`Waiting for condition: ${description}`, { - page: this.pageName, - timeout - }); - - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - try { - if (await condition()) { - return; - } - } catch (error) { - // Continue polling on errors - } - - await this.page.waitForTimeout(retryDelay); - } - - throw new Error(`Timeout waiting for condition: ${description} after ${timeout}ms`); - } - - /** - * Smart wait for navigation menu to expand - */ - async waitForMenuExpansion(): Promise { - await this.waitForCondition( - async () => { - const expandedMenus = await this.page.locator('[expanded], [aria-expanded="true"]').count(); - return expandedMenus > 0; - }, - 'navigation menu to expand', - { timeout: 5000 } - ); - } - - /** - * Smart wait for app installation status - */ - async waitForAppInstallationStatus(appName: string, expectedStatus: 'installed' | 'not-installed'): Promise { - await this.waitForCondition( - async () => { - const statusElements = await this.page.locator(`text=${appName}`).locator('../..').locator('text=Installed').count(); - const isInstalled = statusElements > 0; - return expectedStatus === 'installed' ? isInstalled : !isInstalled; - }, - `app ${appName} to be ${expectedStatus}`, - { timeout: 60000 } // App operations can take time - ); - } - - /** - * Delay execution - */ - async delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } -} - -export class RetryHandler { - /** - * Execute an operation with exponential backoff retry - */ - static async withRetry( - operation: () => Promise, - operationName: string, - options: RetryOptions = {} - ): Promise { - const { - maxAttempts = config.retryAttempts, - delay = config.getRetryConfig().delay, - backoff = 'exponential', - shouldRetry = () => true - } = options; - - let lastError: Error; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const result = await operation(); - - if (attempt > 1) { - logger.success(`${operationName} succeeded on attempt ${attempt}`); - } - - return result; - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - if (attempt === maxAttempts || !shouldRetry(lastError)) { - logger.error(`${operationName} failed after ${attempt} attempts`, lastError); - throw lastError; - } - - const currentDelay = backoff === 'exponential' - ? delay * Math.pow(2, attempt - 1) - : delay; - - logger.retry(operationName, attempt, maxAttempts, lastError); - - await new Promise(resolve => setTimeout(resolve, currentDelay)); - } - } - - throw lastError!; - } - - /** - * Retry specifically for Playwright operations - */ - static async withPlaywrightRetry( - operation: () => Promise, - operationName: string, - options: RetryOptions = {} - ): Promise { - return this.withRetry( - operation, - operationName, - { - ...options, - shouldRetry: (error) => { - // Don't retry on assertion errors - these are test failures - if (error.message.includes('expect(')) { - return false; - } - - // Retry on timeout and network errors - return error.message.includes('timeout') || - error.message.includes('waiting for') || - error.message.includes('not found') || - (options.shouldRetry ? options.shouldRetry(error) : true); - } - } - ); - } -} \ No newline at end of file diff --git a/e2e/tests/app-install.setup.ts b/e2e/tests/app-install.setup.ts deleted file mode 100644 index 475b14f..0000000 --- a/e2e/tests/app-install.setup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test as setup } from '../src/fixtures'; - -setup('install collections toolkit app', async ({ appCatalogPage, appName }) => { - // Check if app is already installed (this navigates to the app page) - const isInstalled = await appCatalogPage.isAppInstalled(appName); - - if (!isInstalled) { - console.log(`App '${appName}' is not installed. Installing...`); - const installed = await appCatalogPage.installApp(appName); - - if (!installed) { - throw new Error(`Failed to install app '${appName}'`); - } - } else { - console.log(`App '${appName}' is already installed`); - } -}); diff --git a/e2e/tests/app-uninstall.teardown.ts b/e2e/tests/app-uninstall.teardown.ts deleted file mode 100644 index 5aa190f..0000000 --- a/e2e/tests/app-uninstall.teardown.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test as teardown } from '../src/fixtures'; - -teardown('uninstall Collections Toolkit app', async ({ appCatalogPage, appName }) => { - // Clean up by uninstalling the app after all tests complete - await appCatalogPage.navigateToPath('/foundry/app-catalog', 'App Catalog'); - await appCatalogPage.uninstallApp(appName); -}); \ No newline at end of file diff --git a/e2e/tests/authenticate.setup.ts b/e2e/tests/authenticate.setup.ts deleted file mode 100644 index ac55314..0000000 --- a/e2e/tests/authenticate.setup.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { authenticate } from '../src/authenticate.cjs'; -import { baseURL, getUserCredentials } from '../src/utils.cjs'; -import { expect, request, test as setup } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; - -let requestContext: APIRequestContext; -const AuthFile = "playwright/.auth/user.json"; - -setup('authenticate', async () => { - requestContext = await request.newContext({baseURL}); - - const {email, password, secret} = await getUserCredentials('2fa-user'); - - await authenticate(requestContext, {email, password, secret}); - - const authVerifyResponse = await requestContext.post('/api2/auth/verify', { - data: {checks: []}, - }); - - expect(authVerifyResponse.ok()).toBe(true); - await requestContext.storageState({ path: AuthFile }); -}); From c3040cf2525d051049aa6e43bf37297199e9687e Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 23 Apr 2026 11:54:18 -0600 Subject: [PATCH 2/2] =?UTF-8?q?Update=20foundry-playwright=20library=20(ne?= =?UTF-8?q?tworkidle=20=E2=86=92=20domcontentloaded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6e3981a..2b82134 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -21,7 +21,7 @@ "node_modules/@crowdstrike/foundry-playwright": { "version": "0.5.0", "resolved": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz", - "integrity": "sha512-fZRbY32K0TJXFVgRr8hkq957CJr5iGlcgbcx8GqsSeVn4rBmd6HRU+HoiRgEGtCIi0bDaWvFcCLsrlLnZOlzYA==", + "integrity": "sha512-o+6eBYQBeE7qrzc1N132/Zg3lmFujMQ9OGRdN1cKnF2KXTJXR4qe4anRAgz0spYAX5au1rDcRnsaNWczIBOqSQ==", "license": "MIT", "dependencies": { "@dotenvx/dotenvx": "^1.61.0",