diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..95bcaaf --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,167 @@ +name: Functions Python App E2E Tests + +on: + workflow_dispatch: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +# Serialize E2E tests to prevent deployment and UI collisions +concurrency: + group: e2e-tests-${{ github.repository }} + cancel-in-progress: false # Let running tests finish before starting new ones + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.actor != 'dependabot[bot]' + env: + FOUNDRY_API_CLIENT_ID: ${{ secrets.FOUNDRY_API_CLIENT_ID }} + FOUNDRY_API_CLIENT_SECRET: ${{ secrets.FOUNDRY_API_CLIENT_SECRET }} + FOUNDRY_CID: ${{ secrets.FOUNDRY_CID }} + FOUNDRY_CLOUD_REGION: ${{ secrets.FOUNDRY_CLOUD_REGION }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@1ccc07ccd8b9519f44d3e5eaa1b41dd90310adf0 # master + + - name: Install required tools + run: | + brew tap crowdstrike/foundry-cli + brew install crowdstrike/foundry-cli/foundry yq + + - name: Create directory for Foundry CLI + run: mkdir -p ~/.config/foundry + + - name: Prepare app manifest + run: | + # Remove IDs from manifest + yq -i 'del(.. | select(has("id")).id) | del(.. | select(has("app_id")).app_id)' manifest.yml + + # Generate unique app name with length safety + REPO_NAME="${{ github.event.repository.name }}" + ACTOR="${{ github.actor }}" + SHORT_ACTOR="${ACTOR/dependabot\[bot\]/deps}" + UNIQUE_NAME="${REPO_NAME}-${SHORT_ACTOR}-$(date +"%m%d%H%M")" + + # Truncate if too long by removing foundry- prefix + if [ ${#UNIQUE_NAME} -gt 50 ]; then + REPO_BASE="${REPO_NAME#foundry-}" + UNIQUE_NAME="${REPO_BASE}-${SHORT_ACTOR}-$(date +"%m%d%H%M")" + fi + + # Export for yq and set the manifest name + export UNIQUE_NAME + yq -i '.name = env(UNIQUE_NAME)' manifest.yml + + # Set app name as environment variable + APP_NAME=$(yq '.name' manifest.yml) + echo "APP_NAME=$APP_NAME" >> $GITHUB_ENV + + echo "Prepared manifest with app name: $APP_NAME" + + - name: Deploy app to Falcon + run: | + foundry apps deploy --change-type=major --change-log="e2e deploy" + echo "App deployment initiated" + + - name: Wait for deployment and release app + run: | + echo "Waiting for deployment to complete..." + timeout=300 # 5 minute timeout + elapsed=0 + + while [ $elapsed -lt $timeout ]; do + if foundry apps list-deployments | grep -i "successful"; then + echo "Deployment successful, releasing app..." + foundry apps release --change-type=major --notes="e2e release" + echo "App released successfully" + + # Brief wait for release to complete - E2E tests handle app discovery with retries + echo "Allowing brief time for release to complete..." + sleep 15 + + # Verify deployment status and get app details + echo "Verifying final deployment status..." + foundry apps list-deployments + + echo "Final deployed app name: $APP_NAME" + + exit 0 + fi + + if foundry apps list-deployments | grep -i "failed"; then + echo "Deployment failed" + exit 1 + fi + + sleep 5 + elapsed=$((elapsed + 5)) + done + + echo "Deployment timeout after ${timeout} seconds" + exit 1 + + # Parallelize Node setup while deployment happens + - name: Install Node LTS + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: e2e/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: e2e + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + working-directory: e2e + + - name: Make envfile + uses: SpicyPizza/create-envfile@6da099c0b655bd3abd8273c4e2fe7c59e63fa88a # v2 + with: + envkey_FALCON_USERNAME: ${{ secrets.FALCON_USERNAME }} + envkey_FALCON_PASSWORD: ${{ secrets.FALCON_PASSWORD }} + envkey_FALCON_AUTH_SECRET: ${{ secrets.FALCON_AUTH_SECRET }} + envkey_APP_NAME: $APP_NAME + directory: e2e + + - name: Run Playwright tests + run: npx dotenvx run --quiet -- npx playwright test + working-directory: e2e + env: + CI: true + + - name: Upload Playwright report + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-${{ env.APP_NAME }} + path: e2e/playwright-report/ + retention-days: 7 + + - name: Upload test results and screenshots + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-test-results-${{ env.APP_NAME }} + path: e2e/test-results/ + retention-days: 7 + + - name: Delete app from Falcon + if: always() + run: | + echo "Deleting app: $APP_NAME" + foundry apps delete -f || echo "App deletion failed, may already be deleted" diff --git a/e2e/.env.sample b/e2e/.env.sample new file mode 100644 index 0000000..f82faa4 --- /dev/null +++ b/e2e/.env.sample @@ -0,0 +1,8 @@ +# Falcon Authentication +FALCON_USERNAME=your.email@company.com +FALCON_PASSWORD=your-password +FALCON_AUTH_SECRET=your-totp-secret +FALCON_BASE_URL=https://falcon.us-2.crowdstrike.com + +# App Configuration +APP_NAME=foundry-sample-functions-python diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..e11abba --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,15 @@ +# .dotenvx +.env +.env.keys + +# IntelliJ IDEA +.idea + +# Playwright +node_modules/ +/test-results/ +/playwright/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +*.log diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..b544aab --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,28 @@ +# E2E Tests + +## Setup + +```bash +npm ci +npx playwright install chromium +cp .env.sample .env +# Edit .env with your credentials +``` + +## Run Tests + +```bash +npm test # All tests +npm run test:debug # Debug mode +npm run test:ui # Interactive UI +``` + +## Environment Variables + +```env +APP_NAME=foundry-sample-functions-python +FALCON_BASE_URL=https://falcon.us-2.crowdstrike.com +FALCON_USERNAME=your-username +FALCON_PASSWORD=your-password +FALCON_AUTH_SECRET=your-mfa-secret +``` diff --git a/e2e/constants/AuthFile.ts b/e2e/constants/AuthFile.ts new file mode 100644 index 0000000..970a66a --- /dev/null +++ b/e2e/constants/AuthFile.ts @@ -0,0 +1 @@ +export const AuthFile = 'playwright/.auth/user.json'; diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..5ec0adb --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,476 @@ +{ + "name": "playwright-foundry", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playwright-foundry", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@dotenvx/dotenvx": "1.49.0", + "otpauth": "9.4.1" + }, + "devDependencies": { + "@playwright/test": "1.55.0", + "@types/node": "24.4.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.49.0.tgz", + "integrity": "sha512-M1cyP6YstFQCjih54SAxCqHLMMi8QqV8tenpgGE48RTXWD7vfMYJiw/6xcCDpS2h28AcLpTsFCZA863Ge9yxzA==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.4.tgz", + "integrity": "sha512-t+iX+Wf5nRKyNzk8dviW3Ikb/280+aEJAnw9YXvCp2tYGPSkMki+NRY+8aNLmVFv3eNtMdvViPNOPxS8SZNP+w==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.2.tgz", + "integrity": "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", + "integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.11.0" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "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==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dotenv": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eciesjs": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.15.tgz", + "integrity": "sha512-r6kEJXDKecVOCj2nLMuXK/FCPeurW33+3JRpfXVbjLja3XUYFfD9I/JBreH6sUyzcm3G/YQboBjMla6poKeSdA==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.3", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.1", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "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, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "engines": { + "node": ">=16" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/otpauth": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz", + "integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/undici-types": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz", + "integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..74488cd --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,25 @@ +{ + "name": "playwright-foundry", + "version": "1.0.0", + "description": "Playwright e2e tests to ensure app installs and renders properly", + "scripts": { + "test": "npx playwright test", + "test:ui": "npx playwright test --ui", + "test:debug": "npx playwright test --debug", + "test:verbose": "DEBUG=true npx playwright test --reporter=list" + }, + "keywords": [], + "license": "MIT", + "type": "commonjs", + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@dotenvx/dotenvx": "1.49.0", + "otpauth": "9.4.1" + }, + "devDependencies": { + "@playwright/test": "1.56.1", + "@types/node": "24.4.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..9a59e52 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,61 @@ +import { defineConfig, devices } from '@playwright/test'; +import { AuthFile } from './constants/AuthFile'; +import dotenv from 'dotenv'; + +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 ? 1 : 0, + workers: process.env.CI ? 2 : 4, + timeout: process.env.CI ? 60 * 1000 : 45 * 1000, + expect: { + timeout: process.env.CI ? 10 * 1000 : 8 * 1000, + }, + reporter: 'list', + use: { + testIdAttribute: 'data-test-selector', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: process.env.CI ? 15 * 1000 : 10 * 1000, + navigationTimeout: process.env.CI ? 30 * 1000 : 20 * 1000, + }, + + projects: [ + { + name: 'setup', + testMatch: /authenticate.setup.ts/, + }, + { + name: 'app-install', + testMatch: /app-install.setup.ts/, + use: { + ...devices['Desktop Chrome'], + storageState: AuthFile + }, + dependencies: ["setup"] + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: AuthFile + }, + dependencies: ["setup", "app-install"] + }, + { + name: 'app-uninstall', + testMatch: /app-uninstall.teardown.ts/, + use: { + ...devices['Desktop Chrome'], + storageState: AuthFile + }, + dependencies: ["chromium"] + }, + ], +}); diff --git a/e2e/src/authenticate.cjs b/e2e/src/authenticate.cjs new file mode 100644 index 0000000..c87e804 --- /dev/null +++ b/e2e/src/authenticate.cjs @@ -0,0 +1,106 @@ +'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 new file mode 100644 index 0000000..bb7bef4 --- /dev/null +++ b/e2e/src/config/TestConfig.ts @@ -0,0 +1,147 @@ +/** + * Centralized configuration management for Foundry E2E tests + * Centralizes all environment variables, validation, and defaults + */ +export class TestConfig { + private static _instance: TestConfig; + + // Core URLs and endpoints + public readonly falconBaseUrl: string; + public readonly apiBaseUrl: string; + + // Authentication + public readonly falconUsername: string; + public readonly falconPassword: string; + public readonly authSecret: string; + + // App configuration + public readonly appName: string; + + // Test configuration + public readonly defaultTimeout: number; + public readonly navigationTimeout: number; + public readonly retryAttempts: number; + public readonly screenshotPath: string; + + // Environment detection + public readonly isCI: boolean; + public readonly isDebugMode: boolean; + + private constructor() { + // Validate all required environment variables first + this.validateEnvironment(); + + // Core URLs + this.falconBaseUrl = process.env.FALCON_BASE_URL || 'https://falcon.us-2.crowdstrike.com'; + this.apiBaseUrl = `${this.falconBaseUrl}/api/v2`; + + // Authentication (required) + this.falconUsername = this.getRequiredEnv('FALCON_USERNAME'); + this.falconPassword = this.getRequiredEnv('FALCON_PASSWORD'); + this.authSecret = this.getRequiredEnv('FALCON_AUTH_SECRET'); + + // App configuration + this.appName = this.getRequiredEnv('APP_NAME'); + + // Test timeouts (configurable defaults - longer in CI due to slower hardware) + this.defaultTimeout = parseInt(process.env.DEFAULT_TIMEOUT || (this.isCI ? '45000' : '30000')); + this.navigationTimeout = parseInt(process.env.NAVIGATION_TIMEOUT || (this.isCI ? '30000' : '15000')); + this.retryAttempts = parseInt(process.env.RETRY_ATTEMPTS || (this.isCI ? '3' : '2')); + + // Paths + this.screenshotPath = process.env.SCREENSHOT_PATH || 'test-results'; + + // Environment detection + this.isCI = !!process.env.CI; + this.isDebugMode = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'debug'; + } + + public static getInstance(): TestConfig { + if (!TestConfig._instance) { + TestConfig._instance = new TestConfig(); + } + return TestConfig._instance; + } + + private validateEnvironment(): void { + const required = [ + 'FALCON_USERNAME', + 'FALCON_PASSWORD', + 'FALCON_AUTH_SECRET', + 'APP_NAME' + ]; + + const missing = required.filter(key => !process.env[key]); + + if (missing.length > 0) { + throw new Error( + `❌ Missing required environment variables: ${missing.join(', ')}\n` + + `Please check your .env file or environment setup.` + ); + } + } + + private getRequiredEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`❌ Required environment variable ${key} is not set`); + } + return value; + } + + /** + * Get environment-aware configuration for Playwright timeouts + */ + public getPlaywrightTimeouts() { + return { + timeout: this.defaultTimeout, + navigationTimeout: this.navigationTimeout, + actionTimeout: this.isCI ? 15000 : 10000, // Longer in CI for slower hardware + }; + } + + /** + * Get screenshot configuration + */ + public getScreenshotConfig() { + return { + path: this.screenshotPath, + fullPage: true, + type: 'png' as const + // Note: quality parameter is not supported for PNG screenshots + }; + } + + /** + * Get retry configuration for flaky operations + */ + public getRetryConfig() { + return { + attempts: this.retryAttempts, + delay: this.isCI ? 2000 : 1000, + backoff: 'exponential' as const + }; + } + + /** + * Log configuration summary (safe for logs) + */ + public logSummary(): void { + if (this.isCI) { + // Very minimal logging in CI + console.log(`E2E Test Config: ${this.isCI ? 'CI' : 'Local'} | ${this.appName}`); + } else { + // Detailed logging for local development + console.log('🔧 Test Configuration:'); + console.log(` Environment: ${this.isCI ? 'CI' : 'Local'}`); + console.log(` Base URL: ${this.falconBaseUrl}`); + console.log(` App Name: ${this.appName}`); + console.log(` Default Timeout: ${this.defaultTimeout}ms`); + console.log(` Retry Attempts: ${this.retryAttempts}`); + console.log(` Debug Mode: ${this.isDebugMode}${this.isDebugMode ? '' : ' (enable with DEBUG=true npm test or npm run test:debug)'}`); + } + } +} + +// Singleton instance export +export const config = TestConfig.getInstance(); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts new file mode 100644 index 0000000..d6418cf --- /dev/null +++ b/e2e/src/fixtures.ts @@ -0,0 +1,68 @@ +import { test as baseTest } from '@playwright/test'; +import { FoundryHomePage } from './pages/FoundryHomePage'; +import { AppManagerPage } from './pages/AppManagerPage'; +import { AppCatalogPage } from './pages/AppCatalogPage'; +import { HelloExtensionPage } from './pages/HelloExtensionPage'; +import { WorkflowsPage } from './pages/WorkflowsPage'; +import { HostManagementPage } from './pages/HostManagementPage'; +import { config } from './config/TestConfig'; +import { logger } from './utils/Logger'; + +type FoundryFixtures = { + foundryHomePage: FoundryHomePage; + appManagerPage: AppManagerPage; + appCatalogPage: AppCatalogPage; + helloExtensionPage: HelloExtensionPage; + workflowsPage: WorkflowsPage; + hostManagementPage: HostManagementPage; + appName: string; +}; + +export const test = baseTest.extend({ + // Configure page with centralized settings + page: async ({ page }, use) => { + const timeouts = config.getPlaywrightTimeouts(); + page.setDefaultTimeout(timeouts.timeout); + + // Log configuration on first use + if (!process.env.CONFIG_LOGGED) { + config.logSummary(); + process.env.CONFIG_LOGGED = 'true'; + } + + await use(page); + }, + + // Page object fixtures with dependency injection + foundryHomePage: async ({ page }, use) => { + await use(new FoundryHomePage(page)); + }, + + appManagerPage: async ({ page }, use) => { + await use(new AppManagerPage(page)); + }, + + appCatalogPage: async ({ page }, use) => { + await use(new AppCatalogPage(page)); + }, + + helloExtensionPage: async ({ page }, use) => { + await use(new HelloExtensionPage(page)); + }, + + workflowsPage: async ({ page }, use) => { + await use(new WorkflowsPage(page)); + }, + + hostManagementPage: async ({ page }, use) => { + await use(new HostManagementPage(page)); + }, + + + // App name from centralized config + appName: async ({}, use) => { + await use(config.appName); + }, +}); + +export { expect } from '@playwright/test'; \ No newline at end of file diff --git a/e2e/src/pages/AppCatalogPage.ts b/e2e/src/pages/AppCatalogPage.ts new file mode 100644 index 0000000..ab5a3d6 --- /dev/null +++ b/e2e/src/pages/AppCatalogPage.ts @@ -0,0 +1,354 @@ +/** + * AppCatalogPage - App installation and management + */ + +import { Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { RetryHandler } from '../utils/SmartWaiter'; +import { config } from '../config/TestConfig'; + +export class AppCatalogPage extends BasePage { + constructor(page: Page) { + super(page, 'AppCatalogPage'); + } + + protected getPagePath(): string { + return '/foundry/app-catalog'; + } + + protected async verifyPageLoaded(): Promise { + await this.waiter.waitForVisible( + this.page.locator('text=App Catalog').or(this.page.locator('text=Apps')), + { description: 'App Catalog page' } + ); + + this.logger.success('App Catalog page loaded successfully'); + } + + /** + * Search for app in catalog and navigate to its page + */ + private async searchAndNavigateToApp(appName: string): Promise { + this.logger.info(`Searching for app '${appName}' in catalog`); + + await this.navigateToPath('/foundry/app-catalog', 'App catalog page'); + + const searchBox = this.page.getByRole('searchbox', { name: 'Search' }); + await searchBox.fill(appName); + await this.page.keyboard.press('Enter'); + await this.page.waitForLoadState('networkidle'); + + const appLink = this.page.getByRole('link', { name: appName, exact: true }); + + try { + await this.waiter.waitForVisible(appLink, { + description: `App '${appName}' link in catalog`, + timeout: 10000 + }); + this.logger.success(`Found app '${appName}' in catalog`); + await this.smartClick(appLink, `App '${appName}' link`); + await this.page.waitForLoadState('networkidle'); + } catch (error) { + throw new Error(`Could not find app '${appName}' in catalog. Make sure the app is deployed.`); + } + } + + /** + * Check if app is installed + */ + async isAppInstalled(appName: string): Promise { + this.logger.step(`Check if app '${appName}' is installed`); + + // Search for and navigate to the app's catalog page + await this.searchAndNavigateToApp(appName); + + // Check for installation indicators on the app's page + // Simple check: if "Install now" link exists, app is NOT installed + const installLink = this.page.getByRole('link', { name: 'Install now' }); + const hasInstallLink = await this.elementExists(installLink, 3000); + + const isInstalled = !hasInstallLink; + this.logger.info(`App '${appName}' installation status: ${isInstalled ? 'Installed' : 'Not installed'}`); + + return isInstalled; + } + + /** + * Install app if not already installed + */ + async installApp(appName: string): Promise { + this.logger.step(`Install app '${appName}'`); + + const isInstalled = await this.isAppInstalled(appName); + if (isInstalled) { + this.logger.info(`App '${appName}' is already installed`); + return false; + } + + // Click Install now link + this.logger.info('App not installed, looking for Install now link'); + const installLink = this.page.getByRole('link', { name: 'Install now' }); + + await this.waiter.waitForVisible(installLink, { description: 'Install now link' }); + await this.smartClick(installLink, 'Install now link'); + this.logger.info('Clicked Install now, waiting for install page to load'); + + // Wait for URL to change to install page and page to stabilize + await this.page.waitForURL(/\/foundry\/app-catalog\/[^\/]+\/install$/, { timeout: 10000 }); + await this.page.waitForLoadState('networkidle'); + + // Handle permissions dialog + await this.handlePermissionsDialog(); + + // Check for API integration configuration screen + await this.configureServiceNowIfNeeded(); + + // Click final Install app button + await this.clickInstallAppButton(); + + // Wait for installation to complete + await this.waitForInstallation(appName); + + this.logger.success(`App '${appName}' installed successfully`); + return true; + } + + /** + * Handle permissions dialog if present + */ + private async handlePermissionsDialog(): Promise { + const acceptButton = this.page.getByRole('button', { name: /accept.*continue/i }); + + if (await this.elementExists(acceptButton, 3000)) { + this.logger.info('Permissions dialog detected, accepting'); + await this.smartClick(acceptButton, 'Accept and continue button'); + await this.waiter.delay(2000); + } + } + + /** + * Configure ServiceNow API integration if configuration form is present + */ + private async configureServiceNowIfNeeded(): Promise { + this.logger.info('Checking if ServiceNow API configuration is required...'); + + // Check if there are text input fields (configuration form) + const textInputs = this.page.locator('input[type="text"]'); + + try { + await textInputs.first().waitFor({ state: 'visible', timeout: 15000 }); + const count = await textInputs.count(); + this.logger.info(`ServiceNow configuration form detected with ${count} input fields`); + } catch (error) { + this.logger.info('No ServiceNow configuration required - no input fields found'); + return; + } + + this.logger.info('ServiceNow configuration required, filling dummy values'); + + // Fill configuration fields using index-based selection + // Field 1: Name + const nameField = this.page.locator('input[type="text"]').first(); + await nameField.fill('ServiceNow Test Instance'); + this.logger.debug('Filled Name field'); + + // Field 2: Instance (the {instance} part of {instance}.service-now.com) + const instanceField = this.page.locator('input[type="text"]').nth(1); + await instanceField.fill('dev12345'); + this.logger.debug('Filled Instance field'); + + // Field 3: Username + const usernameField = this.page.locator('input[type="text"]').nth(2); + await usernameField.fill('dummy_user'); + this.logger.debug('Filled Username field'); + + // Field 4: Password (must be >8 characters) + const passwordField = this.page.locator('input[type="password"]').first(); + await passwordField.fill('DummyPassword123'); + this.logger.debug('Filled Password field'); + + // Wait for network to settle after filling form + await this.page.waitForLoadState('networkidle'); + + this.logger.success('ServiceNow API configuration completed'); + } + + /** + * Click the final "Install app" button + */ + private async clickInstallAppButton(): Promise { + const installButton = this.page.getByRole('button', { name: 'Install app' }); + + await this.waiter.waitForVisible(installButton, { description: 'Install app button' }); + + // Wait for button to be enabled + await installButton.waitFor({ state: 'visible', timeout: 10000 }); + await installButton.waitFor({ state: 'attached', timeout: 5000 }); + + // Simple delay for form to enable button + await this.waiter.delay(1000); + + await this.smartClick(installButton, 'Install app button'); + this.logger.info('Clicked Install app button'); + } + + /** + * Wait for installation to complete + */ + private async waitForInstallation(appName: string): Promise { + this.logger.info('Waiting for installation to complete...'); + + // Wait for URL to change or network to settle + await Promise.race([ + this.page.waitForURL(/\/foundry\/(app-catalog|home)/, { timeout: 15000 }), + this.page.waitForLoadState('networkidle', { timeout: 15000 }) + ]).catch(() => {}); + + // Look for first "installing" message + const installingMessage = this.page.getByText(/installing/i).first(); + + try { + await installingMessage.waitFor({ state: 'visible', timeout: 30000 }); + this.logger.success('Installation started - "installing" message appeared'); + } catch (error) { + throw new Error(`Installation failed to start for app '${appName}' - "installing" message never appeared. Installation may have failed immediately.`); + } + + // Wait for second toast with final status (installed or error) + // Match exact toast messages using app name + const installedMessage = this.page.getByText(`${appName} installed`).first(); + const errorMessage = this.page.getByText(`Error installing ${appName}`).first(); + + try { + await Promise.race([ + installedMessage.waitFor({ state: 'visible', timeout: 60000 }).then(() => 'success'), + errorMessage.waitFor({ state: 'visible', timeout: 60000 }).then(() => 'error') + ]).then(result => { + if (result === 'error') { + throw new Error(`Installation failed for app '${appName}' - error message appeared`); + } + this.logger.success('Installation completed successfully - "installed" message appeared'); + }); + } catch (error) { + if (error.message.includes('Installation failed')) { + throw error; + } + throw new Error(`Installation status unclear for app '${appName}' - timed out waiting for "installed" or "error" message after 60 seconds`); + } + // Brief catalog status check (5-10s) - "installed" toast is the real signal + // This is just for logging/verification, not a hard requirement + this.logger.info('Checking catalog status briefly (installation already confirmed by toast)...'); + + // Navigate directly to app catalog with search query + const baseUrl = new URL(this.page.url()).origin; + await this.page.goto(`${baseUrl}/foundry/app-catalog?q=${appName}`); + await this.page.waitForLoadState('networkidle'); + + // Check status a couple times (up to 10 seconds) + const statusText = this.page.locator('[data-test-selector="status-text"]').filter({ hasText: /installed/i }); + const maxAttempts = 2; // 2 attempts = up to 10 seconds + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const isVisible = await statusText.isVisible().catch(() => false); + + if (isVisible) { + this.logger.success('Catalog status verified - shows Installed'); + return; + } + + if (attempt < maxAttempts - 1) { + this.logger.info(`Catalog status not yet updated, waiting 5s before refresh (attempt ${attempt + 1}/${maxAttempts})...`); + await this.waiter.delay(5000); + await this.page.reload({ waitUntil: 'domcontentloaded' }); + } + } + + // Don't fail - the "installed" toast is reliable enough + this.logger.info(`Catalog status not updated yet after ${maxAttempts * 5}s, but toast confirmed installation - continuing`); + } + + /** + * Navigate to app via Custom Apps menu + */ + async navigateToAppViaCustomApps(appName: string): Promise { + this.logger.step(`Navigate to app '${appName}' via Custom Apps`); + + return RetryHandler.withPlaywrightRetry( + async () => { + // Navigate to Foundry home + await this.navigateToPath('/foundry/home', 'Foundry home page'); + + // Open hamburger menu + const menuButton = this.page.getByTestId('nav-trigger'); + await this.smartClick(menuButton, 'Menu button'); + + // Click Custom apps + const customAppsButton = this.page.getByRole('button', { name: 'Custom apps' }); + await this.smartClick(customAppsButton, 'Custom apps button'); + + // Find and click the app + const appButton = this.page.getByRole('button', { name: appName, exact: false }).first(); + if (await this.elementExists(appButton, 3000)) { + await this.smartClick(appButton, `App '${appName}' button`); + await this.waiter.delay(1000); + + this.logger.success(`Navigated to app '${appName}' via Custom Apps`); + return; + } + + throw new Error(`App '${appName}' not found in Custom Apps menu`); + }, + `Navigate to app via Custom Apps` + ); + } + + /** + * Uninstall app + */ + async uninstallApp(appName: string): Promise { + this.logger.step(`Uninstall app '${appName}'`); + + try { + // Search for and navigate to the app's catalog page + await this.searchAndNavigateToApp(appName); + + // Check if app is actually installed by looking for "Install now" link + // If "Install now" link exists, app is NOT installed + const installLink = this.page.getByRole('link', { name: 'Install now' }); + const hasInstallLink = await this.elementExists(installLink, 3000); + + if (hasInstallLink) { + this.logger.info(`App '${appName}' is already uninstalled`); + return; + } + + // Click the 3-dot menu button + const openMenuButton = this.page.getByRole('button', { name: 'Open menu' }); + await this.waiter.waitForVisible(openMenuButton, { description: 'Open menu button' }); + await this.smartClick(openMenuButton, 'Open menu button'); + + // Click "Uninstall app" menuitem + const uninstallMenuItem = this.page.getByRole('menuitem', { name: 'Uninstall app' }); + await this.waiter.waitForVisible(uninstallMenuItem, { description: 'Uninstall app menuitem' }); + await this.smartClick(uninstallMenuItem, 'Uninstall app menuitem'); + + // Confirm uninstallation in modal + const uninstallButton = this.page.getByRole('button', { name: 'Uninstall' }); + await this.waiter.waitForVisible(uninstallButton, { description: 'Uninstall confirmation button' }); + await this.smartClick(uninstallButton, 'Uninstall button'); + + // Wait for success message + const successMessage = this.page.getByText(/has been uninstalled/i); + await this.waiter.waitForVisible(successMessage, { + description: 'Uninstall success message', + timeout: 30000 + }); + + this.logger.success(`App '${appName}' uninstalled successfully`); + + } catch (error) { + this.logger.warn(`Failed to uninstall app '${appName}': ${error.message}`); + throw error; + } + } +} diff --git a/e2e/src/pages/AppManagerPage.ts b/e2e/src/pages/AppManagerPage.ts new file mode 100644 index 0000000..6fe82dc --- /dev/null +++ b/e2e/src/pages/AppManagerPage.ts @@ -0,0 +1,66 @@ +import { Page, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { RetryHandler } from '../utils/SmartWaiter'; + +export class AppManagerPage extends BasePage { + constructor(page: Page) { + super(page, 'AppManagerPage'); + } + + protected getPagePath(): string { + return '/foundry/app-manager'; + } + + protected async verifyPageLoaded(): Promise { + await expect(this.page).toHaveTitle('App manager | Foundry | Falcon'); + } + + async findAndNavigateToApp(appName: string): Promise { + this.logger.step(`Find and navigate to app '${appName}'`); + + return RetryHandler.withPlaywrightRetry( + async () => { + const appList = await this.waiter.waitForVisible( + this.page.getByTestId('custom-apps-list'), + { description: 'Custom apps list' } + ); + + const appText = await this.waiter.waitForVisible( + appList.getByText(appName), + { description: `App '${appName}' text` } + ); + + const parent = appText.locator('../../../../..'); + await this.smartClick(parent.locator('button'), 'App menu button'); + + await this.smartClick( + this.page.getByText('View in app catalog'), + 'View in app catalog' + ); + + await expect(this.page).toHaveTitle('App catalog | Foundry | Falcon'); + await this.waiter.waitForPageLoad(); + + // Wait for app to appear in catalog with retry + const appLink = this.page.getByRole('link', { name: appName }); + + if (!(await this.elementExists(appLink, 15000))) { + this.logger.debug(`App '${appName}' not immediately visible, refreshing page...`); + await this.page.reload(); + await this.waiter.waitForPageLoad(); + + await this.waiter.waitForVisible(appLink, { + description: `App link for '${appName}'`, + timeout: 15000 + }); + } + + await appLink.click(); + await this.waiter.waitForPageLoad(); + + this.logger.success(`Successfully navigated to ${appName} from App manager`); + }, + `Find and navigate to ${appName}` + ); + } +} diff --git a/e2e/src/pages/BasePage.ts b/e2e/src/pages/BasePage.ts new file mode 100644 index 0000000..398f6de --- /dev/null +++ b/e2e/src/pages/BasePage.ts @@ -0,0 +1,252 @@ +import { Page, expect, Locator } from '@playwright/test'; +import { config } from '../config/TestConfig'; +import { logger, LogContext } from '../utils/Logger'; +import { SmartWaiter, RetryHandler } from '../utils/SmartWaiter'; + +/** + * Base page class + * Eliminates duplication and provides consistent patterns + */ +export abstract class BasePage { + protected readonly page: Page; + protected readonly waiter: SmartWaiter; + protected readonly logger: ReturnType; + protected readonly pageName: string; + + constructor(page: Page, pageName: string) { + this.page = page; + this.pageName = pageName; + this.waiter = new SmartWaiter(page, pageName); + this.logger = logger.forPage(pageName); + + // Set page-level timeouts from config + const timeouts = config.getPlaywrightTimeouts(); + page.setDefaultTimeout(timeouts.timeout); + } + + /** + * Get the base URL from centralized config + */ + protected getBaseURL(): string { + return config.falconBaseUrl; + } + + /** + * Navigate to a specific path with retry logic + */ + protected async navigateToPath(path: string, description?: string): Promise { + const url = `${this.getBaseURL()}${path}`; + const desc = description || `Navigate to ${path}`; + + this.logger.step(desc, { url }); + + await RetryHandler.withPlaywrightRetry( + async () => { + await this.page.goto(url); + await this.waiter.waitForPageLoad(desc); + }, + desc + ); + } + + /** + * Click an element with smart waiting and retry + */ + protected async smartClick( + locator: Locator | string, + description: string, + options: { timeout?: number; force?: boolean } = {} + ): Promise { + const defaultTimeout = config.getPlaywrightTimeouts().actionTimeout; + const actualTimeout = options.timeout || defaultTimeout; + + this.logger.step(`Click ${description}`, { + element: typeof locator === 'string' ? locator : 'locator', + timeout: actualTimeout, + force: options.force + }); + + await RetryHandler.withPlaywrightRetry( + async () => { + const element = await this.waiter.waitForVisible(locator, { + timeout: actualTimeout, + description + }); + await element.click({ force: options.force, timeout: actualTimeout }); + }, + `Click ${description}` + ); + } + + /** + * Wait for an element and perform actions on it + */ + protected async waitAndAct( + locator: Locator | string, + action: (element: Locator) => Promise, + description: string, + options: { timeout?: number; state?: 'visible' | 'attached' } = {} + ): Promise { + const defaultTimeout = config.getPlaywrightTimeouts().actionTimeout; + const actualTimeout = options.timeout || defaultTimeout; + const state = options.state || 'visible'; + + this.logger.debug(`Wait and act: ${description}`, { timeout: actualTimeout, state }); + + return RetryHandler.withPlaywrightRetry( + async () => { + const element = state === 'visible' + ? await this.waiter.waitForVisible(locator, { timeout: actualTimeout, description }) + : typeof locator === 'string' + ? this.page.locator(locator) + : locator; + + if (state === 'attached') { + await element.waitFor({ state: 'attached', timeout: actualTimeout }); + } + + return await action(element); + }, + description + ); + } + + /** + * Take a screenshot with consistent naming and error handling + */ + protected async takeScreenshot(filename: string, context: LogContext = {}): Promise { + try { + const screenshotConfig = config.getScreenshotConfig(); + + // Ensure the directory exists + const fs = require('fs'); + const path = require('path'); + const screenshotDir = screenshotConfig.path; + if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); + } + + // Create full path for the screenshot file + const fullPath = path.join(screenshotDir, filename); + + await this.page.screenshot({ + path: fullPath, + fullPage: screenshotConfig.fullPage, + type: screenshotConfig.type + }); + + this.logger.debug(`Screenshot saved: ${filename}`, { + ...context, + path: fullPath + }); + this.logger.success(`Screenshot saved: ${filename}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Failed to take screenshot: ${filename} - ${errorMessage}`, error instanceof Error ? error : undefined, context); + } + } + + /** + * Verify page URL matches expected pattern + */ + protected async verifyUrl(urlPattern: RegExp, description: string): Promise { + this.logger.step(`Verify URL: ${description}`, { pattern: urlPattern.toString() }); + + await expect(this.page).toHaveURL(urlPattern, { + timeout: config.navigationTimeout + }); + + this.logger.success(`URL verification passed: ${description}`); + } + + /** + * Wait for specific page to be loaded based on URL pattern + */ + protected async waitForPageUrl(urlPattern: RegExp, description: string): Promise { + await this.waiter.waitForCondition( + async () => urlPattern.test(this.page.url()), + description, + { timeout: config.navigationTimeout } + ); + } + + /** + * Check if element exists without throwing + */ + protected async elementExists( + locator: Locator | string, + timeout: number = 3000, + state: 'visible' | 'attached' | 'detached' | 'hidden' = 'visible' + ): Promise { + try { + const element = typeof locator === 'string' ? this.page.locator(locator) : locator; + await element.waitFor({ state, timeout }); + return true; + } catch (error) { + this.logger.debug(`Element not found in expected state '${state}': ${typeof locator === 'string' ? locator : 'locator'}`, error instanceof Error ? error : undefined); + return false; + } + } + + /** + * Clean up any lingering modals or dialogs using semantic locators + */ + async cleanupModals(): Promise { + try { + const modalCloseButton = this.page.getByRole('button', { name: /close|dismiss|cancel/i }); + if (await this.elementExists(modalCloseButton, 1000)) { + await this.smartClick(modalCloseButton, 'Close modal dialog'); + this.logger.debug('Cleaned up lingering modal'); + } + } catch (error) { + // Ignore cleanup errors - they're not critical + this.logger.debug('Modal cleanup completed (no modals found)'); + } + } + + /** + * Execute operation with performance timing + */ + protected async withTiming( + operation: () => Promise, + operationName: string + ): Promise { + const startTime = Date.now(); + + try { + const result = await operation(); + const duration = Date.now() - startTime; + + logger.performance(operationName, duration, { page: this.pageName }); + + return result; + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error(`${operationName} failed after ${duration}ms`, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Abstract method for page-specific verification + */ + protected abstract verifyPageLoaded(): Promise; + + /** + * Navigate to this page and verify it loaded + */ + async goto(): Promise { + await this.withTiming( + async () => { + await this.navigateToPath(this.getPagePath()); + await this.verifyPageLoaded(); + }, + `Navigate to ${this.pageName}` + ); + } + + /** + * Abstract method to get the page path + */ + protected abstract getPagePath(): string; +} \ No newline at end of file diff --git a/e2e/src/pages/FoundryHomePage.ts b/e2e/src/pages/FoundryHomePage.ts new file mode 100644 index 0000000..772f838 --- /dev/null +++ b/e2e/src/pages/FoundryHomePage.ts @@ -0,0 +1,33 @@ +import { Page, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; + +export class FoundryHomePage extends BasePage { + constructor(page: Page) { + super(page, 'FoundryHomePage'); + } + + protected getPagePath(): string { + return '/foundry/home'; + } + + protected async verifyPageLoaded(): Promise { + await expect(this.page).toHaveTitle('Home | Foundry | Falcon'); + } + + async verifyLoaded(): Promise { + await this.verifyPageLoaded(); + this.logger.success('Foundry home page loaded successfully'); + } + + async navigateToAppManager(): Promise { + this.logger.step('Navigate to App manager'); + + await this.smartClick( + this.page.getByRole('link', { name: 'App manager' }), + 'App manager link' + ); + + await expect(this.page).toHaveTitle('App manager | Foundry | Falcon'); + this.logger.success('Navigated to App manager'); + } +} \ No newline at end of file diff --git a/e2e/src/pages/HelloExtensionPage.ts b/e2e/src/pages/HelloExtensionPage.ts new file mode 100644 index 0000000..055977b --- /dev/null +++ b/e2e/src/pages/HelloExtensionPage.ts @@ -0,0 +1,78 @@ +import { Page, expect, FrameLocator } from '@playwright/test'; +import { SocketNavigationPage } from './SocketNavigationPage'; + +/** + * Page object for testing the "hello" UI extension + * Extension appears in activity.detections.details socket + */ +export class HelloExtensionPage extends SocketNavigationPage { + constructor(page: Page) { + super(page); + } + + async navigateToExtension(): Promise { + return this.withTiming( + async () => { + // Navigate to endpoint detections (activity.detections.details socket) + await this.navigateToEndpointDetections(); + + // Open first detection to show details panel with extensions + await this.openFirstDetection(); + + // Wait for detection details panel + await this.page.waitForLoadState('networkidle'); + + this.logger.success('Navigated to detection with hello extension'); + }, + 'Navigate to Hello Extension' + ); + } + + async verifyExtensionRenders(): Promise { + return this.withTiming( + async () => { + this.logger.info('Verifying hello extension renders'); + + // Wait for detection details panel to load + await this.page.waitForLoadState('networkidle'); + + // Extensions in detection details are expandable buttons at the bottom + // Just look for a button named "hello" (it may or may not have aria-expanded) + const extensionButton = this.page.getByRole('button', { name: 'hello', exact: true }); + + // Scroll the button into view if needed + await extensionButton.scrollIntoViewIfNeeded({ timeout: 10000 }); + this.logger.info('Scrolled to hello extension button'); + + // Wait for button to be visible + await expect(extensionButton).toBeVisible({ timeout: 10000 }); + this.logger.info('Found hello extension button'); + + // Check if already expanded, if not click to expand + const isExpanded = await extensionButton.getAttribute('aria-expanded'); + if (isExpanded === 'false') { + await extensionButton.click(); + this.logger.info('Clicked to expand hello extension'); + } else { + this.logger.info('hello extension already expanded'); + } + + // Verify iframe loads + await expect(this.page.locator('iframe')).toBeVisible({ timeout: 15000 }); + this.logger.info('Extension iframe loaded'); + + // Verify iframe content + const iframe: FrameLocator = this.page.frameLocator('iframe'); + + // Check for "Foundry Functions Demo" text + await expect(iframe.getByText(/Foundry Functions Demo/i)).toBeVisible({ timeout: 10000 }); + + // Check for Hello greeting - use .first() to handle multiple matches + await expect(iframe.getByText(/Hello.*@/i).first()).toBeVisible(); + + this.logger.success('hello extension renders correctly with expected content'); + }, + 'Verify hello extension renders' + ); + } +} diff --git a/e2e/src/pages/HostManagementPage.ts b/e2e/src/pages/HostManagementPage.ts new file mode 100644 index 0000000..bc86d44 --- /dev/null +++ b/e2e/src/pages/HostManagementPage.ts @@ -0,0 +1,121 @@ +import { Page, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; + +/** + * Page object for Host Management + * + * Used to retrieve host IDs for workflow testing + */ +export class HostManagementPage extends BasePage { + constructor(page: Page) { + super(page, 'Host Management'); + } + + protected getPagePath(): string { + return '/hosts/hosts'; + } + + protected async verifyPageLoaded(): Promise { + // Check for either "Host management" heading or "Host setup and management" + const heading = this.page.getByRole('heading', { name: /host.*management/i }).first(); + await expect(heading).toBeVisible({ timeout: 10000 }); + this.logger.success('Host management page loaded'); + } + + /** + * Navigate to host management page + */ + async navigateToHostManagement(): Promise { + return this.withTiming( + async () => { + await this.navigateToPath(this.getPagePath(), 'Host management page'); + await this.verifyPageLoaded(); + }, + 'Navigate to Host Management' + ); + } + + /** + * Get the first available host ID from the host list + * Returns null if no hosts are found + */ + async getFirstHostId(): Promise { + return this.withTiming( + async () => { + this.logger.info('Retrieving first host ID from host management'); + + await this.navigateToHostManagement(); + + // Wait for host table to load + await this.page.waitForLoadState('networkidle'); + + // Wait for the hostname column to appear + await this.page.getByText('Hostname').first().waitFor({ state: 'visible', timeout: 10000 }); + + // Look for any text content matching the 32-character hex ID pattern + try { + // Use evaluate to search the DOM for text matching host ID pattern + const hostId = await this.page.evaluate(() => { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const pattern = /^[a-f0-9]{32}$/i; + + let node; + while (node = walker.nextNode()) { + const text = node.textContent?.trim() || ''; + if (pattern.test(text)) { + return text; + } + } + return null; + }); + + if (hostId) { + this.logger.success(`Found host ID: ${hostId}`); + return hostId; + } + + this.logger.warn('No valid host ID found on page'); + return null; + } catch (error) { + this.logger.warn(`Failed to find host ID: ${error.message}`); + this.logger.info('This may indicate no hosts are available in the CID'); + return null; + } + }, + 'Get first host ID' + ); + } + + /** + * Check if any hosts exist in the CID + */ + async hasHosts(): Promise { + return this.withTiming( + async () => { + await this.navigateToHostManagement(); + + // Check for "no hosts" message or empty table + const noHostsMessage = this.page.getByText(/no hosts found|no data/i); + const hasNoHostsMessage = await noHostsMessage.isVisible({ timeout: 3000 }).catch(() => false); + + if (hasNoHostsMessage) { + this.logger.info('No hosts found in CID'); + return false; + } + + // Check if table has rows + const hostRows = this.page.locator('tbody tr'); + const rowCount = await hostRows.count(); + + if (rowCount > 0) { + this.logger.success(`Found ${rowCount} host(s) in CID`); + return true; + } else { + this.logger.info('No hosts found in CID'); + return false; + } + }, + 'Check if hosts exist' + ); + } +} diff --git a/e2e/src/pages/SocketNavigationPage.ts b/e2e/src/pages/SocketNavigationPage.ts new file mode 100644 index 0000000..5565fa1 --- /dev/null +++ b/e2e/src/pages/SocketNavigationPage.ts @@ -0,0 +1,189 @@ +import { Page, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; + +/** + * Utility page object for navigating to detection pages with socket extensions + * + * Uses menu-based navigation to ensure reliability when URLs change. + * + * Supports testing Foundry extensions that appear in detection sockets: + * - activity.detections.details (Endpoint Detections) + * - xdr.detections.panel (XDR Detections) + * - ngsiem.workbench.details (NGSIEM Incidents) + */ +export class SocketNavigationPage extends BasePage { + constructor(page: Page) { + super(page, 'Socket Navigation'); + } + + protected getPagePath(): string { + throw new Error('Socket navigation does not have a direct path - use menu navigation'); + } + + protected async verifyPageLoaded(): Promise { + } + + /** + * Navigate to Endpoint Detections page (activity.detections.details socket) + * Uses menu navigation: Menu → Endpoint security → Monitor → Endpoint detections + */ + async navigateToEndpointDetections(): Promise { + return this.withTiming( + async () => { + this.logger.info('Navigating to Endpoint Detections page'); + + // Navigate to Foundry home first to ensure menu is available + await this.navigateToPath('/foundry/home', 'Foundry home'); + await this.page.waitForLoadState('networkidle'); + + // Open the hamburger menu + const menuButton = this.page.getByTestId('nav-trigger'); + await menuButton.click(); + await this.page.waitForLoadState('networkidle'); + + // Click "Endpoint security" + const endpointSecurityButton = this.page.getByRole('button', { name: /Endpoint security/i }); + await endpointSecurityButton.click(); + await this.waiter.delay(500); + + // Click "Monitor" to expand submenu (if not already expanded) + const monitorButton = this.page.getByRole('button', { name: /^Monitor$/i }); + const isExpanded = await monitorButton.getAttribute('aria-expanded'); + if (isExpanded !== 'true') { + await monitorButton.click(); + await this.waiter.delay(500); + } + + // Click "Endpoint detections" link + const endpointDetectionsLink = this.page.getByRole('link', { name: /Endpoint detections/i }); + await endpointDetectionsLink.click(); + + // Wait for page to load + await this.page.waitForLoadState('networkidle'); + + // Verify we're on the detections page by looking for the page heading + const pageTitle = this.page.locator('h1, h2').filter({ hasText: /Detections/i }).first(); + await expect(pageTitle).toBeVisible({ timeout: 10000 }); + + this.logger.success('Navigated to Endpoint Detections page'); + }, + 'Navigate to Endpoint Detections' + ); + } + + /** + * Navigate to XDR Detections page (xdr.detections.panel socket) + * Uses menu navigation: Menu → Next-Gen SIEM → appropriate submenu → XDR detections + * Note: Requires XDR SKU - may not be available in all environments + */ + async navigateToXDRDetections(): Promise { + return this.withTiming( + async () => { + this.logger.info('Navigating to XDR Detections page (Incidents)'); + + // Navigate to Foundry home first to ensure menu is available + await this.navigateToPath('/foundry/home', 'Foundry home'); + await this.page.waitForLoadState('networkidle'); + + // Open the hamburger menu + const menuButton = this.page.getByTestId('nav-trigger'); + await menuButton.click(); + await this.page.waitForLoadState('networkidle'); + + // Click "Next-Gen SIEM" in the menu (not the home page card) + const ngsiemButton = this.page.getByTestId('popout-button').filter({ hasText: /Next-Gen SIEM/i }); + await ngsiemButton.click(); + await this.waiter.delay(500); + + // Click "Incidents" - use section-link selector to avoid the learn card + + const incidentsLink = this.page.getByTestId('section-link').filter({ hasText: /Incidents/i }); + await incidentsLink.click(); + + await this.page.waitForLoadState('networkidle'); + + const pageTitle = this.page.locator('h1, [role="heading"]').first(); + await expect(pageTitle).toBeVisible({ timeout: 10000 }); + + this.logger.success('Navigated to XDR Detections page (Incidents)'); + }, + 'Navigate to XDR Detections' + ); + } + + /** + * Navigate to NGSIEM Incidents page (ngsiem.workbench.details socket) + * Uses menu navigation: Menu → Next-Gen SIEM → Incidents + */ + async navigateToNGSIEMIncidents(): Promise { + return this.withTiming( + async () => { + this.logger.info('Navigating to NGSIEM Incidents page'); + + // Navigate to Foundry home first to ensure menu is available + await this.navigateToPath('/foundry/home', 'Foundry home'); + await this.page.waitForLoadState('networkidle'); + + // Open the hamburger menu + const menuButton = this.page.getByTestId('nav-trigger'); + await menuButton.click(); + await this.page.waitForLoadState('networkidle'); + + // Click "Next-Gen SIEM" in the menu (not the home page card) + const ngsiemButton = this.page.getByTestId('popout-button').filter({ hasText: /Next-Gen SIEM/i }); + await ngsiemButton.click(); + await this.waiter.delay(500); + + // Click "Incidents" - use section-link selector to avoid the learn card + const incidentsLink = this.page.getByTestId('section-link').filter({ hasText: /Incidents/i }); + await incidentsLink.click(); + + await this.page.waitForLoadState('networkidle'); + + const pageTitle = this.page.locator('h1, [role="heading"]').first(); + await expect(pageTitle).toBeVisible({ timeout: 10000 }); + + this.logger.success('Navigated to NGSIEM Incidents page'); + }, + 'Navigate to NGSIEM Incidents' + ); + } + + async openFirstDetection(): Promise { + return this.withTiming( + async () => { + await this.page.waitForLoadState('networkidle'); + + // In the new Endpoint Detections UI, detections are represented as buttons in the table + // Look for process/host information buttons + const firstDetectionButton = this.page.locator('[role="gridcell"] button').first(); + await firstDetectionButton.waitFor({ state: 'visible', timeout: 10000 }); + await firstDetectionButton.click(); + + // Wait for detection details to load + await this.page.waitForLoadState('networkidle'); + }, + 'Open first detection' + ); + } + + async verifyExtensionInSocket(extensionName: string): Promise { + return this.withTiming( + async () => { + const extension = this.page.getByRole('tab', { name: new RegExp(extensionName, 'i') }); + await expect(extension).toBeVisible({ timeout: 10000 }); + }, + `Verify extension "${extensionName}" in socket` + ); + } + + async clickExtensionTab(extensionName: string): Promise { + return this.withTiming( + async () => { + const extension = this.page.getByRole('tab', { name: new RegExp(extensionName, 'i') }); + await extension.click({ force: true }); + }, + `Click extension tab "${extensionName}"` + ); + } +} diff --git a/e2e/src/pages/WorkflowsPage.ts b/e2e/src/pages/WorkflowsPage.ts new file mode 100644 index 0000000..ac3579d --- /dev/null +++ b/e2e/src/pages/WorkflowsPage.ts @@ -0,0 +1,248 @@ +import { Page, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; + +/** + * Page object for Workflow testing + * + * Supports both workflow rendering verification and execution with inputs + */ +export class WorkflowsPage extends BasePage { + constructor(page: Page) { + super(page, 'Workflows'); + } + + protected getPagePath(): string { + return '/workflow/fusion'; + } + + protected async verifyPageLoaded(): Promise { + await expect(this.page.getByRole('heading', { name: /Workflow/i })).toBeVisible({ timeout: 10000 }); + this.logger.success('Workflows page loaded'); + } + + /** + * Navigate to workflows page via Fusion SOAR menu + */ + async navigateToWorkflows(): Promise { + return this.withTiming( + async () => { + this.logger.info('Navigating to Fusion SOAR Workflows'); + + // Navigate to home first + await this.navigateToPath('/foundry/home', 'Foundry Home'); + + // Open hamburger menu + const menuButton = this.page.getByTestId('nav-trigger'); + await menuButton.click(); + await this.page.waitForLoadState('networkidle'); + + // Click Fusion SOAR in the navigation menu (not the home page cards) + const navigation = this.page.locator('nav, [role="navigation"]'); + const fusionSoarButton = navigation.getByRole('button', { name: 'Fusion SOAR' }); + await fusionSoarButton.click(); + await this.page.waitForTimeout(500); + + // Click Workflows link + const workflowsLink = this.page.getByRole('link', { name: 'Workflows' }); + await workflowsLink.click(); + + // Wait for workflows page to load + await this.page.waitForLoadState('networkidle'); + await this.verifyPageLoaded(); + }, + 'Navigate to Workflows' + ); + } + + /** + * Search for a specific workflow by name + */ + async searchWorkflow(workflowName: string): Promise { + return this.withTiming( + async () => { + this.logger.info(`Searching for workflow: ${workflowName}`); + + // Click the "Search workflows" button to open search + const searchButton = this.page.getByRole('button', { name: /search workflows/i }); + await searchButton.click(); + + // Now the search input should appear + const searchBox = this.page.getByRole('searchbox') + .or(this.page.locator('input[type="search"]')) + .or(this.page.locator('input[placeholder*="Search"]')); + + await searchBox.fill(workflowName); + await this.page.keyboard.press('Enter'); + await this.page.waitForLoadState('networkidle'); + + this.logger.success(`Searched for workflow: ${workflowName}`); + }, + `Search for workflow: ${workflowName}` + ); + } + + /** + * Verify a workflow appears in the list + */ + async verifyWorkflowExists(workflowName: string): Promise { + return this.withTiming( + async () => { + this.logger.info(`Verifying workflow exists: ${workflowName}`); + + // Search for the workflow first + await this.searchWorkflow(workflowName); + + // Look for the workflow link in the results + const workflowLink = this.page.getByRole('link', { name: new RegExp(workflowName, 'i') }); + + try { + await expect(workflowLink).toBeVisible({ timeout: 5000 }); + this.logger.success(`Workflow found: ${workflowName}`); + } catch (error) { + this.logger.error(`Workflow not found: ${workflowName}`); + throw error; + } + }, + `Verify workflow exists: ${workflowName}` + ); + } + + /** + * Open a workflow to view its details + */ + async openWorkflow(workflowName: string): Promise { + return this.withTiming( + async () => { + this.logger.info(`Opening workflow: ${workflowName}`); + + // Look for the workflow link directly in the table + const workflowLink = this.page.getByRole('link', { name: new RegExp(workflowName, 'i') }).first(); + await workflowLink.click(); + + // Wait for workflow details to load + await this.page.waitForLoadState('networkidle'); + + this.logger.success(`Opened workflow: ${workflowName}`); + }, + `Open workflow: ${workflowName}` + ); + } + + /** + * Verify workflow renders (shows the workflow canvas/details) + */ + async verifyWorkflowRenders(workflowName: string): Promise { + return this.withTiming( + async () => { + this.logger.info(`Verifying workflow renders: ${workflowName}`); + + await this.openWorkflow(workflowName); + + // Check for workflow canvas or details view + // Workflows typically show a canvas with nodes or a details panel + const hasCanvas = await this.page.locator('[class*="workflow"], [class*="canvas"], [class*="flow"]').isVisible({ timeout: 5000 }).catch(() => false); + + if (hasCanvas) { + this.logger.success(`Workflow renders correctly: ${workflowName}`); + } else { + this.logger.warn(`Workflow page loaded but canvas not detected: ${workflowName}`); + this.logger.info('This is acceptable for E2E - workflow exists and loads'); + } + }, + `Verify workflow renders: ${workflowName}` + ); + } + + /** + * Execute a workflow with optional input parameters + */ + async executeWorkflow(workflowName: string, inputs?: Record): Promise { + return this.withTiming( + async () => { + this.logger.info(`Executing workflow: ${workflowName}`); + + // Open the workflow + await this.openWorkflow(workflowName); + + // Click "Open menu" button + const openMenuButton = this.page.getByRole('button', { name: /open menu/i }); + await openMenuButton.click(); + + // Click "Execute workflow" option + const executeOption = this.page.getByRole('menuitem', { name: /execute workflow/i }); + await executeOption.click(); + + // Wait for execution modal to appear + await expect(this.page.getByRole('heading', { name: /execute on demand workflow/i })).toBeVisible({ timeout: 5000 }); + this.logger.info('Execution modal opened'); + + // Fill in input parameters if provided + if (inputs && Object.keys(inputs).length > 0) { + this.logger.info(`Filling in ${Object.keys(inputs).length} input parameter(s)`); + for (const [key, value] of Object.entries(inputs)) { + // Look for input field by label or placeholder + const inputField = this.page.getByLabel(new RegExp(key, 'i')) + .or(this.page.getByPlaceholder(new RegExp(key, 'i'))) + .or(this.page.locator(`input[name*="${key}"]`)); + + await inputField.fill(value); + this.logger.info(`Set ${key} = ${value}`); + } + } + + // Click "Execute now" button + const executeButton = this.page.getByRole('button', { name: /execute now/i }); + await executeButton.click(); + + // Wait for execution confirmation + await expect(this.page.getByText(/workflow execution triggered/i)).toBeVisible({ timeout: 10000 }); + this.logger.success(`Workflow execution triggered: ${workflowName}`); + }, + `Execute workflow: ${workflowName}` + ); + } + + /** + * Verify workflow execution completed successfully + * This checks the execution notification or navigates to execution log + */ + async verifyWorkflowExecutionSuccess(workflowName: string): Promise { + return this.withTiming( + async () => { + this.logger.info(`Verifying workflow execution succeeded: ${workflowName}`); + + // Check for the execution triggered notification + const notification = this.page.getByText(/workflow execution triggered/i); + + try { + await expect(notification).toBeVisible({ timeout: 5000 }); + this.logger.success(`Workflow execution confirmed: ${workflowName}`); + + // Optional: Click "View" link to see execution details + const viewLink = this.page.getByRole('link', { name: /^view$/i }); + if (await viewLink.isVisible({ timeout: 2000 })) { + this.logger.info('Execution details view link available'); + } + } catch (error) { + this.logger.error(`Failed to verify workflow execution: ${error.message}`); + throw error; + } + }, + `Verify workflow execution success: ${workflowName}` + ); + } + + /** + * Execute workflow and verify it completes successfully + * Combines executeWorkflow and verifyWorkflowExecutionSuccess + */ + async executeAndVerifyWorkflow(workflowName: string, inputs?: Record): Promise { + return this.withTiming( + async () => { + await this.executeWorkflow(workflowName, inputs); + await this.verifyWorkflowExecutionSuccess(workflowName); + }, + `Execute and verify workflow: ${workflowName}` + ); + } +} diff --git a/e2e/src/utils.cjs b/e2e/src/utils.cjs new file mode 100644 index 0000000..7f2593d --- /dev/null +++ b/e2e/src/utils.cjs @@ -0,0 +1,43 @@ +'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 new file mode 100644 index 0000000..02271dd --- /dev/null +++ b/e2e/src/utils/Logger.ts @@ -0,0 +1,192 @@ +/** + * Structured logging service for E2E tests + * Provides consistent, searchable, and actionable logging + */ +export interface LogContext { + page?: string; + action?: string; + element?: string; + timeout?: number; + attempt?: number; + [key: string]: any; +} + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'step'; + +export class Logger { + private static _instance: Logger; + private readonly isCI: boolean; + private readonly isDebugMode: boolean; + private stepCounter = 0; + + private constructor() { + this.isCI = !!process.env.CI; + this.isDebugMode = process.env.DEBUG === 'true'; + } + + public static getInstance(): Logger { + if (!Logger._instance) { + Logger._instance = new Logger(); + } + return Logger._instance; + } + + /** + * Log a test step with clear visual indication + */ + step(page: string, action: string, context: LogContext = {}): void { + this.stepCounter++; + const emoji = this.getStepEmoji(action); + const message = `${emoji} [${this.stepCounter}] ${page}: ${action}`; + + this.log('step', message, { page, action, ...context }); + } + + /** + * Log successful operations + */ + success(message: string, context: LogContext = {}): void { + this.log('info', `✅ ${message}`, context); + } + + /** + * Log warnings (non-blocking issues) + */ + warn(message: string, context: LogContext = {}): void { + this.log('warn', `âš ī¸ ${message}`, context); + } + + /** + * Log errors (blocking issues) + */ + error(message: string, error?: Error, context: LogContext = {}): void { + const errorDetails = error ? ` - ${error.message}` : ''; + this.log('error', `❌ ${message}${errorDetails}`, { + ...context, + stack: error?.stack + }); + } + + /** + * Log debug information (only in debug mode) + */ + debug(message: string, context: LogContext = {}): void { + if (this.isDebugMode) { + this.log('debug', `🔍 DEBUG: ${message}`, context); + } + } + + /** + * Log informational messages + */ + info(message: string, context: LogContext = {}): void { + this.log('info', `â„šī¸ ${message}`, context); + } + + /** + * Log performance metrics + */ + performance(operation: string, duration: number, context: LogContext = {}): void { + const formattedDuration = duration > 1000 + ? `${(duration / 1000).toFixed(2)}s` + : `${duration}ms`; + + this.log('info', `⚡ ${operation} completed in ${formattedDuration}`, { + ...context, + duration, + performance: true + }); + } + + /** + * Log retry attempts + */ + retry(operation: string, attempt: number, maxAttempts: number, error?: Error): void { + const message = `🔄 Retry ${attempt}/${maxAttempts}: ${operation}`; + const level = attempt === maxAttempts ? 'error' : 'warn'; + + this.log(level, message, { + operation, + attempt, + maxAttempts, + isLastAttempt: attempt === maxAttempts, + error: error?.message + }); + } + + /** + * Log test summary information + */ + summary(title: string, items: string[]): void { + this.log('info', `📊 ${title}:`); + items.forEach(item => { + this.log('info', ` ${item}`); + }); + } + + /** + * Create a scoped logger for a specific page + */ + forPage(pageName: string) { + return { + step: (action: string, context: LogContext = {}) => + this.step(pageName, action, context), + success: (message: string, context: LogContext = {}) => + this.success(message, { ...context, page: pageName }), + warn: (message: string, context: LogContext = {}) => + this.warn(message, { ...context, page: pageName }), + error: (message: string, error?: Error, context: LogContext = {}) => + this.error(message, error, { ...context, page: pageName }), + debug: (message: string, context: LogContext = {}) => + this.debug(message, { ...context, page: pageName }), + info: (message: string, context: LogContext = {}) => + this.info(message, { ...context, page: pageName }), + }; + } + + private log(level: LogLevel, message: string, context: LogContext = {}): void { + const timestamp = new Date().toISOString(); + + // In CI, be much less verbose with plain text output + if (this.isCI) { + // Only log errors, warnings, and final test results in CI + if (level === 'error' || + (level === 'warn' && !message.includes('App page loaded but no content detected')) || + (level === 'info' && ( + message.includes('✅ Test passed') || + message.includes('❌ Test failed') || + message.includes('E2E Test Config:') + ))) { + // Use plain text in CI for better readability + console.log(message); + } + // Completely suppress 'step' level in CI + } else { + // In local development, use human-readable format + console.log(message); + + // Log context details in debug mode + if (this.isDebugMode && Object.keys(context).length > 0) { + console.log(' Context:', JSON.stringify(context, null, 2)); + } + } + } + + private getStepEmoji(action: string): string { + const actionLower = action.toLowerCase(); + + if (actionLower.includes('navigate') || actionLower.includes('goto')) return '🧭'; + if (actionLower.includes('click')) return '👆'; + if (actionLower.includes('type') || actionLower.includes('fill')) return 'âŒ¨ī¸'; + if (actionLower.includes('wait') || actionLower.includes('loading')) return 'âŗ'; + if (actionLower.includes('verify') || actionLower.includes('check')) return '🔍'; + if (actionLower.includes('install') || actionLower.includes('deploy')) return 'đŸ“Ļ'; + if (actionLower.includes('screenshot')) return '📸'; + if (actionLower.includes('menu') || actionLower.includes('button')) return '🔘'; + + return '🔧'; // Default for other actions + } +} + +// Singleton instance export +export const logger = Logger.getInstance(); \ No newline at end of file diff --git a/e2e/src/utils/SmartWaiter.ts b/e2e/src/utils/SmartWaiter.ts new file mode 100644 index 0000000..08c6dee --- /dev/null +++ b/e2e/src/utils/SmartWaiter.ts @@ -0,0 +1,212 @@ +import { Page, Locator, expect } from '@playwright/test'; +import { logger } from './Logger'; +import { config } from '../config/TestConfig'; + +/** + * Waiting and retry utilities + * Eliminates hard-coded timeouts with intelligent waiting strategies + */ + +export interface WaitOptions { + timeout?: number; + retries?: number; + retryDelay?: number; + description?: string; +} + +export interface RetryOptions { + maxAttempts?: number; + delay?: number; + backoff?: 'linear' | 'exponential'; + shouldRetry?: (error: Error) => boolean; +} + +export class SmartWaiter { + constructor(private page: Page, private pageName: string = 'Unknown') {} + + /** + * Wait for an element to be visible with smart retry logic + */ + async waitForVisible( + locator: Locator | string, + options: WaitOptions = {} + ): Promise { + const actualLocator = typeof locator === 'string' + ? this.page.locator(locator) + : locator; + + const { timeout = config.navigationTimeout, description } = options; + const elementDesc = description || 'element'; + + logger.debug(`Waiting for ${elementDesc} to be visible`, { + page: this.pageName, + timeout, + selector: typeof locator === 'string' ? locator : 'locator' + }); + + await actualLocator.waitFor({ + state: 'visible', + timeout + }); + + return actualLocator; + } + + /** + * Wait for page to be fully loaded with network idle + */ + async waitForPageLoad(description: string = 'page load'): Promise { + logger.debug(`Waiting for ${description}`, { page: this.pageName }); + + await Promise.all([ + this.page.waitForLoadState('networkidle'), + this.page.waitForLoadState('domcontentloaded') + ]); + } + + /** + * Wait for a condition to be true with custom polling + */ + async waitForCondition( + condition: () => Promise, + description: string, + options: WaitOptions = {} + ): Promise { + const { timeout = config.defaultTimeout, retryDelay = 500 } = options; + + logger.debug(`Waiting for condition: ${description}`, { + page: this.pageName, + timeout + }); + + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + if (await condition()) { + return; + } + } catch (error) { + // Continue polling on errors + } + + await this.page.waitForTimeout(retryDelay); + } + + throw new Error(`Timeout waiting for condition: ${description} after ${timeout}ms`); + } + + /** + * Smart wait for navigation menu to expand + */ + async waitForMenuExpansion(): Promise { + await this.waitForCondition( + async () => { + const expandedMenus = await this.page.locator('[expanded], [aria-expanded="true"]').count(); + return expandedMenus > 0; + }, + 'navigation menu to expand', + { timeout: 5000 } + ); + } + + /** + * Smart wait for app installation status + */ + async waitForAppInstallationStatus(appName: string, expectedStatus: 'installed' | 'not-installed'): Promise { + await this.waitForCondition( + async () => { + const statusElements = await this.page.locator(`text=${appName}`).locator('../..').locator('text=Installed').count(); + const isInstalled = statusElements > 0; + return expectedStatus === 'installed' ? isInstalled : !isInstalled; + }, + `app ${appName} to be ${expectedStatus}`, + { timeout: 60000 } // App operations can take time + ); + } + + /** + * Delay execution + */ + async delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +export class RetryHandler { + /** + * Execute an operation with exponential backoff retry + */ + static async withRetry( + operation: () => Promise, + operationName: string, + options: RetryOptions = {} + ): Promise { + const { + maxAttempts = config.retryAttempts, + delay = config.getRetryConfig().delay, + backoff = 'exponential', + shouldRetry = () => true + } = options; + + let lastError: Error; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const result = await operation(); + + if (attempt > 1) { + logger.success(`${operationName} succeeded on attempt ${attempt}`); + } + + return result; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt === maxAttempts || !shouldRetry(lastError)) { + logger.error(`${operationName} failed after ${attempt} attempts`, lastError); + throw lastError; + } + + const currentDelay = backoff === 'exponential' + ? delay * Math.pow(2, attempt - 1) + : delay; + + logger.retry(operationName, attempt, maxAttempts, lastError); + + await new Promise(resolve => setTimeout(resolve, currentDelay)); + } + } + + throw lastError!; + } + + /** + * Retry specifically for Playwright operations + */ + static async withPlaywrightRetry( + operation: () => Promise, + operationName: string, + options: RetryOptions = {} + ): Promise { + return this.withRetry( + operation, + operationName, + { + ...options, + shouldRetry: (error) => { + // Don't retry on assertion errors - these are test failures + if (error.message.includes('expect(')) { + return false; + } + + // Retry on timeout and network errors + return error.message.includes('timeout') || + error.message.includes('waiting for') || + error.message.includes('not found') || + (options.shouldRetry ? options.shouldRetry(error) : true); + } + } + ); + } +} \ No newline at end of file diff --git a/e2e/tests/app-install.setup.ts b/e2e/tests/app-install.setup.ts new file mode 100644 index 0000000..1aeafab --- /dev/null +++ b/e2e/tests/app-install.setup.ts @@ -0,0 +1,17 @@ +import { test as setup } from '../src/fixtures'; + +setup('install Functions with Python app', async ({ appCatalogPage, appName }) => { + // Check if app is already installed (this navigates to the app page) + const isInstalled = await appCatalogPage.isAppInstalled(appName); + + if (!isInstalled) { + console.log(`App '${appName}' is not installed. Installing...`); + const installed = await appCatalogPage.installApp(appName); + + if (!installed) { + throw new Error(`Failed to install app '${appName}'`); + } + } else { + console.log(`App '${appName}' is already installed`); + } +}); diff --git a/e2e/tests/app-uninstall.teardown.ts b/e2e/tests/app-uninstall.teardown.ts new file mode 100644 index 0000000..bba888c --- /dev/null +++ b/e2e/tests/app-uninstall.teardown.ts @@ -0,0 +1,7 @@ +import { test as teardown } from '../src/fixtures'; + +teardown('uninstall Functions with Python app', async ({ appCatalogPage, appName }) => { + // Clean up by uninstalling the app after all tests complete + await appCatalogPage.navigateToPath('/foundry/app-catalog', 'App Catalog'); + await appCatalogPage.uninstallApp(appName); +}); \ No newline at end of file diff --git a/e2e/tests/authenticate.setup.ts b/e2e/tests/authenticate.setup.ts new file mode 100644 index 0000000..ac55314 --- /dev/null +++ b/e2e/tests/authenticate.setup.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..71a3d3b --- /dev/null +++ b/e2e/tests/foundry.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '../src/fixtures'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Functions with Python - E2E Tests', () => { + test('should render Hello UI extension', async ({ helloExtensionPage }) => { + await helloExtensionPage.navigateToExtension(); + await helloExtensionPage.verifyExtensionRenders(); + }); + + test('should execute Test hello function workflow', async ({ workflowsPage }) => { + await workflowsPage.navigateToWorkflows(); + await workflowsPage.executeAndVerifyWorkflow('Test hello function'); + }); + + test('should execute Test log-event function workflow', async ({ workflowsPage }) => { + await workflowsPage.navigateToWorkflows(); + await workflowsPage.executeAndVerifyWorkflow('Test log-event function'); + }); + + test('should execute Test host-details function workflow', async ({ workflowsPage, hostManagementPage }) => { + // Get first available host ID + const hostId = await hostManagementPage.getFirstHostId(); + + if (!hostId) { + test.skip(true, 'Skipping: No hosts available in CID'); + return; + } + + // Execute workflow with host ID parameter + await workflowsPage.navigateToWorkflows(); + await workflowsPage.executeAndVerifyWorkflow('Test host-details function', { + 'Host ID': hostId + }); + }); + + test('should render Test servicenow function workflow (without execution)', async ({ workflowsPage }) => { + await workflowsPage.navigateToWorkflows(); + await workflowsPage.verifyWorkflowRenders('Test servicenow function'); + }); +}); diff --git a/manifest.yml b/manifest.yml index f5c4ce6..b8c5895 100644 --- a/manifest.yml +++ b/manifest.yml @@ -11,6 +11,8 @@ ignored: - .+/node_modules/.+ - .+/venv$ - .+/venv/.+ + - e2e + - e2e/.+ ui: homepage: "" extensions: