From ecc00a74082700dc236fc99b5b89f7ac7b172888 Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 10:52:41 -0400 Subject: [PATCH 1/2] Add exhaustive Playwright E2E test suite for Atomic plugin UI 22 tests across 4 spec files covering page load, abilities display and count, ability ingestion verification, and error states. Tests run against a live Caldera instance via CALDERA_URL env var (default http://localhost:8888). --- tests/e2e/.gitignore | 5 + tests/e2e/helpers/auth.js | 27 ++++ tests/e2e/helpers/navigation.js | 22 ++++ tests/e2e/package-lock.json | 78 +++++++++++ tests/e2e/package.json | 15 +++ tests/e2e/playwright.config.js | 31 +++++ .../specs/atomic-abilities-display.spec.js | 78 +++++++++++ tests/e2e/specs/atomic-error-states.spec.js | 121 ++++++++++++++++++ tests/e2e/specs/atomic-ingestion.spec.js | 88 +++++++++++++ tests/e2e/specs/atomic-page-load.spec.js | 40 ++++++ 10 files changed, 505 insertions(+) create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/helpers/auth.js create mode 100644 tests/e2e/helpers/navigation.js create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.js create mode 100644 tests/e2e/specs/atomic-abilities-display.spec.js create mode 100644 tests/e2e/specs/atomic-error-states.spec.js create mode 100644 tests/e2e/specs/atomic-ingestion.spec.js create mode 100644 tests/e2e/specs/atomic-page-load.spec.js diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..a4fcaaf --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +.playwright/ diff --git a/tests/e2e/helpers/auth.js b/tests/e2e/helpers/auth.js new file mode 100644 index 0000000..bb37437 --- /dev/null +++ b/tests/e2e/helpers/auth.js @@ -0,0 +1,27 @@ +/** + * Shared authentication helper for Caldera UI tests. + * + * Caldera's default credentials are admin:admin. Override via env vars + * CALDERA_USER / CALDERA_PASS if the instance uses something else. + */ +const CALDERA_USER = process.env.CALDERA_USER || "admin"; +const CALDERA_PASS = process.env.CALDERA_PASS || "admin"; + +/** + * Log into Caldera through the login page. + * After this resolves the page is authenticated and ready. + */ +async function login(page) { + await page.goto("/"); + + // If we are already past the login screen, nothing to do. + if (page.url().includes("/login") || (await page.locator('input[name="username"], input#username').count()) > 0) { + await page.locator('input[name="username"], input#username').first().fill(CALDERA_USER); + await page.locator('input[name="password"], input#password').first().fill(CALDERA_PASS); + await page.locator('button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign")').first().click(); + // Wait for navigation away from login + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 15_000 }); + } +} + +module.exports = { login, CALDERA_USER, CALDERA_PASS }; diff --git a/tests/e2e/helpers/navigation.js b/tests/e2e/helpers/navigation.js new file mode 100644 index 0000000..cdf16e1 --- /dev/null +++ b/tests/e2e/helpers/navigation.js @@ -0,0 +1,22 @@ +/** + * Navigation helpers for reaching plugin tabs inside Caldera / Magma. + */ + +/** + * Navigate to the Atomic plugin tab in the Magma Vue app. + */ +async function navigateToAtomic(page) { + const navItem = page.locator( + 'a:has-text("Atomic"), .nav-item:has-text("Atomic"), [data-test="nav-atomic"], button:has-text("Atomic")' + ).first(); + await navItem.waitFor({ state: "visible", timeout: 15_000 }); + await navItem.click(); + + // Wait for the atomic page content + await page.locator("h2:has-text('Atomic'), .content:has-text('Atomic')").first().waitFor({ + state: "visible", + timeout: 15_000, + }); +} + +module.exports = { navigateToAtomic }; diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..4597210 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "atomic-e2e-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atomic-e2e-tests", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.52.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..3e15444 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "atomic-e2e-tests", + "version": "1.0.0", + "private": true, + "description": "Playwright E2E tests for the CALDERA Atomic plugin", + "scripts": { + "test": "npx playwright test", + "test:headed": "npx playwright test --headed", + "test:debug": "npx playwright test --debug", + "test:report": "npx playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.52.0" + } +} diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000..d7ca0a8 --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,31 @@ +// @ts-check +const { defineConfig, devices } = require("@playwright/test"); + +const CALDERA_URL = process.env.CALDERA_URL || "http://localhost:8888"; + +module.exports = defineConfig({ + testDir: "./specs", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [["html", { open: "never" }], ["list"]], + timeout: 60_000, + expect: { timeout: 15_000 }, + + use: { + baseURL: CALDERA_URL, + trace: "on-first-retry", + screenshot: "only-on-failure", + actionTimeout: 10_000, + navigationTimeout: 30_000, + ignoreHTTPSErrors: true, + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e/specs/atomic-abilities-display.spec.js b/tests/e2e/specs/atomic-abilities-display.spec.js new file mode 100644 index 0000000..62b3bd0 --- /dev/null +++ b/tests/e2e/specs/atomic-abilities-display.spec.js @@ -0,0 +1,78 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToAtomic } = require("../helpers/navigation"); + +test.describe("Atomic plugin - abilities display", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await navigateToAtomic(page); + }); + + test("should display the abilities count (numeric or placeholder)", async ({ page }) => { + // The Vue template shows {{ atomicAbilities.length || "---" }} + const countText = page.locator(".is-size-1, h1.is-size-1").first(); + await expect(countText).toBeVisible({ timeout: 15_000 }); + const text = await countText.textContent(); + // Should be either a number or "---" + expect(text?.trim()).toMatch(/^(\d+|---)$/); + }); + + test("should display the 'abilities' label under the count", async ({ page }) => { + const label = page.locator("p:has-text('abilities')").first(); + await expect(label).toBeVisible({ timeout: 15_000 }); + }); + + test("should show the View Abilities button", async ({ page }) => { + const viewBtn = page.locator( + 'a:has-text("View Abilities"), button:has-text("View Abilities"), .button:has-text("View Abilities")' + ).first(); + await expect(viewBtn).toBeVisible({ timeout: 15_000 }); + }); + + test("the View Abilities button should link to the abilities page with atomic filter", async ({ page }) => { + const viewBtn = page.locator( + 'a:has-text("View Abilities"), .button:has-text("View Abilities")' + ).first(); + await expect(viewBtn).toBeVisible({ timeout: 15_000 }); + + // The router-link should resolve to /abilities?plugin=atomic + const href = await viewBtn.getAttribute("href"); + if (href) { + expect(href).toContain("abilities"); + expect(href).toContain("atomic"); + } + // If it's a router-link, the href might be generated dynamically + // Just check the button text and that it's clickable + await expect(viewBtn).toBeEnabled(); + }); + + test("clicking View Abilities should navigate to the abilities page", async ({ page }) => { + const viewBtn = page.locator( + 'a:has-text("View Abilities"), .button:has-text("View Abilities")' + ).first(); + await expect(viewBtn).toBeVisible({ timeout: 15_000 }); + await viewBtn.click(); + + // Should navigate to abilities page + await page.waitForURL(/abilities/, { timeout: 15_000 }); + }); + + test("abilities count should update to a number after data loads", async ({ page }) => { + const countText = page.locator(".is-size-1, h1.is-size-1").first(); + await expect(countText).toBeVisible({ timeout: 15_000 }); + + // Wait for count to potentially become a number (may stay --- if no abilities) + await page.waitForTimeout(5_000); + const text = await countText.textContent(); + // Should be either a valid number or "---" (if no atomic abilities ingested) + expect(text?.trim()).toMatch(/^(\d+|---)$/); + }); + + test("the card should be properly centered on the page", async ({ page }) => { + const container = page.locator( + ".is-flex.is-align-items-center.is-justify-content-center" + ).first(); + await expect(container).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/specs/atomic-error-states.spec.js b/tests/e2e/specs/atomic-error-states.spec.js new file mode 100644 index 0000000..a9a04e6 --- /dev/null +++ b/tests/e2e/specs/atomic-error-states.spec.js @@ -0,0 +1,121 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToAtomic } = require("../helpers/navigation"); + +test.describe("Atomic plugin - error states", () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test("should show placeholder count when abilities API fails", async ({ page }) => { + // Intercept abilities API to simulate failure + await page.route("**/api/v2/abilities", (route) => { + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Internal Server Error" }), + }); + }); + + await navigateToAtomic(page); + + // The count should show "---" since no abilities loaded + const countText = page.locator(".is-size-1, h1.is-size-1").first(); + await expect(countText).toBeVisible({ timeout: 15_000 }); + const text = await countText.textContent(); + expect(text?.trim()).toBe("---"); + }); + + test("page should remain functional when abilities API returns empty array", async ({ page }) => { + await page.route("**/api/v2/abilities", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await navigateToAtomic(page); + + // Count should show "---" for zero abilities + const countText = page.locator(".is-size-1, h1.is-size-1").first(); + await expect(countText).toBeVisible({ timeout: 15_000 }); + const text = await countText.textContent(); + expect(text?.trim()).toBe("---"); + + // Page heading should still be visible + await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible(); + }); + + test("page should remain functional when adversaries API fails", async ({ page }) => { + await page.route("**/api/v2/adversaries", (route) => { + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Internal Server Error" }), + }); + }); + + await navigateToAtomic(page); + + // The page heading and description should still render + await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible(); + await expect(page.locator("p:has-text('Red Canary Atomic')").first()).toBeVisible(); + }); + + test("View Abilities button should still be present when no abilities loaded", async ({ page }) => { + await page.route("**/api/v2/abilities", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await navigateToAtomic(page); + + const viewBtn = page.locator( + 'a:has-text("View Abilities"), .button:has-text("View Abilities")' + ).first(); + await expect(viewBtn).toBeVisible({ timeout: 15_000 }); + }); + + test("page should handle slow API responses gracefully", async ({ page }) => { + // Simulate slow response + await page.route("**/api/v2/abilities", async (route) => { + await new Promise((r) => setTimeout(r, 5_000)); + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await navigateToAtomic(page); + + // While loading, the count should show "---" + const countText = page.locator(".is-size-1, h1.is-size-1").first(); + await expect(countText).toBeVisible({ timeout: 15_000 }); + const initialText = await countText.textContent(); + expect(initialText?.trim()).toBe("---"); + + // Heading should be visible during loading + await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible(); + }); + + test("page should not crash with malformed API response", async ({ page }) => { + await page.route("**/api/v2/abilities", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: "not valid json", + }); + }); + + await navigateToAtomic(page); + + // Page should still show the heading even if parsing fails + await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/tests/e2e/specs/atomic-ingestion.spec.js b/tests/e2e/specs/atomic-ingestion.spec.js new file mode 100644 index 0000000..a7fdc3a --- /dev/null +++ b/tests/e2e/specs/atomic-ingestion.spec.js @@ -0,0 +1,88 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToAtomic } = require("../helpers/navigation"); + +test.describe("Atomic plugin - ability ingestion", () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test("atomic abilities should be filtered from the global abilities store", async ({ page }) => { + await navigateToAtomic(page); + + // The atomic page makes API calls to /api/v2/abilities + // Intercept to verify the call is made + const apiCalled = page.waitForResponse( + (resp) => resp.url().includes("/api/v2/abilities") && resp.status() === 200, + { timeout: 20_000 } + ).catch(() => null); + + // Reload to trigger the API call fresh + await page.reload(); + await navigateToAtomic(page); + + const response = await apiCalled; + if (response) { + expect(response.status()).toBe(200); + } + }); + + test("the abilities API response should contain ability objects", async ({ page }) => { + // Intercept abilities API + let abilitiesData = null; + await page.route("**/api/v2/abilities", async (route) => { + const response = await route.fetch(); + abilitiesData = await response.json(); + await route.fulfill({ response }); + }); + + await navigateToAtomic(page); + await page.waitForTimeout(5_000); + + // If we got abilities data, verify structure + if (abilitiesData && Array.isArray(abilitiesData)) { + // Each ability should have standard fields + if (abilitiesData.length > 0) { + const first = abilitiesData[0]; + expect(first).toHaveProperty("ability_id"); + expect(first).toHaveProperty("name"); + } + } + }); + + test("atomic abilities should have plugin field set to 'atomic'", async ({ page }) => { + let abilitiesData = null; + await page.route("**/api/v2/abilities", async (route) => { + const response = await route.fetch(); + abilitiesData = await response.json(); + await route.fulfill({ response }); + }); + + await navigateToAtomic(page); + await page.waitForTimeout(5_000); + + if (abilitiesData && Array.isArray(abilitiesData)) { + const atomicOnes = abilitiesData.filter((a) => a.plugin === "atomic"); + // The count on the page should match the filtered count + const countText = await page.locator(".is-size-1, h1.is-size-1").first().textContent(); + const displayedCount = parseInt(countText?.trim() || "0", 10); + if (!isNaN(displayedCount) && displayedCount > 0) { + expect(atomicOnes.length).toBe(displayedCount); + } + } + }); + + test("adversaries API should also be called on page mount", async ({ page }) => { + const apiCalled = page.waitForResponse( + (resp) => resp.url().includes("/api/v2/adversaries") && resp.status() === 200, + { timeout: 20_000 } + ).catch(() => null); + + await navigateToAtomic(page); + const response = await apiCalled; + if (response) { + expect(response.status()).toBe(200); + } + }); +}); diff --git a/tests/e2e/specs/atomic-page-load.spec.js b/tests/e2e/specs/atomic-page-load.spec.js new file mode 100644 index 0000000..fec8516 --- /dev/null +++ b/tests/e2e/specs/atomic-page-load.spec.js @@ -0,0 +1,40 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToAtomic } = require("../helpers/navigation"); + +test.describe("Atomic plugin - page load", () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + test("should load the Caldera UI successfully", async ({ page }) => { + await expect(page).not.toHaveURL(/\/login/); + }); + + test("should display the Atomic navigation item", async ({ page }) => { + const navItem = page.locator( + 'a:has-text("Atomic"), .nav-item:has-text("Atomic"), [data-test="nav-atomic"], button:has-text("Atomic")' + ).first(); + await expect(navItem).toBeVisible({ timeout: 15_000 }); + }); + + test("should navigate to the Atomic tab and display the heading", async ({ page }) => { + await navigateToAtomic(page); + await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible(); + }); + + test("should display the plugin description text", async ({ page }) => { + await navigateToAtomic(page); + await expect( + page.locator("p:has-text('Red Canary Atomic')").first() + ).toBeVisible(); + }); + + test("should display the abilities count card", async ({ page }) => { + await navigateToAtomic(page); + // The card shows the count of atomic abilities or "---" while loading + const countCard = page.locator(".card, .is-flex .card").first(); + await expect(countCard).toBeVisible({ timeout: 15_000 }); + }); +}); From 2801c3630c80e4aa37d68beb25b440e050c856a9 Mon Sep 17 00:00:00 2001 From: deacon Date: Wed, 18 Mar 2026 09:10:17 -0400 Subject: [PATCH 2/2] fix: address Copilot review feedback on e2e test files atomic-error-states.spec.js: - Add return before all route.fulfill() calls (6 instances) so the promise is properly returned to Playwright's route handler machinery atomic-ingestion.spec.js: - Remove .catch(() => null) from waitForResponse calls so timeouts propagate as test failures rather than silently passing - Replace page.waitForTimeout(5000) with waitForResponse to reliably detect when the abilities API has been called - Assert abilitiesData is an array and non-empty, and verify structure of the first ability object - Assert displayedCount is a valid integer and matches the API result count rather than silently skipping when data is missing atomic-abilities-display.spec.js: - Replace waitForTimeout(5000) with condition-based wait using expect(countText).not.toHaveText('---') so the test waits for actual DOM change rather than an arbitrary fixed delay --- .../specs/atomic-abilities-display.spec.js | 7 +- tests/e2e/specs/atomic-error-states.spec.js | 12 ++-- tests/e2e/specs/atomic-ingestion.spec.js | 69 +++++++++---------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/e2e/specs/atomic-abilities-display.spec.js b/tests/e2e/specs/atomic-abilities-display.spec.js index 62b3bd0..9814c80 100644 --- a/tests/e2e/specs/atomic-abilities-display.spec.js +++ b/tests/e2e/specs/atomic-abilities-display.spec.js @@ -62,8 +62,11 @@ test.describe("Atomic plugin - abilities display", () => { const countText = page.locator(".is-size-1, h1.is-size-1").first(); await expect(countText).toBeVisible({ timeout: 15_000 }); - // Wait for count to potentially become a number (may stay --- if no abilities) - await page.waitForTimeout(5_000); + // Wait until count text changes from the initial "---" placeholder to a number, + // or confirms it stays "---" if no atomic abilities are ingested. + await expect(countText).not.toHaveText("---", { timeout: 15_000 }).catch(() => { + // It's acceptable for the count to remain "---" when no abilities are ingested. + }); const text = await countText.textContent(); // Should be either a valid number or "---" (if no atomic abilities ingested) expect(text?.trim()).toMatch(/^(\d+|---)$/); diff --git a/tests/e2e/specs/atomic-error-states.spec.js b/tests/e2e/specs/atomic-error-states.spec.js index a9a04e6..425c4ab 100644 --- a/tests/e2e/specs/atomic-error-states.spec.js +++ b/tests/e2e/specs/atomic-error-states.spec.js @@ -11,7 +11,7 @@ test.describe("Atomic plugin - error states", () => { test("should show placeholder count when abilities API fails", async ({ page }) => { // Intercept abilities API to simulate failure await page.route("**/api/v2/abilities", (route) => { - route.fulfill({ + return route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Internal Server Error" }), @@ -29,7 +29,7 @@ test.describe("Atomic plugin - error states", () => { test("page should remain functional when abilities API returns empty array", async ({ page }) => { await page.route("**/api/v2/abilities", (route) => { - route.fulfill({ + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]), @@ -50,7 +50,7 @@ test.describe("Atomic plugin - error states", () => { test("page should remain functional when adversaries API fails", async ({ page }) => { await page.route("**/api/v2/adversaries", (route) => { - route.fulfill({ + return route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Internal Server Error" }), @@ -66,7 +66,7 @@ test.describe("Atomic plugin - error states", () => { test("View Abilities button should still be present when no abilities loaded", async ({ page }) => { await page.route("**/api/v2/abilities", (route) => { - route.fulfill({ + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]), @@ -85,7 +85,7 @@ test.describe("Atomic plugin - error states", () => { // Simulate slow response await page.route("**/api/v2/abilities", async (route) => { await new Promise((r) => setTimeout(r, 5_000)); - route.fulfill({ + return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]), @@ -106,7 +106,7 @@ test.describe("Atomic plugin - error states", () => { test("page should not crash with malformed API response", async ({ page }) => { await page.route("**/api/v2/abilities", (route) => { - route.fulfill({ + return route.fulfill({ status: 200, contentType: "application/json", body: "not valid json", diff --git a/tests/e2e/specs/atomic-ingestion.spec.js b/tests/e2e/specs/atomic-ingestion.spec.js index a7fdc3a..5278d9a 100644 --- a/tests/e2e/specs/atomic-ingestion.spec.js +++ b/tests/e2e/specs/atomic-ingestion.spec.js @@ -9,23 +9,16 @@ test.describe("Atomic plugin - ability ingestion", () => { }); test("atomic abilities should be filtered from the global abilities store", async ({ page }) => { - await navigateToAtomic(page); - - // The atomic page makes API calls to /api/v2/abilities - // Intercept to verify the call is made + // Set up response interception before navigating to ensure we catch the call const apiCalled = page.waitForResponse( (resp) => resp.url().includes("/api/v2/abilities") && resp.status() === 200, { timeout: 20_000 } - ).catch(() => null); + ); - // Reload to trigger the API call fresh - await page.reload(); await navigateToAtomic(page); const response = await apiCalled; - if (response) { - expect(response.status()).toBe(200); - } + expect(response.status()).toBe(200); }); test("the abilities API response should contain ability objects", async ({ page }) => { @@ -34,21 +27,23 @@ test.describe("Atomic plugin - ability ingestion", () => { await page.route("**/api/v2/abilities", async (route) => { const response = await route.fetch(); abilitiesData = await response.json(); - await route.fulfill({ response }); + return route.fulfill({ response }); }); + // Wait for the abilities API response after navigation + const apiResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/v2/abilities") && resp.status() === 200, + { timeout: 20_000 } + ); await navigateToAtomic(page); - await page.waitForTimeout(5_000); + await apiResponse; - // If we got abilities data, verify structure - if (abilitiesData && Array.isArray(abilitiesData)) { - // Each ability should have standard fields - if (abilitiesData.length > 0) { - const first = abilitiesData[0]; - expect(first).toHaveProperty("ability_id"); - expect(first).toHaveProperty("name"); - } - } + // Verify the response is a non-empty array with standard ability fields + expect(Array.isArray(abilitiesData)).toBe(true); + expect(abilitiesData.length).toBeGreaterThan(0); + const first = abilitiesData[0]; + expect(first).toHaveProperty("ability_id"); + expect(first).toHaveProperty("name"); }); test("atomic abilities should have plugin field set to 'atomic'", async ({ page }) => { @@ -56,33 +51,35 @@ test.describe("Atomic plugin - ability ingestion", () => { await page.route("**/api/v2/abilities", async (route) => { const response = await route.fetch(); abilitiesData = await response.json(); - await route.fulfill({ response }); + return route.fulfill({ response }); }); + // Wait for the abilities API response after navigation + const apiResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/v2/abilities") && resp.status() === 200, + { timeout: 20_000 } + ); await navigateToAtomic(page); - await page.waitForTimeout(5_000); + await apiResponse; - if (abilitiesData && Array.isArray(abilitiesData)) { - const atomicOnes = abilitiesData.filter((a) => a.plugin === "atomic"); - // The count on the page should match the filtered count - const countText = await page.locator(".is-size-1, h1.is-size-1").first().textContent(); - const displayedCount = parseInt(countText?.trim() || "0", 10); - if (!isNaN(displayedCount) && displayedCount > 0) { - expect(atomicOnes.length).toBe(displayedCount); - } - } + expect(Array.isArray(abilitiesData)).toBe(true); + const atomicOnes = abilitiesData.filter((a) => a.plugin === "atomic"); + // The count on the page should match the filtered count + const countText = await page.locator(".is-size-1, h1.is-size-1").first().textContent(); + const displayedCount = parseInt(countText?.trim() || "", 10); + // Assert the displayed count is a valid number and matches the API array length + expect(Number.isInteger(displayedCount)).toBe(true); + expect(atomicOnes.length).toBe(displayedCount); }); test("adversaries API should also be called on page mount", async ({ page }) => { const apiCalled = page.waitForResponse( (resp) => resp.url().includes("/api/v2/adversaries") && resp.status() === 200, { timeout: 20_000 } - ).catch(() => null); + ); await navigateToAtomic(page); const response = await apiCalled; - if (response) { - expect(response.status()).toBe(200); - } + expect(response.status()).toBe(200); }); });