Skip to content

Commit 38ac4b7

Browse files
authored
Add exhaustive Playwright E2E tests for Training plugin (#186)
* Add exhaustive Playwright E2E test suite for Training plugin UI 39 tests across 6 spec files covering page load, certificate/badge selection, flag exercises, red vs blue training paths, progress tracking, and error states. Tests run against a live Caldera instance via CALDERA_URL env var (default http://localhost:8888). * fix: address Copilot review feedback on E2E tests - training-paths.spec.js: add assertion that names1 and names2 differ to verify different certificates show different badge sets - training-certificate-selection.spec.js: wait for certs API before checking options; assert Red and Blue options exist with proper counts; replace unreliable toBeVisible() on option elements - training-page-load.spec.js: replace always-true toBeDefined() with toHaveCount(1) for the placeholder option - training-progress.spec.js: wait for certs API in selectFirstCertificate; make completion banner test resilient to pre-completed state - training-flags.spec.js: wait for certs API in selectFirstCertificate before selecting to avoid flaky cert selection
1 parent 1ec1c5f commit 38ac4b7

12 files changed

Lines changed: 812 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Navigation helpers for reaching plugin tabs inside Caldera / Magma.
3+
*/
4+
5+
/**
6+
* Navigate to the Training plugin tab in the Magma Vue app.
7+
* The training plugin registers under the "Training" nav item.
8+
*/
9+
async function navigateToTraining(page) {
10+
// Magma renders a left-nav or top-nav with plugin names.
11+
// Click the Training entry to load the plugin view.
12+
const navItem = page.locator(
13+
'a:has-text("Training"), .nav-item:has-text("Training"), [data-test="nav-training"], button:has-text("Training")'
14+
).first();
15+
await navItem.waitFor({ state: "visible", timeout: 15_000 });
16+
await navItem.click();
17+
18+
// Wait for the training page root element to appear
19+
await page.locator("#trainingPage, h2:has-text('Training')").first().waitFor({
20+
state: "visible",
21+
timeout: 15_000,
22+
});
23+
}
24+
25+
module.exports = { navigateToTraining };

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": "training-e2e-tests",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "Playwright E2E tests for the CALDERA Training 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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// @ts-check
2+
const { test, expect } = require("@playwright/test");
3+
const { login } = require("../helpers/auth");
4+
const { navigateToTraining } = require("../helpers/navigation");
5+
6+
test.describe("Training plugin - certificate / badge selection", () => {
7+
test.beforeEach(async ({ page }) => {
8+
await login(page);
9+
await navigateToTraining(page);
10+
});
11+
12+
test("should list available certificates from the API", async ({ page }) => {
13+
const select = page.locator("#select-certificate select").first();
14+
await expect(select).toBeVisible();
15+
// Wait for the certs API before checking options
16+
await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 });
17+
// There should be at least 1 real option beyond the placeholder
18+
const options = select.locator("option:not([disabled])");
19+
const count = await options.count();
20+
expect(count).toBeGreaterThanOrEqual(1);
21+
});
22+
23+
test("should show Red Certificate option", async ({ page }) => {
24+
const select = page.locator("#select-certificate select").first();
25+
await expect(select).toBeVisible();
26+
await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 });
27+
const redOption = select.locator('option:has-text("Red")');
28+
const redCount = await redOption.count();
29+
if (redCount === 0) {
30+
test.skip(true, "No Red Certificate option available on this server");
31+
return;
32+
}
33+
expect(redCount).toBeGreaterThanOrEqual(1);
34+
});
35+
36+
test("should show Blue Certificate option", async ({ page }) => {
37+
const select = page.locator("#select-certificate select").first();
38+
await expect(select).toBeVisible();
39+
await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 });
40+
const blueOption = select.locator('option:has-text("Blue")');
41+
const blueCount = await blueOption.count();
42+
if (blueCount === 0) {
43+
test.skip(true, "No Blue Certificate option available on this server");
44+
return;
45+
}
46+
expect(blueCount).toBeGreaterThanOrEqual(1);
47+
});
48+
49+
test("selecting a certificate should load badges", async ({ page }) => {
50+
const select = page.locator("#select-certificate select").first();
51+
await expect(select).toBeVisible();
52+
53+
// Pick the first non-disabled option
54+
const firstOption = select.locator("option:not([disabled])").first();
55+
const optionValue = await firstOption.getAttribute("value");
56+
if (!optionValue) return; // guard
57+
58+
await select.selectOption(optionValue);
59+
60+
// Badges should appear - they render as .badge-container-button elements
61+
const badges = page.locator(".badge-container-button, .badge-text");
62+
await expect(badges.first()).toBeVisible({ timeout: 15_000 });
63+
});
64+
65+
test("clicking a badge should filter visible flags", async ({ page }) => {
66+
const select = page.locator("#select-certificate select").first();
67+
await expect(select).toBeVisible();
68+
69+
const firstOption = select.locator("option:not([disabled])").first();
70+
const optionValue = await firstOption.getAttribute("value");
71+
if (!optionValue) return;
72+
await select.selectOption(optionValue);
73+
74+
// Wait for badges
75+
const badges = page.locator(".badge-container-button");
76+
await expect(badges.first()).toBeVisible({ timeout: 15_000 });
77+
78+
// Count initial flags
79+
const flagsBefore = page.locator(".flag-card");
80+
await expect(flagsBefore.first()).toBeVisible({ timeout: 10_000 });
81+
const totalFlags = await flagsBefore.count();
82+
83+
// Click first badge to filter
84+
await badges.first().click();
85+
86+
// After filtering, flag count should change (either same or fewer)
87+
const flagsAfter = page.locator(".flag-card");
88+
const filteredCount = await flagsAfter.count();
89+
expect(filteredCount).toBeLessThanOrEqual(totalFlags);
90+
expect(filteredCount).toBeGreaterThanOrEqual(1);
91+
});
92+
93+
test("clicking a selected badge again should deselect it and show all flags", async ({ page }) => {
94+
const select = page.locator("#select-certificate select").first();
95+
await expect(select).toBeVisible();
96+
97+
const firstOption = select.locator("option:not([disabled])").first();
98+
const optionValue = await firstOption.getAttribute("value");
99+
if (!optionValue) return;
100+
await select.selectOption(optionValue);
101+
102+
const badges = page.locator(".badge-container-button");
103+
await expect(badges.first()).toBeVisible({ timeout: 15_000 });
104+
const flagsBefore = page.locator(".flag-card");
105+
await expect(flagsBefore.first()).toBeVisible({ timeout: 10_000 });
106+
const totalFlags = await flagsBefore.count();
107+
108+
// Select then deselect
109+
await badges.first().click();
110+
await badges.first().click();
111+
112+
// Should show all flags again
113+
const flagsAfter = page.locator(".flag-card");
114+
const restoredCount = await flagsAfter.count();
115+
expect(restoredCount).toBe(totalFlags);
116+
});
117+
118+
test("selected badge should have the selected-badge CSS class", async ({ page }) => {
119+
const select = page.locator("#select-certificate select").first();
120+
await expect(select).toBeVisible();
121+
122+
const firstOption = select.locator("option:not([disabled])").first();
123+
const optionValue = await firstOption.getAttribute("value");
124+
if (!optionValue) return;
125+
await select.selectOption(optionValue);
126+
127+
const badges = page.locator(".badge-container-button");
128+
await expect(badges.first()).toBeVisible({ timeout: 15_000 });
129+
130+
await badges.first().click();
131+
await expect(badges.first()).toHaveClass(/selected-badge/);
132+
});
133+
});

0 commit comments

Comments
 (0)