diff --git a/e2e/constants/AuthFile.ts b/e2e/constants/AuthFile.ts deleted file mode 100644 index 970a66a..0000000 --- a/e2e/constants/AuthFile.ts +++ /dev/null @@ -1 +0,0 @@ -export const AuthFile = 'playwright/.auth/user.json'; diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6df7958..336837a 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": "25.6.0" }, "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-o+6eBYQBeE7qrzc1N132/Zg3lmFujMQ9OGRdN1cKnF2KXTJXR4qe4anRAgz0spYAX5au1rDcRnsaNWczIBOqSQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.1.tgz", + "integrity": "sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q==", "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" @@ -44,13 +57,13 @@ } }, "node_modules/@ecies/ciphers": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", - "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", "license": "MIT", "engines": { "bun": ">=1", - "deno": ">=2", + "deno": ">=2.7.10", "node": ">=16" }, "peerDependencies": { @@ -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" @@ -113,13 +125,13 @@ } }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "node_modules/commander": { @@ -167,9 +179,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -179,9 +191,9 @@ } }, "node_modules/eciesjs": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", - "integrity": "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==", + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", + "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", "license": "MIT", "dependencies": { "@ecies/ciphers": "^0.2.5", @@ -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, @@ -293,12 +304,12 @@ } }, "node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/merge-stream": { @@ -386,9 +397,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -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" @@ -466,9 +475,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -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..58ba9a2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,7 +1,7 @@ { "name": "playwright-foundry", "version": "1.0.0", - "description": "Playwright e2e tests to ensure app installs and renders properly", + "description": "Playwright E2E tests for foundry-sample-functions-python", "scripts": { "test": "npx playwright test", "test:ui": "npx playwright test --ui", @@ -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": "25.6.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index c1c8011..630cbbf 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,60 +1,5 @@ -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 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: 'list', - 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"] - }, - ], +export default defineFoundryConfig({ + appInstallDir: './tests', }); 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 d6418cf..46c3af1 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,39 +1,25 @@ import { test as baseTest } from '@playwright/test'; -import { FoundryHomePage } from './pages/FoundryHomePage'; -import { AppManagerPage } from './pages/AppManagerPage'; -import { AppCatalogPage } from './pages/AppCatalogPage'; -import { HelloExtensionPage } from './pages/HelloExtensionPage'; -import { WorkflowsPage } from './pages/WorkflowsPage'; -import { HostManagementPage } from './pages/HostManagementPage'; -import { config } from './config/TestConfig'; -import { logger } from './utils/Logger'; +import { + FoundryHomePage, + AppManagerPage, + AppCatalogPage, + DetectionExtensionPage, + WorkflowsPage, + HostManagementPage, + config, +} from '@crowdstrike/foundry-playwright'; type FoundryFixtures = { foundryHomePage: FoundryHomePage; appManagerPage: AppManagerPage; appCatalogPage: AppCatalogPage; - helloExtensionPage: HelloExtensionPage; + detectionExtensionPage: DetectionExtensionPage; workflowsPage: WorkflowsPage; hostManagementPage: HostManagementPage; appName: string; }; 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)); }, @@ -46,8 +32,8 @@ export const test = baseTest.extend({ await use(new AppCatalogPage(page)); }, - helloExtensionPage: async ({ page }, use) => { - await use(new HelloExtensionPage(page)); + detectionExtensionPage: async ({ page }, use) => { + await use(new DetectionExtensionPage(page)); }, workflowsPage: async ({ page }, use) => { @@ -58,11 +44,9 @@ export const test = baseTest.extend({ await use(new HostManagementPage(page)); }, - - // App name from centralized config 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 dc1271e..0000000 --- a/e2e/src/pages/AppCatalogPage.ts +++ /dev/null @@ -1,358 +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 screen - await this.configureServiceNowIfNeeded(); - - // 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 ServiceNow API integration if configuration form is present - */ - private async configureServiceNowIfNeeded(): Promise { - this.logger.info('Checking if ServiceNow API configuration is required...'); - - // Check if there are text input fields (configuration form) - const textInputs = this.page.locator('input[type="text"]'); - - try { - await textInputs.first().waitFor({ state: 'visible', timeout: 15000 }); - const count = await textInputs.count(); - this.logger.info(`ServiceNow configuration form detected with ${count} input fields`); - } catch (error) { - this.logger.info('No ServiceNow configuration required - no input fields found'); - return; - } - - this.logger.info('ServiceNow configuration required, filling dummy values'); - - // Fill configuration fields using index-based selection - // Field 1: Name - const nameField = this.page.locator('input[type="text"]').first(); - await nameField.fill('ServiceNow Test Instance'); - this.logger.debug('Filled Name field'); - - // Field 2: Instance (the {instance} part of {instance}.service-now.com) - const instanceField = this.page.locator('input[type="text"]').nth(1); - await instanceField.fill('dev12345'); - this.logger.debug('Filled Instance field'); - - // Field 3: Username - const usernameField = this.page.locator('input[type="text"]').nth(2); - await usernameField.fill('dummy_user'); - this.logger.debug('Filled Username field'); - - // Field 4: Password (must be >8 characters) - const passwordField = this.page.locator('input[type="password"]').first(); - await passwordField.fill('DummyPassword123'); - this.logger.debug('Filled Password field'); - - // Wait for network to settle after filling form - await this.page.waitForLoadState('networkidle'); - - this.logger.success('ServiceNow API configuration completed'); - } - - /** - * 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: 'Save and 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, 'Save and 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`); - } - - /** - * Navigate to app via Custom Apps menu - */ - async navigateToAppViaCustomApps(appName: string): Promise { - this.logger.step(`Navigate to app '${appName}' via Custom Apps`); - - return RetryHandler.withPlaywrightRetry( - async () => { - // Navigate to Foundry home - await this.navigateToPath('/foundry/home', 'Foundry home page'); - - // Open hamburger menu - const menuButton = this.page.getByTestId('nav-trigger'); - await this.smartClick(menuButton, 'Menu button'); - - // Click Custom apps - const customAppsButton = this.page.getByRole('button', { name: 'Custom apps' }); - await this.smartClick(customAppsButton, 'Custom apps button'); - - // Find and click the app - const appButton = this.page.getByRole('button', { name: appName, exact: false }).first(); - if (await this.elementExists(appButton, 3000)) { - await this.smartClick(appButton, `App '${appName}' button`); - await this.waiter.delay(1000); - - this.logger.success(`Navigated to app '${appName}' via Custom Apps`); - return; - } - - throw new Error(`App '${appName}' not found in Custom Apps menu`); - }, - `Navigate to app via Custom Apps` - ); - } - - /** - * 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 success message - const successMessage = this.page.getByText(/has been uninstalled/i); - await this.waiter.waitForVisible(successMessage, { - description: 'Uninstall success message', - timeout: 30000 - }); - - 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/HelloExtensionPage.ts b/e2e/src/pages/HelloExtensionPage.ts deleted file mode 100644 index cc4b831..0000000 --- a/e2e/src/pages/HelloExtensionPage.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Page, expect, FrameLocator } from '@playwright/test'; -import { SocketNavigationPage } from './SocketNavigationPage'; - -/** - * Page object for testing the "hello" UI extension - * Extension appears in activity.detections.details socket - */ -export class HelloExtensionPage extends SocketNavigationPage { - constructor(page: Page) { - super(page); - } - - async navigateToExtension(): Promise { - return this.withTiming( - async () => { - // Navigate to endpoint detections (activity.detections.details socket) - await this.navigateToEndpointDetections(); - - // Open first detection to show details panel with extensions - await this.openFirstDetection(); - - // Wait for detection details panel - await this.page.waitForLoadState('networkidle'); - - this.logger.success('Navigated to detection with hello extension'); - }, - 'Navigate to Hello Extension' - ); - } - - async verifyExtensionRenders(): Promise { - return this.withTiming( - async () => { - this.logger.info('Verifying hello extension renders'); - - // Wait for detection details panel to load - await this.page.waitForLoadState('networkidle'); - - // Extensions in detection details are expandable buttons at the bottom - // Just look for a button named "hello" (it may or may not have aria-expanded) - const extensionButton = this.page.getByRole('button', { name: 'hello', exact: true }); - - // Scroll the button into view if needed - await extensionButton.scrollIntoViewIfNeeded({ timeout: 10000 }); - this.logger.info('Scrolled to hello extension button'); - - // Wait for button to be visible - await expect(extensionButton).toBeVisible({ timeout: 10000 }); - this.logger.info('Found hello extension button'); - - // Check if already expanded, if not click to expand - const isExpanded = await extensionButton.getAttribute('aria-expanded'); - if (isExpanded === 'false') { - await extensionButton.click(); - this.logger.info('Clicked to expand hello extension'); - } else { - this.logger.info('hello extension already expanded'); - } - - // Verify iframe loads - await expect(this.page.locator('iframe[name="portal"]')).toBeVisible({ timeout: 15000 }); - this.logger.info('Extension iframe loaded'); - - // Verify iframe content - const iframe: FrameLocator = this.page.frameLocator('iframe[name="portal"]'); - - // Check for "Foundry Functions Demo" text - await expect(iframe.getByText(/Foundry Functions Demo/i)).toBeVisible({ timeout: 10000 }); - - // Check for Hello greeting - use .first() to handle multiple matches - await expect(iframe.getByText(/Hello.*@/i).first()).toBeVisible(); - - this.logger.success('hello extension renders correctly with expected content'); - }, - 'Verify hello extension renders' - ); - } -} diff --git a/e2e/src/pages/HostManagementPage.ts b/e2e/src/pages/HostManagementPage.ts deleted file mode 100644 index bc86d44..0000000 --- a/e2e/src/pages/HostManagementPage.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Page, expect } from '@playwright/test'; -import { BasePage } from './BasePage'; - -/** - * Page object for Host Management - * - * Used to retrieve host IDs for workflow testing - */ -export class HostManagementPage extends BasePage { - constructor(page: Page) { - super(page, 'Host Management'); - } - - protected getPagePath(): string { - return '/hosts/hosts'; - } - - protected async verifyPageLoaded(): Promise { - // Check for either "Host management" heading or "Host setup and management" - const heading = this.page.getByRole('heading', { name: /host.*management/i }).first(); - await expect(heading).toBeVisible({ timeout: 10000 }); - this.logger.success('Host management page loaded'); - } - - /** - * Navigate to host management page - */ - async navigateToHostManagement(): Promise { - return this.withTiming( - async () => { - await this.navigateToPath(this.getPagePath(), 'Host management page'); - await this.verifyPageLoaded(); - }, - 'Navigate to Host Management' - ); - } - - /** - * Get the first available host ID from the host list - * Returns null if no hosts are found - */ - async getFirstHostId(): Promise { - return this.withTiming( - async () => { - this.logger.info('Retrieving first host ID from host management'); - - await this.navigateToHostManagement(); - - // Wait for host table to load - await this.page.waitForLoadState('networkidle'); - - // Wait for the hostname column to appear - await this.page.getByText('Hostname').first().waitFor({ state: 'visible', timeout: 10000 }); - - // Look for any text content matching the 32-character hex ID pattern - try { - // Use evaluate to search the DOM for text matching host ID pattern - const hostId = await this.page.evaluate(() => { - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); - const pattern = /^[a-f0-9]{32}$/i; - - let node; - while (node = walker.nextNode()) { - const text = node.textContent?.trim() || ''; - if (pattern.test(text)) { - return text; - } - } - return null; - }); - - if (hostId) { - this.logger.success(`Found host ID: ${hostId}`); - return hostId; - } - - this.logger.warn('No valid host ID found on page'); - return null; - } catch (error) { - this.logger.warn(`Failed to find host ID: ${error.message}`); - this.logger.info('This may indicate no hosts are available in the CID'); - return null; - } - }, - 'Get first host ID' - ); - } - - /** - * Check if any hosts exist in the CID - */ - async hasHosts(): Promise { - return this.withTiming( - async () => { - await this.navigateToHostManagement(); - - // Check for "no hosts" message or empty table - const noHostsMessage = this.page.getByText(/no hosts found|no data/i); - const hasNoHostsMessage = await noHostsMessage.isVisible({ timeout: 3000 }).catch(() => false); - - if (hasNoHostsMessage) { - this.logger.info('No hosts found in CID'); - return false; - } - - // Check if table has rows - const hostRows = this.page.locator('tbody tr'); - const rowCount = await hostRows.count(); - - if (rowCount > 0) { - this.logger.success(`Found ${rowCount} host(s) in CID`); - return true; - } else { - this.logger.info('No hosts found in CID'); - return false; - } - }, - 'Check if hosts exist' - ); - } -} diff --git a/e2e/src/pages/SocketNavigationPage.ts b/e2e/src/pages/SocketNavigationPage.ts deleted file mode 100644 index 2bd2bc3..0000000 --- a/e2e/src/pages/SocketNavigationPage.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Page, expect } from '@playwright/test'; -import { BasePage } from './BasePage'; - -/** - * Utility page object for navigating to detection pages with socket extensions - * - * Uses menu-based navigation to ensure reliability when URLs change. - * - * Supports testing Foundry extensions that appear in detection sockets: - * - activity.detections.details (Endpoint Detections) - * - xdr.detections.panel (XDR Detections) - * - ngsiem.workbench.details (NGSIEM Incidents) - */ -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'); - - // 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 - const menuButton = this.page.getByTestId('nav-trigger'); - 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 = navigation.getByRole('button', { name: 'Monitor', exact: true }); - const isExpanded = await monitorButton.getAttribute('aria-expanded'); - if (isExpanded !== 'true') { - await monitorButton.click(); - await this.waiter.delay(500); - } - - // Click "Endpoint detections" link - const endpointDetectionsLink = navigation.getByRole('link', { name: /Endpoint detections/ }); - 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 - const menuButton = this.page.getByTestId('nav-trigger'); - 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 - const menuButton = this.page.getByTestId('nav-trigger'); - 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(); - - // Wait for detection details to load - 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}"` - ); - } -} diff --git a/e2e/src/pages/WorkflowsPage.ts b/e2e/src/pages/WorkflowsPage.ts deleted file mode 100644 index 97048bd..0000000 --- a/e2e/src/pages/WorkflowsPage.ts +++ /dev/null @@ -1,326 +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 button in the navigation menu (not the content buttons) - // Look for the navigation and find the exact "Fusion SOAR" button (not content that mentions Fusion SOAR) - const navigation = this.page.getByRole('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}` - ); - } - - /** - * Check the actual execution status by viewing the execution details. - * Navigates to the execution log, waits for the execution to complete, - * expands the execution row, and checks the status. - */ - async verifyWorkflowExecutionCompleted(timeoutMs = 120000): Promise { - return this.withTiming( - async () => { - this.logger.info('Checking workflow execution status in detail view'); - - // The "View" link opens in a new tab - capture it - const viewLink = this.page.getByRole('link', { name: /^view$/i }); - await viewLink.waitFor({ state: 'visible', timeout: 10000 }); - - const [executionPage] = await Promise.all([ - this.page.context().waitForEvent('page'), - viewLink.click(), - ]); - - // Wait for the new tab to load (execution pages can be slow to render) - await executionPage.waitForLoadState('networkidle'); - await executionPage.waitForLoadState('domcontentloaded'); - this.logger.info('Execution page opened in new tab'); - - // Wait for "Execution status" to appear (proves execution details loaded) - const statusLabel = executionPage.getByText('Execution status'); - await statusLabel.waitFor({ state: 'visible', timeout: 60000 }); - this.logger.info('Execution details visible'); - - // Poll until execution reaches a terminal state - this.logger.info(`Waiting up to ${timeoutMs / 1000}s for execution to complete...`); - - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - // Re-find status label each iteration (DOM recreated on reload) - const currentStatusLabel = executionPage.getByText('Execution status'); - await currentStatusLabel.waitFor({ state: 'visible', timeout: 15000 }); - const statusContainer = currentStatusLabel.locator('..'); - const statusText = await statusContainer.textContent() || ''; - const currentStatus = statusText.replace('Execution status', '').trim(); - this.logger.info(`Current status: ${currentStatus}`); - - if (currentStatus.toLowerCase().includes('failed')) { - // Capture error details - const pageContent = await executionPage.textContent('body') || ''; - const messageMatch = pageContent.match(/"message":\s*"([^"]+)"/); - - let errorMessage = 'Workflow action failed'; - if (messageMatch) { - errorMessage = messageMatch[1]; - } - - await executionPage.close(); - this.logger.error(`Workflow execution failed: ${errorMessage}`); - throw new Error(`Workflow execution failed: ${errorMessage}`); - } - - if (!currentStatus.toLowerCase().includes('in progress')) { - // Terminal state that isn't "Failed" - await executionPage.close(); - this.logger.success(`Workflow execution completed with status: ${currentStatus}`); - return; - } - - await executionPage.waitForTimeout(5000); - - // Reload to get updated status - the page doesn't auto-refresh - await executionPage.reload({ waitUntil: 'networkidle' }); - } - - await executionPage.close(); - throw new Error('Workflow execution timed out - still in progress'); - }, - 'Verify workflow execution completed' - ); - } -} 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 index 1aeafab..95268f7 100644 --- a/e2e/tests/app-install.setup.ts +++ b/e2e/tests/app-install.setup.ts @@ -1,17 +1,14 @@ -import { test as setup } from '../src/fixtures'; +import { test as setup } from '@playwright/test'; +import { AppCatalogPage, config } from '@crowdstrike/foundry-playwright'; -setup('install Functions with Python 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`); - } +setup('install app', async ({ page }) => { + const catalog = new AppCatalogPage(page); + await catalog.installApp(config.appName, { + configureSettings: async (page) => { + await page.getByLabel('Name').first().fill('ServiceNow Integration'); + await page.getByLabel('Instance').fill('dev12345'); + await page.getByLabel('Username').fill('test_user'); + await page.getByLabel('Password').fill('test_password'); + }, + }); }); diff --git a/e2e/tests/app-uninstall.teardown.ts b/e2e/tests/app-uninstall.teardown.ts deleted file mode 100644 index bba888c..0000000 --- a/e2e/tests/app-uninstall.teardown.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { test as teardown } from '../src/fixtures'; - -teardown('uninstall Functions with Python 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 }); -}); diff --git a/e2e/tests/foundry.spec.ts b/e2e/tests/foundry.spec.ts index 7a154f0..0d08787 100644 --- a/e2e/tests/foundry.spec.ts +++ b/e2e/tests/foundry.spec.ts @@ -1,30 +1,26 @@ -import { test } from '../src/fixtures'; +import { test, expect } from '../src/fixtures'; test.describe.configure({ mode: 'serial' }); test.describe('Functions with Python - E2E Tests', () => { - test('should render Hello UI extension', async ({ helloExtensionPage }) => { - await helloExtensionPage.navigateToExtension(); - await helloExtensionPage.verifyExtensionRenders(); + test('should render Hello UI extension', async ({ detectionExtensionPage }) => { + const frame = await detectionExtensionPage.openExtension('hello'); + await expect(frame.getByText(/Foundry Functions Demo/i)).toBeVisible({ timeout: 10000 }); + await expect(frame.getByText(/Hello.*@/i).first()).toBeVisible(); }); test('should execute Test hello function workflow', async ({ workflowsPage }) => { test.setTimeout(180000); - await workflowsPage.navigateToWorkflows(); await workflowsPage.executeAndVerifyWorkflow('Test hello function'); - await workflowsPage.verifyWorkflowExecutionCompleted(); }); test('should execute Test log-event function workflow', async ({ workflowsPage }) => { test.setTimeout(180000); - await workflowsPage.navigateToWorkflows(); await workflowsPage.executeAndVerifyWorkflow('Test log-event function'); - await workflowsPage.verifyWorkflowExecutionCompleted(); }); test('should execute Test host-details function workflow', async ({ workflowsPage, hostManagementPage }) => { test.setTimeout(180000); - // Get first available host ID const hostId = await hostManagementPage.getFirstHostId(); if (!hostId) { @@ -32,12 +28,9 @@ test.describe('Functions with Python - E2E Tests', () => { return; } - // Execute workflow with host ID parameter - await workflowsPage.navigateToWorkflows(); await workflowsPage.executeAndVerifyWorkflow('Test host-details function', { - 'Host ID': hostId + inputs: { 'Host ID': hostId }, }); - await workflowsPage.verifyWorkflowExecutionCompleted(); }); test('should render Test servicenow function workflow (without execution)', async ({ workflowsPage }) => { diff --git a/functions/log-event/main.py b/functions/log-event/main.py index 6e5de69..f73924b 100644 --- a/functions/log-event/main.py +++ b/functions/log-event/main.py @@ -5,19 +5,11 @@ import uuid from crowdstrike.foundry.function import Function, Request, Response, APIError -from falconpy import CustomStorage +from falconpy import APIHarnessV2 FUNC = Function.instance() -def _app_headers() -> dict: - """Build app headers for CustomStorage construction.""" - app_id = os.environ.get("APP_ID") - if app_id: - return {"X-CS-APP-ID": app_id} - return {} - - @FUNC.handler(method="POST", path="/log-event") def on_post(request: Request) -> Response: """ @@ -48,12 +40,22 @@ def on_post(request: Request) -> Response: "timestamp": int(time.time()) } - custom_storage = CustomStorage(ext_headers=_app_headers()) + # Allow setting APP_ID as an env variable for local testing + headers = {} + if os.environ.get("APP_ID"): + headers = { + "X-CS-APP-ID": os.environ.get("APP_ID") + } + + api_client = APIHarnessV2() collection_name = "event_logs" - response = custom_storage.PutObject(body=json_data, - collection_name=collection_name, - object_key=event_id) + response = api_client.command("PutObject", + body=json_data, + collection_name=collection_name, + object_key=event_id, + headers=headers + ) if response["status_code"] != 200: error_message = response.get("error", {}).get("message", "Unknown error") @@ -66,14 +68,17 @@ def on_post(request: Request) -> Response: ) # Query the collection to retrieve the event by id - query_response = custom_storage.SearchObjects(filter=f"event_id:'{event_id}'", - collection_name=collection_name, - limit=5) + query_response = api_client.command("SearchObjects", + filter=f"event_id:'{event_id}'", + collection_name=collection_name, + limit=5, + headers=headers + ) return Response( body={ "stored": True, - "metadata": query_response.get("body", {}).get("resources", []) + "metadata": query_response.get("body").get("resources", []) }, code=200 ) diff --git a/functions/log-event/test_main.py b/functions/log-event/test_main.py index 14ca111..967ad86 100644 --- a/functions/log-event/test_main.py +++ b/functions/log-event/test_main.py @@ -29,37 +29,37 @@ def setUp(self): importlib.reload(main) - @patch('main.CustomStorage') + @patch('main.APIHarnessV2') @patch('main.uuid.uuid4') @patch('main.time.time') - def test_on_post_success(self, mock_time, mock_uuid, mock_custom_storage_class): + def test_on_post_success(self, mock_time, mock_uuid, mock_api_harness_class): """Test successful POST request with valid event_data in body.""" # Mock dependencies mock_uuid.return_value = MagicMock() mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123") mock_time.return_value = 1690123456 - # Mock CustomStorage instance + # Mock APIHarnessV2 instance mock_api_instance = MagicMock() - mock_custom_storage_class.return_value = mock_api_instance + mock_api_harness_class.return_value = mock_api_instance # Mock successful PutObject response - mock_api_instance.PutObject.return_value = { - "status_code": 200, - "body": {"success": True} - } - - # Mock successful SearchObjects response - mock_api_instance.SearchObjects.return_value = { - "status_code": 200, - "body": { - "resources": [{ - "event_id": "test-event-id-123", - "data": {"test": "data"}, - "timestamp": 1690123456 - }] + mock_api_instance.command.side_effect = [ + { # PutObject response + "status_code": 200, + "body": {"success": True} + }, + { # SearchObjects response + "status_code": 200, + "body": { + "resources": [{ + "event_id": "test-event-id-123", + "data": {"test": "data"}, + "timestamp": 1690123456 + }] + } } - } + ] request = Request() request.body = { @@ -73,9 +73,12 @@ def test_on_post_success(self, mock_time, mock_uuid, mock_custom_storage_class): self.assertIn("metadata", response.body) self.assertEqual(len(response.body["metadata"]), 1) + # Verify API calls + self.assertEqual(mock_api_instance.command.call_count, 2) + # Verify PutObject call - mock_api_instance.PutObject.assert_called_once() - put_call = mock_api_instance.PutObject.call_args + put_call = mock_api_instance.command.call_args_list[0] + self.assertEqual(put_call[0][0], "PutObject") self.assertEqual(put_call[1]["collection_name"], "event_logs") self.assertEqual(put_call[1]["object_key"], "test-event-id-123") self.assertEqual(put_call[1]["body"]["event_id"], "test-event-id-123") @@ -83,8 +86,8 @@ def test_on_post_success(self, mock_time, mock_uuid, mock_custom_storage_class): self.assertEqual(put_call[1]["body"]["timestamp"], 1690123456) # Verify SearchObjects call - mock_api_instance.SearchObjects.assert_called_once() - search_call = mock_api_instance.SearchObjects.call_args + search_call = mock_api_instance.command.call_args_list[1] + self.assertEqual(search_call[0][0], "SearchObjects") self.assertEqual(search_call[1]["filter"], "event_id:'test-event-id-123'") self.assertEqual(search_call[1]["collection_name"], "event_logs") @@ -98,20 +101,20 @@ def test_on_post_missing_event_data(self): self.assertEqual(len(response.errors), 1) self.assertEqual(response.errors[0].message, "missing event_data") - @patch('main.CustomStorage') + @patch('main.APIHarnessV2') @patch('main.uuid.uuid4') @patch('main.time.time') - def test_on_post_put_object_error(self, mock_time, mock_uuid, mock_custom_storage_class): + def test_on_post_put_object_error(self, mock_time, mock_uuid, mock_api_harness_class): """Test POST request when PutObject API returns an error.""" # Mock dependencies mock_uuid.return_value = MagicMock() mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123") mock_time.return_value = 1690123456 - # Mock CustomStorage instance with error response + # Mock APIHarnessV2 instance with error response mock_api_instance = MagicMock() - mock_custom_storage_class.return_value = mock_api_instance - mock_api_instance.PutObject.return_value = { + mock_api_harness_class.return_value = mock_api_instance + mock_api_instance.command.return_value = { "status_code": 500, "error": {"message": "Internal server error"} } @@ -127,18 +130,18 @@ def test_on_post_put_object_error(self, mock_time, mock_uuid, mock_custom_storag self.assertEqual(len(response.errors), 1) self.assertIn("Failed to store event: Internal server error", response.errors[0].message) - @patch('main.CustomStorage') + @patch('main.APIHarnessV2') @patch('main.uuid.uuid4') @patch('main.time.time') - def test_on_post_exception_handling(self, mock_time, mock_uuid, mock_custom_storage_class): + def test_on_post_exception_handling(self, mock_time, mock_uuid, mock_api_harness_class): """Test POST request when an exception is raised.""" # Mock dependencies mock_uuid.return_value = MagicMock() mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123") mock_time.return_value = 1690123456 - # Mock CustomStorage to raise an exception - mock_custom_storage_class.side_effect = ConnectionError("Connection failed") + # Mock APIHarnessV2 to raise an exception + mock_api_harness_class.side_effect = ConnectionError("Connection failed") request = Request() request.body = { @@ -152,27 +155,23 @@ def test_on_post_exception_handling(self, mock_time, mock_uuid, mock_custom_stor self.assertIn("Error saving collection: Connection failed", response.errors[0].message) @patch.dict('main.os.environ', {'APP_ID': 'test-app-123'}) - @patch('main.CustomStorage') + @patch('main.APIHarnessV2') @patch('main.uuid.uuid4') @patch('main.time.time') - def test_on_post_with_app_id_header(self, mock_time, mock_uuid, mock_custom_storage_class): + def test_on_post_with_app_id_header(self, mock_time, mock_uuid, mock_api_harness_class): """Test POST request with APP_ID environment variable set.""" # Mock dependencies mock_uuid.return_value = MagicMock() mock_uuid.return_value.__str__ = MagicMock(return_value="test-event-id-123") mock_time.return_value = 1690123456 - # Mock CustomStorage instance + # Mock APIHarnessV2 instance mock_api_instance = MagicMock() - mock_custom_storage_class.return_value = mock_api_instance - mock_api_instance.PutObject.return_value = { - "status_code": 200, - "body": {"success": True} - } - mock_api_instance.SearchObjects.return_value = { - "status_code": 200, - "body": {"resources": []} - } + mock_api_harness_class.return_value = mock_api_instance + mock_api_instance.command.side_effect = [ + {"status_code": 200, "body": {"success": True}}, + {"status_code": 200, "body": {"resources": []}} + ] request = Request() request.body = { @@ -183,10 +182,9 @@ def test_on_post_with_app_id_header(self, mock_time, mock_uuid, mock_custom_stor self.assertEqual(response.code, 200) - # Verify that CustomStorage was constructed with ext_headers containing APP_ID - mock_custom_storage_class.assert_called_once_with( - ext_headers={"X-CS-APP-ID": "test-app-123"} - ) + # Verify that headers with APP_ID were passed to both API calls + for call in mock_api_instance.command.call_args_list: + self.assertEqual(call[1]["headers"], {"X-CS-APP-ID": "test-app-123"}) if __name__ == "__main__":