Skip to content

Commit ecc00a7

Browse files
committed
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).
1 parent b153cc3 commit ecc00a7

10 files changed

Lines changed: 505 additions & 0 deletions

tests/e2e/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules/
2+
test-results/
3+
playwright-report/
4+
blob-report/
5+
.playwright/

tests/e2e/helpers/auth.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Shared authentication helper for Caldera UI tests.
3+
*
4+
* Caldera's default credentials are admin:admin. Override via env vars
5+
* CALDERA_USER / CALDERA_PASS if the instance uses something else.
6+
*/
7+
const CALDERA_USER = process.env.CALDERA_USER || "admin";
8+
const CALDERA_PASS = process.env.CALDERA_PASS || "admin";
9+
10+
/**
11+
* Log into Caldera through the login page.
12+
* After this resolves the page is authenticated and ready.
13+
*/
14+
async function login(page) {
15+
await page.goto("/");
16+
17+
// If we are already past the login screen, nothing to do.
18+
if (page.url().includes("/login") || (await page.locator('input[name="username"], input#username').count()) > 0) {
19+
await page.locator('input[name="username"], input#username').first().fill(CALDERA_USER);
20+
await page.locator('input[name="password"], input#password').first().fill(CALDERA_PASS);
21+
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign")').first().click();
22+
// Wait for navigation away from login
23+
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 15_000 });
24+
}
25+
}
26+
27+
module.exports = { login, CALDERA_USER, CALDERA_PASS };

tests/e2e/helpers/navigation.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Navigation helpers for reaching plugin tabs inside Caldera / Magma.
3+
*/
4+
5+
/**
6+
* Navigate to the Atomic plugin tab in the Magma Vue app.
7+
*/
8+
async function navigateToAtomic(page) {
9+
const navItem = page.locator(
10+
'a:has-text("Atomic"), .nav-item:has-text("Atomic"), [data-test="nav-atomic"], button:has-text("Atomic")'
11+
).first();
12+
await navItem.waitFor({ state: "visible", timeout: 15_000 });
13+
await navItem.click();
14+
15+
// Wait for the atomic page content
16+
await page.locator("h2:has-text('Atomic'), .content:has-text('Atomic')").first().waitFor({
17+
state: "visible",
18+
timeout: 15_000,
19+
});
20+
}
21+
22+
module.exports = { navigateToAtomic };

tests/e2e/package-lock.json

Lines changed: 78 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/e2e/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "atomic-e2e-tests",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "Playwright E2E tests for the CALDERA Atomic plugin",
6+
"scripts": {
7+
"test": "npx playwright test",
8+
"test:headed": "npx playwright test --headed",
9+
"test:debug": "npx playwright test --debug",
10+
"test:report": "npx playwright show-report"
11+
},
12+
"devDependencies": {
13+
"@playwright/test": "^1.52.0"
14+
}
15+
}

tests/e2e/playwright.config.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// @ts-check
2+
const { defineConfig, devices } = require("@playwright/test");
3+
4+
const CALDERA_URL = process.env.CALDERA_URL || "http://localhost:8888";
5+
6+
module.exports = defineConfig({
7+
testDir: "./specs",
8+
fullyParallel: false,
9+
forbidOnly: !!process.env.CI,
10+
retries: process.env.CI ? 2 : 0,
11+
workers: 1,
12+
reporter: [["html", { open: "never" }], ["list"]],
13+
timeout: 60_000,
14+
expect: { timeout: 15_000 },
15+
16+
use: {
17+
baseURL: CALDERA_URL,
18+
trace: "on-first-retry",
19+
screenshot: "only-on-failure",
20+
actionTimeout: 10_000,
21+
navigationTimeout: 30_000,
22+
ignoreHTTPSErrors: true,
23+
},
24+
25+
projects: [
26+
{
27+
name: "chromium",
28+
use: { ...devices["Desktop Chrome"] },
29+
},
30+
],
31+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// @ts-check
2+
const { test, expect } = require("@playwright/test");
3+
const { login } = require("../helpers/auth");
4+
const { navigateToAtomic } = require("../helpers/navigation");
5+
6+
test.describe("Atomic plugin - abilities display", () => {
7+
test.beforeEach(async ({ page }) => {
8+
await login(page);
9+
await navigateToAtomic(page);
10+
});
11+
12+
test("should display the abilities count (numeric or placeholder)", async ({ page }) => {
13+
// The Vue template shows {{ atomicAbilities.length || "---" }}
14+
const countText = page.locator(".is-size-1, h1.is-size-1").first();
15+
await expect(countText).toBeVisible({ timeout: 15_000 });
16+
const text = await countText.textContent();
17+
// Should be either a number or "---"
18+
expect(text?.trim()).toMatch(/^(\d+|---)$/);
19+
});
20+
21+
test("should display the 'abilities' label under the count", async ({ page }) => {
22+
const label = page.locator("p:has-text('abilities')").first();
23+
await expect(label).toBeVisible({ timeout: 15_000 });
24+
});
25+
26+
test("should show the View Abilities button", async ({ page }) => {
27+
const viewBtn = page.locator(
28+
'a:has-text("View Abilities"), button:has-text("View Abilities"), .button:has-text("View Abilities")'
29+
).first();
30+
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
31+
});
32+
33+
test("the View Abilities button should link to the abilities page with atomic filter", async ({ page }) => {
34+
const viewBtn = page.locator(
35+
'a:has-text("View Abilities"), .button:has-text("View Abilities")'
36+
).first();
37+
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
38+
39+
// The router-link should resolve to /abilities?plugin=atomic
40+
const href = await viewBtn.getAttribute("href");
41+
if (href) {
42+
expect(href).toContain("abilities");
43+
expect(href).toContain("atomic");
44+
}
45+
// If it's a router-link, the href might be generated dynamically
46+
// Just check the button text and that it's clickable
47+
await expect(viewBtn).toBeEnabled();
48+
});
49+
50+
test("clicking View Abilities should navigate to the abilities page", async ({ page }) => {
51+
const viewBtn = page.locator(
52+
'a:has-text("View Abilities"), .button:has-text("View Abilities")'
53+
).first();
54+
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
55+
await viewBtn.click();
56+
57+
// Should navigate to abilities page
58+
await page.waitForURL(/abilities/, { timeout: 15_000 });
59+
});
60+
61+
test("abilities count should update to a number after data loads", async ({ page }) => {
62+
const countText = page.locator(".is-size-1, h1.is-size-1").first();
63+
await expect(countText).toBeVisible({ timeout: 15_000 });
64+
65+
// Wait for count to potentially become a number (may stay --- if no abilities)
66+
await page.waitForTimeout(5_000);
67+
const text = await countText.textContent();
68+
// Should be either a valid number or "---" (if no atomic abilities ingested)
69+
expect(text?.trim()).toMatch(/^(\d+|---)$/);
70+
});
71+
72+
test("the card should be properly centered on the page", async ({ page }) => {
73+
const container = page.locator(
74+
".is-flex.is-align-items-center.is-justify-content-center"
75+
).first();
76+
await expect(container).toBeVisible({ timeout: 15_000 });
77+
});
78+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// @ts-check
2+
const { test, expect } = require("@playwright/test");
3+
const { login } = require("../helpers/auth");
4+
const { navigateToAtomic } = require("../helpers/navigation");
5+
6+
test.describe("Atomic plugin - error states", () => {
7+
test.beforeEach(async ({ page }) => {
8+
await login(page);
9+
});
10+
11+
test("should show placeholder count when abilities API fails", async ({ page }) => {
12+
// Intercept abilities API to simulate failure
13+
await page.route("**/api/v2/abilities", (route) => {
14+
route.fulfill({
15+
status: 500,
16+
contentType: "application/json",
17+
body: JSON.stringify({ error: "Internal Server Error" }),
18+
});
19+
});
20+
21+
await navigateToAtomic(page);
22+
23+
// The count should show "---" since no abilities loaded
24+
const countText = page.locator(".is-size-1, h1.is-size-1").first();
25+
await expect(countText).toBeVisible({ timeout: 15_000 });
26+
const text = await countText.textContent();
27+
expect(text?.trim()).toBe("---");
28+
});
29+
30+
test("page should remain functional when abilities API returns empty array", async ({ page }) => {
31+
await page.route("**/api/v2/abilities", (route) => {
32+
route.fulfill({
33+
status: 200,
34+
contentType: "application/json",
35+
body: JSON.stringify([]),
36+
});
37+
});
38+
39+
await navigateToAtomic(page);
40+
41+
// Count should show "---" for zero abilities
42+
const countText = page.locator(".is-size-1, h1.is-size-1").first();
43+
await expect(countText).toBeVisible({ timeout: 15_000 });
44+
const text = await countText.textContent();
45+
expect(text?.trim()).toBe("---");
46+
47+
// Page heading should still be visible
48+
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible();
49+
});
50+
51+
test("page should remain functional when adversaries API fails", async ({ page }) => {
52+
await page.route("**/api/v2/adversaries", (route) => {
53+
route.fulfill({
54+
status: 500,
55+
contentType: "application/json",
56+
body: JSON.stringify({ error: "Internal Server Error" }),
57+
});
58+
});
59+
60+
await navigateToAtomic(page);
61+
62+
// The page heading and description should still render
63+
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible();
64+
await expect(page.locator("p:has-text('Red Canary Atomic')").first()).toBeVisible();
65+
});
66+
67+
test("View Abilities button should still be present when no abilities loaded", async ({ page }) => {
68+
await page.route("**/api/v2/abilities", (route) => {
69+
route.fulfill({
70+
status: 200,
71+
contentType: "application/json",
72+
body: JSON.stringify([]),
73+
});
74+
});
75+
76+
await navigateToAtomic(page);
77+
78+
const viewBtn = page.locator(
79+
'a:has-text("View Abilities"), .button:has-text("View Abilities")'
80+
).first();
81+
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
82+
});
83+
84+
test("page should handle slow API responses gracefully", async ({ page }) => {
85+
// Simulate slow response
86+
await page.route("**/api/v2/abilities", async (route) => {
87+
await new Promise((r) => setTimeout(r, 5_000));
88+
route.fulfill({
89+
status: 200,
90+
contentType: "application/json",
91+
body: JSON.stringify([]),
92+
});
93+
});
94+
95+
await navigateToAtomic(page);
96+
97+
// While loading, the count should show "---"
98+
const countText = page.locator(".is-size-1, h1.is-size-1").first();
99+
await expect(countText).toBeVisible({ timeout: 15_000 });
100+
const initialText = await countText.textContent();
101+
expect(initialText?.trim()).toBe("---");
102+
103+
// Heading should be visible during loading
104+
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible();
105+
});
106+
107+
test("page should not crash with malformed API response", async ({ page }) => {
108+
await page.route("**/api/v2/abilities", (route) => {
109+
route.fulfill({
110+
status: 200,
111+
contentType: "application/json",
112+
body: "not valid json",
113+
});
114+
});
115+
116+
await navigateToAtomic(page);
117+
118+
// Page should still show the heading even if parsing fails
119+
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible({ timeout: 15_000 });
120+
});
121+
});

0 commit comments

Comments
 (0)