From 692f1c8606361164ef5027b4eff9765f66bed6e7 Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 23 Apr 2026 08:48:05 -0600 Subject: [PATCH 1/5] Migrate e2e tests to @crowdstrike/foundry-playwright --- e2e/package-lock.json | 133 +++++++---- e2e/package.json | 11 +- e2e/playwright.config.ts | 61 +---- e2e/src/authenticate.cjs | 106 --------- e2e/src/config/TestConfig.ts | 146 ------------ e2e/src/constants/AuthFile.ts | 1 - e2e/src/fixtures.ts | 26 +- e2e/src/pages/AppCatalogPage.ts | 345 --------------------------- e2e/src/pages/BasePage.ts | 252 -------------------- e2e/src/pages/FoundryHomePage.ts | 33 --- e2e/src/pages/LogScalePage.ts | 357 ++++++---------------------- e2e/src/pages/WorkflowsPage.ts | 305 ------------------------ e2e/src/utils.cjs | 43 ---- e2e/src/utils/Logger.ts | 190 --------------- e2e/src/utils/SmartWaiter.ts | 212 ----------------- e2e/tests/app-install.setup.ts | 17 -- e2e/tests/app-uninstall.teardown.ts | 17 -- e2e/tests/authenticate.setup.ts | 22 -- e2e/tests/foundry.spec.ts | 2 +- 19 files changed, 168 insertions(+), 2111 deletions(-) delete mode 100644 e2e/src/authenticate.cjs delete mode 100644 e2e/src/config/TestConfig.ts delete mode 100644 e2e/src/constants/AuthFile.ts delete mode 100644 e2e/src/pages/AppCatalogPage.ts delete mode 100644 e2e/src/pages/BasePage.ts delete mode 100644 e2e/src/pages/FoundryHomePage.ts delete mode 100644 e2e/src/pages/WorkflowsPage.ts delete mode 100644 e2e/src/utils.cjs delete mode 100644 e2e/src/utils/Logger.ts delete mode 100644 e2e/src/utils/SmartWaiter.ts delete mode 100644 e2e/tests/app-install.setup.ts delete mode 100644 e2e/tests/app-uninstall.teardown.ts delete mode 100644 e2e/tests/authenticate.setup.ts diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6df7958..7e80efd 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -9,21 +9,30 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@dotenvx/dotenvx": "1.52.0", - "otpauth": "9.5.0" + "@crowdstrike/foundry-playwright": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz" }, "devDependencies": { - "@playwright/test": "1.57.0", - "@types/node": "25.1.0" + "@types/node": "latest" + } + }, + "node_modules/@crowdstrike/foundry-playwright": { + "version": "0.5.0", + "resolved": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz", + "integrity": "sha512-bkdlCSBEHasOaSWA1fFr3VkAzyCoqzTiPzpIyWlj4NA6rhOCnelA2k+X3tYKtY+tLb54NkvnVzrDsUwIsn24og==", + "license": "MIT", + "dependencies": { + "@dotenvx/dotenvx": "^1.61.0", + "@playwright/test": "^1.59.1", + "otpauth": "^9.5.0" }, "engines": { - "node": ">=22.0.0" + "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 +42,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 +54,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 +107,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 +122,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 +176,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 +188,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 +248,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 +301,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 +394,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 +406,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 +424,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 +472,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 +492,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..04fbf0b 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-logscale", "scripts": { "test": "npx playwright test", "test:ui": "npx playwright test --ui", @@ -11,15 +11,10 @@ "keywords": [], "license": "MIT", "type": "commonjs", - "engines": { - "node": ">=22.0.0" - }, "dependencies": { - "@dotenvx/dotenvx": "1.52.0", - "otpauth": "9.5.0" + "@crowdstrike/foundry-playwright": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz" }, "devDependencies": { - "@playwright/test": "1.57.0", - "@types/node": "25.1.0" + "@types/node": "latest" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index f8671bd..ef56be3 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,60 +1,3 @@ -import { defineConfig, devices } from '@playwright/test'; -import { AuthFile } from './src/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 ? 240 * 1000 : 150 * 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(); 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 d956822..0000000 --- a/e2e/src/config/TestConfig.ts +++ /dev/null @@ -1,146 +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.isCI = !!process.env.CI; - 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.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 - }; - } - - /** - * 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/constants/AuthFile.ts b/e2e/src/constants/AuthFile.ts deleted file mode 100644 index 392248e..0000000 --- a/e2e/src/constants/AuthFile.ts +++ /dev/null @@ -1 +0,0 @@ -export const AuthFile = "playwright/.auth/user.json"; diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 9fcabe9..640b31c 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,9 +1,11 @@ import { test as baseTest } from '@playwright/test'; -import { FoundryHomePage } from './pages/FoundryHomePage'; -import { AppCatalogPage } from './pages/AppCatalogPage'; +import { + FoundryHomePage, + AppCatalogPage, + WorkflowsPage, + config, +} from '@crowdstrike/foundry-playwright'; import { LogScalePage } from './pages/LogScalePage'; -import { WorkflowsPage } from './pages/WorkflowsPage'; -import { config } from './config/TestConfig'; type FoundryFixtures = { foundryHomePage: FoundryHomePage; @@ -14,21 +16,6 @@ type FoundryFixtures = { }; export const test = baseTest.extend({ - // Configure page with centralized settings - page: async ({ page }, use) => { - const timeouts = config.getPlaywrightTimeouts(); - page.setDefaultTimeout(timeouts.timeout); - - // Log configuration on first use - if (!process.env.CONFIG_LOGGED) { - config.logSummary(); - process.env.CONFIG_LOGGED = 'true'; - } - - await use(page); - }, - - // Page object fixtures with dependency injection foundryHomePage: async ({ page }, use) => { await use(new FoundryHomePage(page)); }, @@ -45,7 +32,6 @@ export const test = baseTest.extend({ await use(new WorkflowsPage(page)); }, - // App name from centralized config appName: async ({}, use) => { await use(config.appName); }, diff --git a/e2e/src/pages/AppCatalogPage.ts b/e2e/src/pages/AppCatalogPage.ts deleted file mode 100644 index 16ba607..0000000 --- a/e2e/src/pages/AppCatalogPage.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * AppCatalogPage - App installation and management - */ - -import { Page, expect } 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('domcontentloaded'); - - const appLink = this.page.getByRole('link', { name: appName, exact: true }); - - try { - await this.waiter.waitForVisible(appLink, { - description: `App '${appName}' link in catalog`, - timeout: 30000 - }); - this.logger.success(`Found app '${appName}' in catalog`); - await this.smartClick(appLink, `App '${appName}' link`); - await this.page.waitForLoadState('domcontentloaded'); - } 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(); - - // Click final Install app button - await this.clickInstallAppButton(); - - // Wait for installation to complete - await this.waitForInstallation(appName); - - // Dismiss any toast notifications left from install to prevent UI overlay issues - await this.dismissToasts(); - - 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); - } - } - - /** - * Click the final "Save and install" button - */ - private async clickInstallAppButton(): Promise { - const installButton = this.page.getByRole('button', { name: 'Save and install' }); - - 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) - 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') { - 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 as 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`); - } - - 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; - - 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' }); - } - } - - this.logger.info(`Catalog status not updated yet after ${maxAttempts * 5}s, but toast confirmed installation - continuing`); - } - - /** - * Dismiss any visible toast notifications by clicking their close buttons - */ - private async dismissToasts(): Promise { - const closeButtons = this.page.locator('[role="alertdialog"] button[aria-label="Close"], [role="alert"] button[aria-label="Close"]'); - const count = await closeButtons.count(); - for (let i = 0; i < count; i++) { - await closeButtons.nth(i).click().catch(() => {}); - } - if (count > 0) { - this.logger.info(`Dismissed ${count} toast notification(s)`); - } - } - - /** - * Navigate to app via Custom Apps menu. - * Uses 5-attempt retry with page refresh to handle platform flakiness - * where Custom Apps button doesn't appear on first load. - */ - async navigateToAppViaCustomApps(appName: string): Promise { - this.logger.step(`Navigate to app '${appName}' via Custom Apps`); - - await this.navigateToPath('/foundry/home', 'Foundry home page'); - await this.page.waitForLoadState('networkidle'); - - // Retry with page refresh if Custom apps menu or app button doesn't appear - let appFound = false; - for (let attempt = 1; attempt <= 5; attempt++) { - const menuButton = this.page.getByTestId('nav-trigger'); - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - const customAppsButton = this.page.getByRole('button', { name: 'Custom apps' }); - try { - await customAppsButton.waitFor({ state: 'visible', timeout: 20000 }); - await customAppsButton.click(); - await this.waiter.delay(1500); - this.logger.info(`Custom apps button found on attempt ${attempt}`); - } catch (e) { - this.logger.warn(`Custom apps not visible on attempt ${attempt}, refreshing page...`); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); - await this.waiter.delay(3000); - continue; - } - - // Check if the app button appears in the submenu - const appButtonCheck = this.page.getByRole('button', { name: appName, exact: false }).first(); - try { - await appButtonCheck.waitFor({ state: 'visible', timeout: 10000 }); - appFound = true; - this.logger.info(`App '${appName}' found in Custom apps menu on attempt ${attempt}`); - break; - } catch (e) { - this.logger.warn(`App '${appName}' not in Custom apps on attempt ${attempt}, refreshing page...`); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); - await this.waiter.delay(3000); - continue; - } - } - if (!appFound) { - throw new Error(`App '${appName}' not found in Custom apps menu after 5 attempts with page refresh`); - } - - // Find and click the app - const appButton = this.page.getByRole('button', { name: appName, exact: false }).first(); - await expect(appButton).toBeVisible({ timeout: 10000 }); - await appButton.click(); - await this.waiter.delay(1000); - - this.logger.success(`Navigated to app '${appName}' 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 - 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 as Error).message}`); - throw error; - } - } -} diff --git a/e2e/src/pages/BasePage.ts b/e2e/src/pages/BasePage.ts deleted file mode 100644 index 79ba758..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}`); - } - } - - /** - * 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'}`); - 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; -} diff --git a/e2e/src/pages/FoundryHomePage.ts b/e2e/src/pages/FoundryHomePage.ts deleted file mode 100644 index 6a81ec1..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'); - } -} diff --git a/e2e/src/pages/LogScalePage.ts b/e2e/src/pages/LogScalePage.ts index bb93f71..0df5eeb 100644 --- a/e2e/src/pages/LogScalePage.ts +++ b/e2e/src/pages/LogScalePage.ts @@ -1,5 +1,5 @@ import { Page, expect, FrameLocator } from '@playwright/test'; -import { BasePage } from './BasePage'; +import { BasePage, AppCatalogPage, config } from '@crowdstrike/foundry-playwright'; export interface FormData { event_type: string; @@ -8,349 +8,138 @@ export interface FormData { additional_fields?: string; } -/** - * Page object for LogScale Data Ingestion app - * The app runs inside an iframe within the Foundry platform - */ export class LogScalePage extends BasePage { constructor(page: Page) { super(page, 'LogScalePage'); } - /** - * Get the iframe containing the app content - */ private getAppFrame(): FrameLocator { return this.page.frameLocator('iframe[name="portal"]'); } protected getPagePath(): string { - return '/foundry/home'; + throw new Error('Direct path navigation not supported. Use navigateToApp() instead.'); } protected async verifyPageLoaded(): Promise { - // First wait for the iframe to be visible await expect(this.page.locator('iframe[name="portal"]')).toBeVisible({ timeout: 15000 }); this.logger.info('App iframe is visible'); - // Then check for content inside the iframe const iframe = this.getAppFrame(); const heading = iframe.locator('h1', { hasText: 'Ingest Custom Data into LogScale' }); await expect(heading).toBeVisible({ timeout: 15000 }); this.logger.success('LogScale app page loaded'); } - /** - * Navigate to LogScale app. - * Strategy: Try "Open app" button from App Catalog first (fastest, most reliable), - * then fall back to Custom Apps menu if the button isn't available. - */ async navigateToApp(): Promise { - return this.withTiming( - async () => { - const appName = process.env.APP_NAME || 'foundry-sample-logscale'; - - // Strategy 1: Try "Open app" from the App Catalog detail page - const openedViaCatalog = await this.tryOpenAppViaCatalog(appName); - if (openedViaCatalog) return; - - // Strategy 2: Fall back to Custom Apps menu with retry loop - this.logger.info('Falling back to Custom Apps menu navigation'); - await this.navigateViaCustomApps(appName); - }, - 'Navigate to LogScale app' - ); - } - - /** - * Try to open the app via the "Open app" button on its App Catalog detail page. - * Returns true if successful, false if the button wasn't available. - */ - private async tryOpenAppViaCatalog(appName: string): Promise { - try { - this.logger.info(`Trying to open app via App Catalog "Open app" button`); - - // Navigate to app catalog with filter to find the app - const baseUrl = this.getBaseURL(); - const filterParam = encodeURIComponent(`name:~'${appName}'`); - await this.page.goto(`${baseUrl}/foundry/app-catalog?filter=${filterParam}`); - await this.page.waitForLoadState('domcontentloaded'); - - // Click on the app link to go to its detail page - const appLink = this.page.getByRole('link', { name: appName, exact: true }); - await appLink.waitFor({ state: 'visible', timeout: 15000 }); - await appLink.click(); - - // Look for the "Open app" button - const openAppButton = this.page.getByRole('button', { name: 'Open app' }); - await openAppButton.waitFor({ state: 'visible', timeout: 10000 }); - await openAppButton.click(); - this.logger.success('Clicked "Open app" button from App Catalog'); - - // Wait for the iframe to become visible (it starts with class="hidden" and transitions) - const iframe = this.page.locator('iframe[name="portal"]'); - await iframe.waitFor({ state: 'visible', timeout: 30000 }); + return this.withTiming(async () => { + const catalog = new AppCatalogPage(this.page); + await catalog.navigateToInstalledApp(config.appName); await this.verifyPageLoaded(); - return true; - } catch (e) { - this.logger.warn(`"Open app" button not available: ${(e as Error).message}`); - return false; - } + }, 'Navigate to LogScale app'); } - /** - * Navigate to app via Custom Apps menu with retry loop. - * Handles platform flakiness where Custom Apps button doesn't appear on first load. - */ - private async navigateViaCustomApps(appName: string): Promise { - await this.navigateToPath('/foundry/home', 'Foundry Home'); - await this.page.waitForLoadState('networkidle'); - await this.waiter.delay(2000); - - // Close sidebar menu if already open from a previous navigation - const menuButton = this.page.getByTestId('nav-trigger'); - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - const menuIsOpen = await menuButton.getAttribute('aria-expanded'); - if (menuIsOpen === 'true') { - await menuButton.click(); - await this.waiter.delay(1000); - } - - let appFound = false; - for (let attempt = 1; attempt <= 8; attempt++) { - await menuButton.waitFor({ state: 'visible', timeout: 30000 }); - await menuButton.click(); - await this.page.waitForLoadState('networkidle'); - - const customAppsButton = this.page.getByRole('button', { name: 'Custom apps' }); - try { - await customAppsButton.waitFor({ state: 'visible', timeout: 20000 }); - await customAppsButton.click(); - await this.waiter.delay(1500); - this.logger.info(`Custom apps button found on attempt ${attempt}`); - } catch (e) { - this.logger.warn(`Custom apps not visible on attempt ${attempt}, refreshing page...`); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); - await this.waiter.delay(3000); - continue; - } + async fillForm(data: FormData): Promise { + return this.withTiming(async () => { + this.logger.info(`Filling form with event_type: ${data.event_type}, severity: ${data.severity}`); + + const iframe = this.getAppFrame(); + + const eventTypeInput = iframe.locator('#eventType'); + await eventTypeInput.fill(data.event_type); - // Check if the app button appears in the submenu - const appButton = this.page.getByRole('button', { name: appName, exact: false }).first(); - try { - await appButton.waitFor({ state: 'visible', timeout: 10000 }); - appFound = true; - this.logger.info(`App '${appName}' found in Custom apps menu on attempt ${attempt}`); - break; - } catch (e) { - this.logger.warn(`App '${appName}' not in Custom apps on attempt ${attempt}, refreshing page...`); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); - await this.waiter.delay(3000); - continue; + const severitySelect = iframe.locator('#severity'); + await severitySelect.selectOption(data.severity); + + const descriptionInput = iframe.locator('#description'); + await descriptionInput.fill(data.description); + + if (data.additional_fields) { + const additionalFieldsInput = iframe.locator('#additionalFields'); + await additionalFieldsInput.fill(data.additional_fields); } - } - if (!appFound) { - throw new Error(`App '${appName}' not found in Custom apps menu after 8 attempts with page refresh`); - } - - // Re-locate the app button and verify visibility before interacting - const appButton = this.page.getByRole('button', { name: appName, exact: false }).first(); - await expect(appButton).toBeVisible({ timeout: 10000 }); - - // Expand the app menu only if not already expanded - const isExpanded = await appButton.getAttribute('aria-expanded'); - if (isExpanded !== 'true') { - await appButton.click(); - } - - // Click the page link to navigate - const appLink = this.page.getByRole('link', { name: /data ingestion/i }).first(); - await expect(appLink).toBeVisible({ timeout: 20000 }); - await appLink.click(); - - // Wait for app page to load - await this.page.waitForLoadState('networkidle'); - await this.verifyPageLoaded(); - } - /** - * Fill the form with test data - */ - async fillForm(data: FormData): Promise { - return this.withTiming( - async () => { - this.logger.info(`Filling form with event_type: ${data.event_type}, severity: ${data.severity}`); - - const iframe = this.getAppFrame(); - - // Fill Event Type - const eventTypeInput = iframe.locator('#eventType'); - await eventTypeInput.fill(data.event_type); - - // Select Severity - const severitySelect = iframe.locator('#severity'); - await severitySelect.selectOption(data.severity); - - // Fill Description - const descriptionInput = iframe.locator('#description'); - await descriptionInput.fill(data.description); - - // Fill Additional Fields if provided - if (data.additional_fields) { - const additionalFieldsInput = iframe.locator('#additionalFields'); - await additionalFieldsInput.fill(data.additional_fields); - } - - this.logger.success('Form filled successfully'); - }, - 'Fill form' - ); + this.logger.success('Form filled successfully'); + }, 'Fill form'); } - /** - * Click the "Fill with Test Data" button - */ async clickFillWithTestData(): Promise { - return this.withTiming( - async () => { - this.logger.info('Clicking Fill with Test Data button'); + return this.withTiming(async () => { + this.logger.info('Clicking Fill with Test Data button'); - const iframe = this.getAppFrame(); - const fillTestDataButton = iframe.locator('[data-test-selector="fill-test-data-button"]'); - await fillTestDataButton.click(); + const iframe = this.getAppFrame(); + const fillTestDataButton = iframe.locator('[data-test-selector="fill-test-data-button"]'); + await fillTestDataButton.click(); - // Wait for form to be populated (success toast appears) - await this.waitForSuccessToast('Form populated'); + await this.waitForSuccessToast('Form populated'); - this.logger.success('Form populated with test data'); - }, - 'Click Fill with Test Data' - ); + this.logger.success('Form populated with test data'); + }, 'Click Fill with Test Data'); } - /** - * Submit the form - */ async submitForm(): Promise { - return this.withTiming( - async () => { - this.logger.info('Submitting form'); - - const iframe = this.getAppFrame(); - const submitButton = iframe.locator('[data-test-selector="submit-data-button"]'); - await submitButton.click(); - - this.logger.success('Form submitted'); - }, - 'Submit form' - ); + return this.withTiming(async () => { + this.logger.info('Submitting form'); + + const iframe = this.getAppFrame(); + const submitButton = iframe.locator('[data-test-selector="submit-data-button"]'); + await submitButton.click(); + + this.logger.success('Form submitted'); + }, 'Submit form'); } - /** - * Wait for success toast notification - */ async waitForSuccessToast(expectedText?: string): Promise { - return this.withTiming( - async () => { - this.logger.info('Waiting for success toast'); + return this.withTiming(async () => { + this.logger.info('Waiting for success toast'); - const iframe = this.getAppFrame(); - // Toast is inside #toastContainer and has role="alertdialog" with border-positive class - const toast = iframe.locator('#toastContainer [role="alertdialog"]').filter({ hasText: expectedText || '' }); + const iframe = this.getAppFrame(); + const toast = iframe.locator('#toastContainer [role="alertdialog"]').filter({ hasText: expectedText || '' }); - await expect(toast.first()).toBeVisible({ timeout: 30000 }); + await expect(toast.first()).toBeVisible({ timeout: 30000 }); - this.logger.success('Success toast appeared'); - }, - 'Wait for success toast' - ); + this.logger.success('Success toast appeared'); + }, 'Wait for success toast'); } - /** - * Wait for data ingestion success toast - */ async waitForIngestionSuccess(): Promise { await this.waitForSuccessToast('successfully ingested'); } - /** - * Click the refresh button to reload recent data - */ async refreshRecentData(): Promise { - return this.withTiming( - async () => { - this.logger.info('Refreshing recent data'); - - const iframe = this.getAppFrame(); - const refreshButton = iframe.locator('#refreshBtn'); - await refreshButton.click(); - - // Wait for loading to complete - const loadingState = iframe.locator('#loadingState'); - await loadingState.waitFor({ state: 'hidden', timeout: 15000 }); - - this.logger.success('Recent data refreshed'); - }, - 'Refresh recent data' - ); - } + return this.withTiming(async () => { + this.logger.info('Refreshing recent data'); - /** - * Get the count of recent data cards - */ - async getRecentDataCardCount(): Promise { - const iframe = this.getAppFrame(); - const cards = iframe.locator('#resultsContainer sl-card'); - return await cards.count(); - } + const iframe = this.getAppFrame(); + const refreshButton = iframe.locator('#refreshBtn'); + await refreshButton.click(); - /** - * Verify that submitted data appears in the recent data section - */ - async verifyDataInRecent(data: FormData): Promise { - return this.withTiming( - async () => { - this.logger.info(`Verifying data appears in recent section: ${data.event_type}`); + const loadingState = iframe.locator('#loadingState'); + await loadingState.waitFor({ state: 'hidden', timeout: 15000 }); - const iframe = this.getAppFrame(); + this.logger.success('Recent data refreshed'); + }, 'Refresh recent data'); + } - // Look for a card containing the event type - const cardWithEventType = iframe.locator('#resultsContainer sl-card').filter({ - has: iframe.locator(`strong:has-text("${data.event_type}")`) - }); + async verifyDataInRecent(data: FormData): Promise { + return this.withTiming(async () => { + this.logger.info(`Verifying data appears in recent section: ${data.event_type}`); - await expect(cardWithEventType.first()).toBeVisible({ timeout: 5000 }); + const iframe = this.getAppFrame(); - // Verify severity badge - const severityBadge = cardWithEventType.first().locator('sl-badge'); - await expect(severityBadge).toBeVisible(); + const cardWithEventType = iframe.locator('#resultsContainer sl-card').filter({ + has: iframe.locator(`strong:has-text("${data.event_type}")`) + }); - // Verify description text - const descriptionText = cardWithEventType.first().locator('p.text-body-and-labels').first(); - await expect(descriptionText).toContainText(data.description); + await expect(cardWithEventType.first()).toBeVisible({ timeout: 5000 }); - this.logger.success(`Data verified in recent section: ${data.event_type}`); - }, - 'Verify data in recent' - ); - } + const severityBadge = cardWithEventType.first().locator('sl-badge'); + await expect(severityBadge).toBeVisible(); - /** - * Wait for empty state (no recent data) - */ - async waitForEmptyState(): Promise { - const iframe = this.getAppFrame(); - const emptyState = iframe.locator('#emptyState'); - await expect(emptyState).toBeVisible({ timeout: 10000 }); - } + const descriptionText = cardWithEventType.first().locator('p.text-body-and-labels').first(); + await expect(descriptionText).toContainText(data.description); - /** - * Check if recent data section shows any cards - */ - async hasRecentData(): Promise { - const cardCount = await this.getRecentDataCardCount(); - return cardCount > 0; + this.logger.success(`Data verified in recent section: ${data.event_type}`); + }, 'Verify data in recent'); } } diff --git a/e2e/src/pages/WorkflowsPage.ts b/e2e/src/pages/WorkflowsPage.ts deleted file mode 100644 index 0798f1c..0000000 --- a/e2e/src/pages/WorkflowsPage.ts +++ /dev/null @@ -1,305 +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 - 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 using the filter dropdown - */ - async searchWorkflow(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info(`Searching for workflow: ${workflowName}`); - - // Click the "Search workflows" button to open filter dropdown - const searchButton = this.page.getByRole('button', { name: /search workflows/i }); - await searchButton.click(); - - // Wait for dropdown to open and find the filter input - const filterInput = this.page.locator('input[placeholder*="filter"]') - .or(this.page.getByPlaceholder(/type to filter/i)); - - await filterInput.fill(workflowName); - - // Click Apply button to apply the filter - const applyButton = this.page.getByRole('button', { name: 'Apply' }); - await applyButton.click(); - - 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}`); - - // Look for the workflow link directly in the table (it may already be visible) - const workflowLink = this.page.getByRole('link', { name: new RegExp(workflowName, 'i') }); - - try { - await expect(workflowLink).toBeVisible({ timeout: 10000 }); - this.logger.success(`Workflow found: ${workflowName}`); - } catch { - // If not immediately visible, try searching - this.logger.info('Workflow not immediately visible, trying search filter'); - await this.searchWorkflow(workflowName); - - await expect(workflowLink).toBeVisible({ timeout: 5000 }); - this.logger.success(`Workflow found after search: ${workflowName}`); - } - }, - `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 - 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 JSON data input - */ - async executeWorkflow(workflowName: string, jsonData?: object): Promise { - return this.withTiming( - async () => { - this.logger.info(`Executing workflow: ${workflowName}`); - - // Ensure we're on the workflows list page - await this.navigateToWorkflows(); - - // Find the workflow row and click its "Open menu" button - const workflowLink = this.page.getByRole('link', { name: new RegExp(workflowName, 'i') }); - await expect(workflowLink).toBeVisible({ timeout: 10000 }); - - const workflowRow = this.page.getByRole('row', { name: new RegExp(workflowName, 'i') }); - const openMenuButton = workflowRow.getByLabel('Open menu'); - await openMenuButton.click(); - - // Click "Execute workflow" from the menu - const executeMenuItem = this.page.getByRole('menuitem', { name: 'Execute workflow' }); - await executeMenuItem.click(); - - // Wait for execution modal to appear - await expect(this.page.getByRole('heading', { name: /execute on demand workflow/i })).toBeVisible({ timeout: 10000 }); - this.logger.info('Execution modal opened'); - - // Fill in JSON data if provided - if (jsonData) { - this.logger.info('Filling in JSON data for workflow'); - // The JSON editor is a CodeMirror instance - click on the visible code area - const codeEditor = this.page.locator('.CodeMirror-scroll'); - await codeEditor.click(); - // Select all and type new content - await this.page.keyboard.press('Meta+a'); - await this.page.keyboard.press('Control+a'); - await this.page.keyboard.type(JSON.stringify(jsonData, null, 2)); - } - - // Click "Execute now" button - const executeNowButton = this.page.getByRole('button', { name: 'Execute now' }); - await executeNowButton.click(); - - // Wait for execution confirmation toast - await expect(this.page.getByText(/workflow execution triggered/i)).toBeVisible({ timeout: 15000 }); - this.logger.success(`Workflow execution triggered: ${workflowName}`); - }, - `Execute workflow: ${workflowName}` - ); - } - - /** - * Verify workflow execution completed by checking Execution log table - */ - async verifyExecutionCompleted(workflowName: string): Promise { - return this.withTiming( - async () => { - this.logger.info('Verifying workflow execution completed via Execution log'); - - // Navigate directly to Execution log page - await this.page.goto(`${this.getBaseURL()}/workflow/fusion/executions`); - await this.page.waitForLoadState('networkidle'); - - // Poll the Execution log table for "Completed" status - const maxAttempts = 12; // 12 attempts * 5 seconds = 60 seconds max - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // Wait for table to load - await this.page.waitForTimeout(3000); - - // Find a row containing the workflow name (as plain text, not a link) - const row = this.page.locator('tr').filter({ hasText: workflowName }).first(); - const rowExists = await row.isVisible().catch(() => false); - - if (rowExists) { - // Check if this row has "Completed" status - const hasCompleted = await row.getByText('Completed').isVisible().catch(() => false); - - if (hasCompleted) { - this.logger.success('Workflow execution completed successfully'); - return; - } - - const hasInProgress = await row.getByText('In progress').isVisible().catch(() => false); - if (hasInProgress) { - this.logger.info(`Execution still in progress, refreshing (attempt ${attempt}/${maxAttempts})`); - } else { - this.logger.info(`Checking execution status (attempt ${attempt}/${maxAttempts})`); - } - } else { - this.logger.info(`Waiting for execution to appear (attempt ${attempt}/${maxAttempts})`); - } - - await this.page.waitForTimeout(5000); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); - } - - // Final verification - look for Completed text anywhere in a row with the workflow name - const row = this.page.locator('tr').filter({ hasText: workflowName }).first(); - await expect(row.getByText('Completed')).toBeVisible({ timeout: 5000 }); - this.logger.success('Workflow execution completed successfully'); - }, - 'Verify execution completed' - ); - } - - /** - * Verify workflow execution completed successfully - */ - 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 as Error).message}`); - throw error; - } - }, - `Verify workflow execution success: ${workflowName}` - ); - } - - /** - * Execute workflow and verify it completes successfully - */ - async executeAndVerifyWorkflow(workflowName: string, jsonData?: object): Promise { - return this.withTiming( - async () => { - await this.executeWorkflow(workflowName, jsonData); - await this.verifyExecutionCompleted(workflowName); - }, - `Execute and verify workflow: ${workflowName}` - ); - } -} diff --git a/e2e/src/utils.cjs b/e2e/src/utils.cjs deleted file mode 100644 index 7f2593d..0000000 --- a/e2e/src/utils.cjs +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; - -const OTPAuth = require('otpauth'); -const dotenv = require('@dotenvx/dotenvx'); - -dotenv.config(); - -/** - * Gets the baseUrl to use for the environment and context the tests are running in - */ -const baseURL = process.env.FALCON_BASE_URL ?? 'https://falcon.us-2.crowdstrike.com/'; - -/** - * @param {string} role - */ -async function getUserCredentials(role) { - let email = process.env.FALCON_USERNAME; - let password = process.env.FALCON_PASSWORD; - let secret = process.env.FALCON_AUTH_SECRET; - - return { email, password, secret }; -} - -/** - * Generates a time-based one-time password - * @param {string} secret - Secret key for 2FA - */ -function getTotp(secret) { - const totp = new OTPAuth.TOTP({ - algorithm: 'SHA1', - digits: 6, - period: 30, - secret, - }); - - return totp.generate(); -} - -module.exports = { - baseURL, - getUserCredentials, - getTotp -}; diff --git a/e2e/src/utils/Logger.ts b/e2e/src/utils/Logger.ts deleted file mode 100644 index 24d47e6..0000000 --- a/e2e/src/utils/Logger.ts +++ /dev/null @@ -1,190 +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', `[OK] ${message}`, context); - } - - /** - * Log warnings (non-blocking issues) - */ - warn(message: string, context: LogContext = {}): void { - this.log('warn', `[WARN] ${message}`, context); - } - - /** - * Log errors (blocking issues) - */ - error(message: string, error?: Error, context: LogContext = {}): void { - const errorDetails = error ? ` - ${error.message}` : ''; - this.log('error', `[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', `[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', `[PERF] ${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', `[SUMMARY] ${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 { - // 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('[OK] Test passed') || - message.includes('[ERROR] 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 'o'; - - return '-'; // Default for other actions - } -} - -// Singleton instance export -export const logger = Logger.getInstance(); diff --git a/e2e/src/utils/SmartWaiter.ts b/e2e/src/utils/SmartWaiter.ts deleted file mode 100644 index 5b29690..0000000 --- a/e2e/src/utils/SmartWaiter.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { Page, Locator } 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); - } - } - ); - } -} diff --git a/e2e/tests/app-install.setup.ts b/e2e/tests/app-install.setup.ts deleted file mode 100644 index a20eb90..0000000 --- a/e2e/tests/app-install.setup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test as setup } from '../src/fixtures'; - -setup('install LogScale Data Ingestion app', async ({ appCatalogPage, appName }) => { - // Check if app is already installed (this navigates to the app page) - const isInstalled = await appCatalogPage.isAppInstalled(appName); - - if (!isInstalled) { - console.log(`App '${appName}' is not installed. Installing...`); - const installed = await appCatalogPage.installApp(appName); - - if (!installed) { - throw new Error(`Failed to install app '${appName}'`); - } - } else { - console.log(`App '${appName}' is already installed`); - } -}); diff --git a/e2e/tests/app-uninstall.teardown.ts b/e2e/tests/app-uninstall.teardown.ts deleted file mode 100644 index d861e7c..0000000 --- a/e2e/tests/app-uninstall.teardown.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test as teardown } from '../src/fixtures'; - -teardown('uninstall LogScale Data Ingestion app', async ({ appCatalogPage, appName }) => { - // Clean up by uninstalling the app after all tests complete - // uninstallApp navigates to the catalog directly via searchAndNavigateToApp - // If the app is not found (e.g., already deleted by CI cleanup), skip gracefully - try { - await appCatalogPage.uninstallApp(appName); - } catch (error) { - const message = (error as Error).message; - if (message.includes('Could not find app')) { - console.log(`Teardown: app '${appName}' not found in catalog, skipping uninstall`); - } else { - throw error; - } - } -}); 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 968106a..06dc536 100644 --- a/e2e/tests/foundry.spec.ts +++ b/e2e/tests/foundry.spec.ts @@ -7,7 +7,7 @@ test.describe('LogScale Data Ingestion - E2E Tests', () => { await logScalePage.navigateToApp(); }); - test('should fill form with test data and submit successfully', async ({ logScalePage }) => { + test('should fill form with test data and submit successfully', { timeout: 240_000 }, async ({ logScalePage }) => { await logScalePage.navigateToApp(); // Fill form with specific test data From f272243c4e32a25eaeddb931af2674a938eaf3b6 Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 23 Apr 2026 11:54:58 -0600 Subject: [PATCH 2/5] =?UTF-8?q?Update=20foundry-playwright=20library=20(ne?= =?UTF-8?q?tworkidle=20=E2=86=92=20domcontentloaded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 7e80efd..82e8af4 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -18,7 +18,7 @@ "node_modules/@crowdstrike/foundry-playwright": { "version": "0.5.0", "resolved": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz", - "integrity": "sha512-bkdlCSBEHasOaSWA1fFr3VkAzyCoqzTiPzpIyWlj4NA6rhOCnelA2k+X3tYKtY+tLb54NkvnVzrDsUwIsn24og==", + "integrity": "sha512-o+6eBYQBeE7qrzc1N132/Zg3lmFujMQ9OGRdN1cKnF2KXTJXR4qe4anRAgz0spYAX5au1rDcRnsaNWczIBOqSQ==", "license": "MIT", "dependencies": { "@dotenvx/dotenvx": "^1.61.0", From 5ce16873650b732c1ac99e21cff8799161c1a7fd Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 7 May 2026 11:39:35 -0600 Subject: [PATCH 3/5] Use published @crowdstrike/foundry-playwright@0.5.0 Replace local tarball reference with published npm package. --- e2e/package-lock.json | 79 +++++++++++++++++++++++++++++++------------ e2e/package.json | 4 +-- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 82e8af4..a079a6c 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -8,31 +8,31 @@ "name": "playwright-foundry", "version": "1.0.0", "license": "MIT", - "dependencies": { - "@crowdstrike/foundry-playwright": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz" - }, "devDependencies": { + "@crowdstrike/foundry-playwright": "0.5.0", "@types/node": "latest" } }, "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==", + "resolved": "https://registry.npmjs.org/@crowdstrike/foundry-playwright/-/foundry-playwright-0.5.0.tgz", + "integrity": "sha512-0dKup8mOEVT5KKJezfYVskOgk1zDh6MONyQ1EBdkQ4l1HfMbloZEJ4eMK+Opgww28yMCjhgsyuKp/qq4xmzURg==", + "dev": true, "license": "MIT", "dependencies": { - "@dotenvx/dotenvx": "^1.61.0", - "@playwright/test": "^1.59.1", - "otpauth": "^9.5.0" + "@dotenvx/dotenvx": "1.64.0", + "@playwright/test": "1.59.1", + "otpauth": "9.5.1" }, "engines": { "node": ">=24" } }, "node_modules/@dotenvx/dotenvx": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.1.tgz", - "integrity": "sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.64.0.tgz", + "integrity": "sha512-6+xRpZaWuHXEqnhBjae+VmQI9Uaqw5Uzu/ScpO+W7ww9Zp3lHSNBoNjFcUxhrCyc7pRGQzyDjhKzloqrPHERiQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "commander": "^11.1.0", @@ -57,6 +57,7 @@ "version": "0.2.6", "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "dev": true, "license": "MIT", "engines": { "bun": ">=1", @@ -71,6 +72,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -83,6 +85,7 @@ "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, "license": "MIT", "dependencies": { "@noble/hashes": "1.8.0" @@ -98,6 +101,7 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -110,6 +114,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.59.1" @@ -135,6 +140,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=16" @@ -144,6 +150,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -158,12 +165,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -179,6 +188,7 @@ "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -191,6 +201,7 @@ "version": "0.4.18", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", + "dev": true, "license": "MIT", "dependencies": { "@ecies/ciphers": "^0.2.5", @@ -208,6 +219,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -231,6 +243,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -248,6 +261,7 @@ "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, @@ -262,6 +276,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -274,6 +289,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -283,6 +299,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -292,6 +309,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -304,6 +322,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -313,12 +332,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, "license": "MIT" }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -328,6 +349,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -340,6 +362,7 @@ "version": "1.1.33", "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -349,6 +372,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -361,21 +385,23 @@ } }, "node_modules/otpauth": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", - "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz", + "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==", + "dev": true, "license": "MIT", "dependencies": { - "@noble/hashes": "2.0.1" + "@noble/hashes": "2.2.0" }, "funding": { "url": "https://github.com/hectorm/otpauth?sponsor=1" } }, "node_modules/otpauth/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -388,6 +414,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -397,6 +424,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -409,6 +437,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.59.1" @@ -427,6 +456,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -439,6 +469,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -451,6 +482,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -460,12 +492,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -482,6 +516,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -494,9 +529,10 @@ } }, "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==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", + "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", + "dev": true, "license": "MIT", "dependencies": { "yoctocolors": "^2.1.1" @@ -512,6 +548,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/e2e/package.json b/e2e/package.json index 04fbf0b..3a09de0 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,10 +11,8 @@ "keywords": [], "license": "MIT", "type": "commonjs", - "dependencies": { - "@crowdstrike/foundry-playwright": "file:../../foundry-playwright/crowdstrike-foundry-playwright-0.5.0.tgz" - }, "devDependencies": { + "@crowdstrike/foundry-playwright": "0.5.0", "@types/node": "latest" } } From 3134b1737ac914ec18b22e20ae618ced14df1884 Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 7 May 2026 12:15:43 -0600 Subject: [PATCH 4/5] Pin @types/node to exact version (remove "latest") --- e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/package.json b/e2e/package.json index 3a09de0..3df0266 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -13,6 +13,6 @@ "type": "commonjs", "devDependencies": { "@crowdstrike/foundry-playwright": "0.5.0", - "@types/node": "latest" + "@types/node": "25.6.0" } } From d9a7ec7d3d3227e792cf16cb9c0d224e5455a8f7 Mon Sep 17 00:00:00 2001 From: Matt Raible Date: Thu, 7 May 2026 14:25:42 -0600 Subject: [PATCH 5/5] Remove unused fixtures from e2e tests Kept logScalePage and workflowsPage which are used in tests. Removed FoundryHomePage, AppCatalogPage, appName, and config fixtures that were defined but never destructured. --- e2e/src/fixtures.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 640b31c..6d89c5a 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,29 +1,13 @@ import { test as baseTest } from '@playwright/test'; -import { - FoundryHomePage, - AppCatalogPage, - WorkflowsPage, - config, -} from '@crowdstrike/foundry-playwright'; +import { WorkflowsPage } from '@crowdstrike/foundry-playwright'; import { LogScalePage } from './pages/LogScalePage'; type FoundryFixtures = { - foundryHomePage: FoundryHomePage; - appCatalogPage: AppCatalogPage; logScalePage: LogScalePage; workflowsPage: WorkflowsPage; - appName: string; }; export const test = baseTest.extend({ - foundryHomePage: async ({ page }, use) => { - await use(new FoundryHomePage(page)); - }, - - appCatalogPage: async ({ page }, use) => { - await use(new AppCatalogPage(page)); - }, - logScalePage: async ({ page }, use) => { await use(new LogScalePage(page)); }, @@ -31,10 +15,6 @@ export const test = baseTest.extend({ workflowsPage: async ({ page }, use) => { await use(new WorkflowsPage(page)); }, - - appName: async ({}, use) => { - await use(config.appName); - }, }); export { expect } from '@playwright/test';