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..c0660ed --- /dev/null +++ b/tests/e2e/helpers/navigation.js @@ -0,0 +1,25 @@ +/** + * Navigation helpers for reaching plugin tabs inside Caldera / Magma. + */ + +/** + * Navigate to the Training plugin tab in the Magma Vue app. + * The training plugin registers under the "Training" nav item. + */ +async function navigateToTraining(page) { + // Magma renders a left-nav or top-nav with plugin names. + // Click the Training entry to load the plugin view. + const navItem = page.locator( + 'a:has-text("Training"), .nav-item:has-text("Training"), [data-test="nav-training"], button:has-text("Training")' + ).first(); + await navItem.waitFor({ state: "visible", timeout: 15_000 }); + await navItem.click(); + + // Wait for the training page root element to appear + await page.locator("#trainingPage, h2:has-text('Training')").first().waitFor({ + state: "visible", + timeout: 15_000, + }); +} + +module.exports = { navigateToTraining }; diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..9dfd35f --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "training-e2e-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "training-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..22a74f9 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "training-e2e-tests", + "version": "1.0.0", + "private": true, + "description": "Playwright E2E tests for the CALDERA Training 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/training-certificate-selection.spec.js b/tests/e2e/specs/training-certificate-selection.spec.js new file mode 100644 index 0000000..0ad27c3 --- /dev/null +++ b/tests/e2e/specs/training-certificate-selection.spec.js @@ -0,0 +1,133 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToTraining } = require("../helpers/navigation"); + +test.describe("Training plugin - certificate / badge selection", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await navigateToTraining(page); + }); + + test("should list available certificates from the API", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + // Wait for the certs API before checking options + await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 }); + // There should be at least 1 real option beyond the placeholder + const options = select.locator("option:not([disabled])"); + const count = await options.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("should show Red Certificate option", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 }); + const redOption = select.locator('option:has-text("Red")'); + const redCount = await redOption.count(); + if (redCount === 0) { + test.skip(true, "No Red Certificate option available on this server"); + return; + } + expect(redCount).toBeGreaterThanOrEqual(1); + }); + + test("should show Blue Certificate option", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 }); + const blueOption = select.locator('option:has-text("Blue")'); + const blueCount = await blueOption.count(); + if (blueCount === 0) { + test.skip(true, "No Blue Certificate option available on this server"); + return; + } + expect(blueCount).toBeGreaterThanOrEqual(1); + }); + + test("selecting a certificate should load badges", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + // Pick the first non-disabled option + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) return; // guard + + await select.selectOption(optionValue); + + // Badges should appear - they render as .badge-container-button elements + const badges = page.locator(".badge-container-button, .badge-text"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("clicking a badge should filter visible flags", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) return; + await select.selectOption(optionValue); + + // Wait for badges + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + + // Count initial flags + const flagsBefore = page.locator(".flag-card"); + await expect(flagsBefore.first()).toBeVisible({ timeout: 10_000 }); + const totalFlags = await flagsBefore.count(); + + // Click first badge to filter + await badges.first().click(); + + // After filtering, flag count should change (either same or fewer) + const flagsAfter = page.locator(".flag-card"); + const filteredCount = await flagsAfter.count(); + expect(filteredCount).toBeLessThanOrEqual(totalFlags); + expect(filteredCount).toBeGreaterThanOrEqual(1); + }); + + test("clicking a selected badge again should deselect it and show all flags", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) return; + await select.selectOption(optionValue); + + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + const flagsBefore = page.locator(".flag-card"); + await expect(flagsBefore.first()).toBeVisible({ timeout: 10_000 }); + const totalFlags = await flagsBefore.count(); + + // Select then deselect + await badges.first().click(); + await badges.first().click(); + + // Should show all flags again + const flagsAfter = page.locator(".flag-card"); + const restoredCount = await flagsAfter.count(); + expect(restoredCount).toBe(totalFlags); + }); + + test("selected badge should have the selected-badge CSS class", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) return; + await select.selectOption(optionValue); + + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + + await badges.first().click(); + await expect(badges.first()).toHaveClass(/selected-badge/); + }); +}); diff --git a/tests/e2e/specs/training-error-states.spec.js b/tests/e2e/specs/training-error-states.spec.js new file mode 100644 index 0000000..7730e98 --- /dev/null +++ b/tests/e2e/specs/training-error-states.spec.js @@ -0,0 +1,102 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToTraining } = require("../helpers/navigation"); + +test.describe("Training plugin - error states", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await navigateToTraining(page); + }); + + test("should not display flags before a certificate is selected", async ({ page }) => { + // Without selecting a certificate, no flags should be visible + const flags = page.locator(".flag-card"); + await page.waitForTimeout(2_000); // give time for any erroneous rendering + const count = await flags.count(); + expect(count).toBe(0); + }); + + test("should not display badges before a certificate is selected", async ({ page }) => { + const badges = page.locator(".badge-container-button"); + await page.waitForTimeout(2_000); + const count = await badges.count(); + expect(count).toBe(0); + }); + + test("should not show certificate completion banner on initial load", async ({ page }) => { + const banner = page.locator("h3:has-text('Certificate complete')"); + await page.waitForTimeout(2_000); + await expect(banner).toBeHidden(); + }); + + test("API error should not crash the page when fetching flags", async ({ page }) => { + // Intercept the flags API to simulate an error + await page.route("**/plugin/training/flags", (route) => { + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Internal Server Error" }), + }); + }); + + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) return; + await select.selectOption(optionValue); + + // Page should still be functional - heading should remain + await expect(page.locator("h2:has-text('Training')").first()).toBeVisible(); + // No flags should be shown since the API errored + await page.waitForTimeout(3_000); + const flags = page.locator(".flag-card"); + const count = await flags.count(); + expect(count).toBe(0); + }); + + test("API error should not crash the page when fetching certificates", async ({ page }) => { + // Navigate fresh and intercept the certs API + await page.route("**/plugin/training/certs", (route) => { + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "Internal Server Error" }), + }); + }); + + // Reload to trigger the certs fetch with the mock + await page.reload(); + + // Navigate back to training + const navItem = page.locator( + 'a:has-text("Training"), .nav-item:has-text("Training"), button:has-text("Training")' + ).first(); + if ((await navItem.count()) > 0) { + await navItem.click(); + } + + // Page should still render the heading + await expect(page.locator("h2:has-text('Training')").first()).toBeVisible({ timeout: 15_000 }); + }); + + test("badge images with broken src should fall back to default lock image", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) return; + await select.selectOption(optionValue); + + const badges = page.locator(".badge-icon-img"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + + // Badge images have an onerror handler pointing to defaultlock.png + // Verify the onerror attribute is set on at least the first image + const onerror = await badges.first().getAttribute("onerror"); + expect(onerror).toContain("defaultlock.png"); + }); +}); diff --git a/tests/e2e/specs/training-flags.spec.js b/tests/e2e/specs/training-flags.spec.js new file mode 100644 index 0000000..9f1656c --- /dev/null +++ b/tests/e2e/specs/training-flags.spec.js @@ -0,0 +1,111 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToTraining } = require("../helpers/navigation"); + +/** + * Helper: select the first available certificate so flags and badges load. + */ +async function selectFirstCertificate(page) { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + // Wait for the certs API to populate the dropdown before selecting + await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 }); + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) throw new Error("No certificate options found"); + await select.selectOption(optionValue); + // Wait for flags to render + await page.locator(".flag-card").first().waitFor({ state: "visible", timeout: 15_000 }); +} + +test.describe("Training plugin - flag exercises", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await navigateToTraining(page); + }); + + test("should display flag cards after selecting a certificate", async ({ page }) => { + await selectFirstCertificate(page); + const flags = page.locator(".flag-card"); + const count = await flags.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("each flag card should display a flag name", async ({ page }) => { + await selectFirstCertificate(page); + const flagNames = page.locator(".flag-card-title-name p"); + await expect(flagNames.first()).toBeVisible(); + const text = await flagNames.first().textContent(); + expect(text?.trim().length).toBeGreaterThan(0); + }); + + test("each flag card should display a challenge description", async ({ page }) => { + await selectFirstCertificate(page); + const challenges = page.locator(".flag-card-text .has-text-weight-bold"); + await expect(challenges.first()).toBeVisible(); + const text = await challenges.first().textContent(); + expect(text?.trim().length).toBeGreaterThan(0); + }); + + test("flag cards should have a show-more/expand button", async ({ page }) => { + await selectFirstCertificate(page); + const expandBtns = page.locator(".flag-show-more-button"); + await expect(expandBtns.first()).toBeVisible(); + }); + + test("clicking show-more should expand the flag card", async ({ page }) => { + await selectFirstCertificate(page); + const firstCard = page.locator(".flag-card").first(); + const expandBtn = firstCard.locator(".flag-show-more-button"); + await expandBtn.click(); + + // The card content should gain the flag-show-more class + const content = firstCard.locator(".flag-card-content"); + await expect(content).toHaveClass(/flag-show-more/); + }); + + test("clicking show-more again should collapse the flag card", async ({ page }) => { + await selectFirstCertificate(page); + const firstCard = page.locator(".flag-card").first(); + const expandBtn = firstCard.locator(".flag-show-more-button"); + + // Expand + await expandBtn.click(); + await expect(firstCard.locator(".flag-card-content")).toHaveClass(/flag-show-more/); + + // Collapse + await expandBtn.click(); + await expect(firstCard.locator(".flag-card-content")).not.toHaveClass(/flag-show-more/); + }); + + test("flag status icons should be displayed for visible flags", async ({ page }) => { + await selectFirstCertificate(page); + // The flag status bar shows flag icons (fas fa-flag or far fa-flag) + const statusIcons = page.locator(".has-text-centered.mt-4.mb-4 .icon, .has-text-centered .icon"); + await expect(statusIcons.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("the first incomplete flag should be marked as active", async ({ page }) => { + await selectFirstCertificate(page); + // Active flags get flag-card-content-active or flag-card-title-active classes + const activeCards = page.locator(".flag-card-content-active, .flag-card-title-active"); + // There should be at least one active card (the first incomplete one) + const count = await activeCards.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("flag cards should show badge icon in title area", async ({ page }) => { + await selectFirstCertificate(page); + const badgeIcons = page.locator(".flag-card-title-badge img"); + await expect(badgeIcons.first()).toBeVisible(); + }); + + test("solution guide links should be present on flags that have them", async ({ page }) => { + await selectFirstCertificate(page); + // Solution guide links exist but may be hidden for flags without guides + const guideLinks = page.locator(".solution-guide-link"); + const count = await guideLinks.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/e2e/specs/training-page-load.spec.js b/tests/e2e/specs/training-page-load.spec.js new file mode 100644 index 0000000..f7320a0 --- /dev/null +++ b/tests/e2e/specs/training-page-load.spec.js @@ -0,0 +1,40 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToTraining } = require("../helpers/navigation"); + +test.describe("Training 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 Training navigation item", async ({ page }) => { + const navItem = page.locator( + 'a:has-text("Training"), .nav-item:has-text("Training"), [data-test="nav-training"], button:has-text("Training")' + ).first(); + await expect(navItem).toBeVisible({ timeout: 15_000 }); + }); + + test("should navigate to the Training tab and display the heading", async ({ page }) => { + await navigateToTraining(page); + await expect(page.locator("h2:has-text('Training')").first()).toBeVisible(); + }); + + test("should show the certificate selection dropdown", async ({ page }) => { + await navigateToTraining(page); + await expect(page.locator("#select-certificate select, #select-certificate .select select").first()).toBeVisible(); + }); + + test("should have a default placeholder option in the certificate dropdown", async ({ page }) => { + await navigateToTraining(page); + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + // The default option should be disabled / placeholder + const defaultOption = select.locator("option[disabled], option[value='']"); + await expect(defaultOption).toHaveCount(1); + }); +}); diff --git a/tests/e2e/specs/training-paths.spec.js b/tests/e2e/specs/training-paths.spec.js new file mode 100644 index 0000000..77f0077 --- /dev/null +++ b/tests/e2e/specs/training-paths.spec.js @@ -0,0 +1,141 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToTraining } = require("../helpers/navigation"); + +test.describe("Training plugin - Red vs Blue training paths", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await navigateToTraining(page); + }); + + test("should list both Red and Blue certificate paths (if available)", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + const options = select.locator("option:not([disabled])"); + const count = await options.count(); + // The server should provide at least one certificate path + expect(count).toBeGreaterThanOrEqual(1); + + // Collect option texts to check for red/blue + const texts = []; + for (let i = 0; i < count; i++) { + texts.push(await options.nth(i).textContent()); + } + // Log for debugging - at least one should exist + expect(texts.length).toBeGreaterThanOrEqual(1); + }); + + test("selecting Red Certificate should load red-path badges", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + // Try to find and select the Red option + const redOption = select.locator('option:has-text("Red")'); + const redCount = await redOption.count(); + if (redCount === 0) { + test.skip(true, "No Red Certificate option available on this server"); + return; + } + + const value = await redOption.first().getAttribute("value"); + await select.selectOption(value); + + // Badges should load + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("selecting Blue Certificate should load blue-path badges", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const blueOption = select.locator('option:has-text("Blue")'); + const blueCount = await blueOption.count(); + if (blueCount === 0) { + test.skip(true, "No Blue Certificate option available on this server"); + return; + } + + const value = await blueOption.first().getAttribute("value"); + await select.selectOption(value); + + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + }); + + test("switching between certificates should reset badge selection", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const options = select.locator("option:not([disabled])"); + const count = await options.count(); + if (count < 2) { + test.skip(true, "Need at least 2 certificates to test switching"); + return; + } + + // Select first certificate + const val1 = await options.nth(0).getAttribute("value"); + await select.selectOption(val1); + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + + // Select a badge + await badges.first().click(); + await expect(badges.first()).toHaveClass(/selected-badge/); + + // Switch to second certificate + const val2 = await options.nth(1).getAttribute("value"); + await select.selectOption(val2); + + // Wait for new badges to load + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + + // No badge should be in selected state after switching + const selectedBadges = page.locator(".badge-container-button.selected-badge"); + const selectedCount = await selectedBadges.count(); + expect(selectedCount).toBe(0); + }); + + test("different certificates should show different badge sets", async ({ page }) => { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + + const options = select.locator("option:not([disabled])"); + const count = await options.count(); + if (count < 2) { + test.skip(true, "Need at least 2 certificates to compare badge sets"); + return; + } + + // Get badge names from first certificate + const val1 = await options.nth(0).getAttribute("value"); + await select.selectOption(val1); + const badgeTexts1 = page.locator(".badge-text"); + await expect(badgeTexts1.first()).toBeVisible({ timeout: 15_000 }); + const names1 = []; + for (let i = 0; i < await badgeTexts1.count(); i++) { + names1.push(await badgeTexts1.nth(i).textContent()); + } + + // Get badge names from second certificate + const val2 = await options.nth(1).getAttribute("value"); + await select.selectOption(val2); + await expect(badgeTexts1.first()).toBeVisible({ timeout: 15_000 }); + const names2 = []; + for (let i = 0; i < await badgeTexts1.count(); i++) { + names2.push(await badgeTexts1.nth(i).textContent()); + } + + // Both should have badges loaded + expect(names1.length).toBeGreaterThanOrEqual(1); + expect(names2.length).toBeGreaterThanOrEqual(1); + + // The two certificate paths should have different badge sets + const areDifferent = + names1.length !== names2.length || + names1.some((name, i) => name !== names2[i]); + expect(areDifferent).toBe(true); + }); +}); diff --git a/tests/e2e/specs/training-progress.spec.js b/tests/e2e/specs/training-progress.spec.js new file mode 100644 index 0000000..fb264b2 --- /dev/null +++ b/tests/e2e/specs/training-progress.spec.js @@ -0,0 +1,104 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); +const { login } = require("../helpers/auth"); +const { navigateToTraining } = require("../helpers/navigation"); + +async function selectFirstCertificate(page) { + const select = page.locator("#select-certificate select").first(); + await expect(select).toBeVisible(); + // Wait for the certs API to populate the dropdown before selecting + await page.waitForResponse((resp) => resp.url().includes('/plugin/training/certs') && resp.status() === 200, { timeout: 15_000 }); + const firstOption = select.locator("option:not([disabled])").first(); + const optionValue = await firstOption.getAttribute("value"); + if (!optionValue) throw new Error("No certificate options found"); + await select.selectOption(optionValue); + await page.locator(".flag-card, .badge-container-button").first().waitFor({ state: "visible", timeout: 15_000 }); +} + +test.describe("Training plugin - progress tracking", () => { + test.beforeEach(async ({ page }) => { + await login(page); + await navigateToTraining(page); + }); + + test("badges should display completion status styling", async ({ page }) => { + await selectFirstCertificate(page); + const badges = page.locator(".badge-container-button"); + await expect(badges.first()).toBeVisible({ timeout: 15_000 }); + + // Each badge should have either completed or non-completed styling + // The badge-completed class or badge-completed-text class indicates completion + const count = await badges.count(); + expect(count).toBeGreaterThanOrEqual(1); + + // Verify badge text elements exist + const badgeTexts = page.locator(".badge-text"); + expect(await badgeTexts.count()).toBeGreaterThanOrEqual(1); + }); + + test("flag icons should indicate completed vs incomplete state", async ({ page }) => { + await selectFirstCertificate(page); + // Flag icons use fas fa-flag (solid=completed) or far fa-flag (outline=incomplete) + const flagIcons = page.locator(".flag-card-title-name .icon"); + await expect(flagIcons.first()).toBeVisible({ timeout: 10_000 }); + const count = await flagIcons.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("completed flags should have gold icon styling", async ({ page }) => { + await selectFirstCertificate(page); + // If any flags are completed, they get flag-icon-completed class (gold color) + // This is a structural check - the class binding exists in the template + const flagCards = page.locator(".flag-card"); + const count = await flagCards.count(); + expect(count).toBeGreaterThanOrEqual(1); + + // Verify the icon container is present in each flag card + for (let i = 0; i < Math.min(count, 3); i++) { + const icon = flagCards.nth(i).locator(".flag-card-title-name .icon"); + await expect(icon).toBeVisible(); + } + }); + + test("certificate completion banner should not show when certificate is incomplete", async ({ page }) => { + await selectFirstCertificate(page); + // On a fresh server the certificate should not be complete + // The completion banner contains "Certificate complete" + const banner = page.locator("h3:has-text('Certificate complete')"); + const bannerCount = await banner.count(); + if (bannerCount > 0) { + // Training state is already complete in this environment - skip rather than fail + test.skip(true, "Certificate already complete; cannot test incomplete state without resetting"); + return; + } + await expect(banner).toHaveCount(0); + }); + + test("the flag status bar should show icons matching the number of visible flags", async ({ page }) => { + await selectFirstCertificate(page); + const flagCards = page.locator(".flag-card"); + await expect(flagCards.first()).toBeVisible({ timeout: 10_000 }); + const cardCount = await flagCards.count(); + + // The status bar icons + const statusIcons = page.locator(".has-text-centered.mt-4.mb-4 .icon"); + const iconCount = await statusIcons.count(); + + // Each visible flag should have a corresponding status icon + expect(iconCount).toBe(cardCount); + }); + + test("badge icon images should load without error (fallback to default)", async ({ page }) => { + await selectFirstCertificate(page); + const badgeImgs = page.locator(".badge-icon-img"); + await expect(badgeImgs.first()).toBeVisible({ timeout: 10_000 }); + + const count = await badgeImgs.count(); + for (let i = 0; i < Math.min(count, 5); i++) { + const img = badgeImgs.nth(i); + // Image should have a src attribute (either real or fallback defaultlock.png) + const src = await img.getAttribute("src"); + expect(src).toBeTruthy(); + } + }); +});