From 420e0555f7826db6307019da185b477977719975 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 25 Feb 2026 10:04:09 +0100 Subject: [PATCH] test: add Playwright E2E test suite (30 tests) Add browser-level end-to-end tests covering public pages, Keycloak OAuth authentication flow, dashboard interactions, providers config, developer section, organization pages, and sidebar navigation. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 +++ bun.lock | 9 +++++ e2e/auth.setup.ts | 24 +++++++++++++ e2e/auth.spec.ts | 72 ++++++++++++++++++++++++++++++++++++++ e2e/dashboard.spec.ts | 75 ++++++++++++++++++++++++++++++++++++++++ e2e/developer.spec.ts | 33 ++++++++++++++++++ e2e/navigation.spec.ts | 52 ++++++++++++++++++++++++++++ e2e/organization.spec.ts | 41 ++++++++++++++++++++++ e2e/providers.spec.ts | 55 +++++++++++++++++++++++++++++ e2e/public.spec.ts | 60 ++++++++++++++++++++++++++++++++ package.json | 1 + playwright.config.ts | 40 +++++++++++++++++++++ 12 files changed, 467 insertions(+) create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/dashboard.spec.ts create mode 100644 e2e/developer.spec.ts create mode 100644 e2e/navigation.spec.ts create mode 100644 e2e/organization.spec.ts create mode 100644 e2e/providers.spec.ts create mode 100644 e2e/public.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 75620fe..8e10faa 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ keycloak/* node_modules/ searxng/ + +# Playwright +e2e/.auth/ +playwright-report/ +test-results/ diff --git a/bun.lock b/bun.lock index 5d6c3e9..5849f07 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "tailwindcss": "^4.1.18", }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/bun": "latest", }, "peerDependencies": { @@ -16,6 +17,8 @@ }, }, "packages": { + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], @@ -24,6 +27,12 @@ "daisyui": ["daisyui@5.5.18", "", {}, "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..e8b6cb1 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,24 @@ +import { test as setup, expect } from "@playwright/test"; + +const AUTH_FILE = "e2e/.auth/user.json"; + +setup("authenticate via Keycloak", async ({ page }) => { + // Navigate to a protected route to trigger the auth redirect chain: + // /dashboard -> /auth (Axum) -> Keycloak login page + await page.goto("/dashboard"); + + // Wait for Keycloak login form to appear + await page.waitForSelector("#username", { timeout: 15_000 }); + + // Fill Keycloak credentials + await page.fill("#username", process.env.TEST_USER ?? "admin@certifai.local"); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + // Wait for redirect back to the app dashboard + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".sidebar")).toBeVisible(); + + // Persist authenticated state (cookies + localStorage) + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..fe35ce6 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "@playwright/test"; + +// These tests use a fresh browser context (no saved auth state) +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("Authentication flow", () => { + test("unauthenticated visit to /dashboard redirects to Keycloak", async ({ + page, + }) => { + await page.goto("/dashboard"); + + // Should end up on Keycloak login page + await page.waitForSelector("#username", { timeout: 15_000 }); + await expect(page.locator("#kc-login")).toBeVisible(); + }); + + test("valid credentials log in and redirect to dashboard", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".dashboard-page")).toBeVisible(); + }); + + test("dashboard shows sidebar with user info after login", async ({ + page, + }) => { + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + await expect(page.locator(".sidebar-name")).toBeVisible(); + await expect(page.locator(".sidebar-email")).toBeVisible(); + }); + + test("logout redirects away from dashboard", async ({ page }) => { + // First log in + await page.goto("/dashboard"); + await page.waitForSelector("#username", { timeout: 15_000 }); + + await page.fill( + "#username", + process.env.TEST_USER ?? "admin@certifai.local" + ); + await page.fill("#password", process.env.TEST_PASSWORD ?? "admin"); + await page.click("#kc-login"); + + await page.waitForURL("**/dashboard", { timeout: 15_000 }); + + // Click logout + await page.locator('a.logout-btn, a[href="/logout"]').click(); + + // Should no longer be on the dashboard + await expect(page).not.toHaveURL(/\/dashboard/); + }); +}); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 0000000..89094e2 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Dashboard", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + // Wait for WASM hydration and auth check to complete + await page.waitForSelector(".dashboard-page", { timeout: 15_000 }); + }); + + test("dashboard page loads with page header", async ({ page }) => { + await expect(page.locator(".page-header")).toContainText("Dashboard"); + }); + + test("default topic chips are visible", async ({ page }) => { + const topics = ["AI", "Technology", "Science", "Finance", "Writing", "Research"]; + + for (const topic of topics) { + await expect( + page.locator(".filter-tab", { hasText: topic }) + ).toBeVisible(); + } + }); + + test("clicking a topic chip triggers search", async ({ page }) => { + const chip = page.locator(".filter-tab", { hasText: "AI" }); + await chip.click(); + + // Either a loading state or results should appear + const searchingOrResults = page + .locator(".dashboard-loading, .news-grid, .dashboard-empty"); + await expect(searchingOrResults.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("news cards render after search completes", async ({ page }) => { + // Click a topic to trigger search + await page.locator(".filter-tab", { hasText: "Technology" }).click(); + + // Wait for loading to finish + await page.waitForSelector(".dashboard-loading", { + state: "hidden", + timeout: 15_000, + }).catch(() => { + // Loading may already be done + }); + + // Either news cards or an empty state message should be visible + const content = page.locator(".news-grid .news-card, .dashboard-empty"); + await expect(content.first()).toBeVisible({ timeout: 10_000 }); + }); + + test("clicking a news card opens article detail panel", async ({ page }) => { + // Trigger a search and wait for results + await page.locator(".filter-tab", { hasText: "AI" }).click(); + + await page.waitForSelector(".dashboard-loading", { + state: "hidden", + timeout: 15_000, + }).catch(() => {}); + + const firstCard = page.locator(".news-card").first(); + // Only test if cards are present (search results depend on live data) + if (await firstCard.isVisible().catch(() => false)) { + await firstCard.click(); + await expect(page.locator(".dashboard-right, .dashboard-split")).toBeVisible(); + } + }); + + test("settings toggle opens settings panel", async ({ page }) => { + const settingsBtn = page.locator(".settings-toggle"); + await settingsBtn.click(); + + await expect(page.locator(".settings-panel")).toBeVisible(); + await expect(page.locator(".settings-panel-title")).toBeVisible(); + }); +}); diff --git a/e2e/developer.spec.ts b/e2e/developer.spec.ts new file mode 100644 index 0000000..9d84e30 --- /dev/null +++ b/e2e/developer.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Developer section", () => { + test("agents page loads with sub-nav tabs", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".developer-shell", { timeout: 15_000 }); + + const nav = page.locator(".sub-nav"); + await expect(nav.locator("a", { hasText: "Agents" })).toBeVisible(); + await expect(nav.locator("a", { hasText: "Flow" })).toBeVisible(); + await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible(); + }); + + test("agents page shows Coming Soon badge", async ({ page }) => { + await page.goto("/developer/agents"); + await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + + await expect(page.locator(".placeholder-badge")).toContainText( + "Coming Soon" + ); + await expect(page.locator("h2")).toContainText("Agent Builder"); + }); + + test("analytics page loads via sub-nav", async ({ page }) => { + await page.goto("/developer/analytics"); + await page.waitForSelector(".placeholder-page", { timeout: 15_000 }); + + await expect(page.locator("h2")).toContainText("Analytics"); + await expect(page.locator(".placeholder-badge")).toContainText( + "Coming Soon" + ); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..cf3859e --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Sidebar navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForSelector(".sidebar", { timeout: 15_000 }); + }); + + test("sidebar links route to correct pages", async ({ page }) => { + const navTests = [ + { label: "Providers", url: /\/providers/ }, + { label: "Developer", url: /\/developer\/agents/ }, + { label: "Organization", url: /\/organization\/pricing/ }, + { label: "Dashboard", url: /\/dashboard/ }, + ]; + + for (const { label, url } of navTests) { + await page.locator(".sidebar-link", { hasText: label }).click(); + await expect(page).toHaveURL(url, { timeout: 10_000 }); + } + }); + + test("browser back/forward navigation works", async ({ page }) => { + // Navigate to Providers + await page.locator(".sidebar-link", { hasText: "Providers" }).click(); + await expect(page).toHaveURL(/\/providers/); + + // Navigate to Developer + await page.locator(".sidebar-link", { hasText: "Developer" }).click(); + await expect(page).toHaveURL(/\/developer/); + + // Go back + await page.goBack(); + await expect(page).toHaveURL(/\/providers/); + + // Go forward + await page.goForward(); + await expect(page).toHaveURL(/\/developer/); + }); + + test("logo link navigates to dashboard", async ({ page }) => { + // Navigate away first + await page.locator(".sidebar-link", { hasText: "Providers" }).click(); + await expect(page).toHaveURL(/\/providers/); + + // Click the logo/brand in sidebar header + const logo = page.locator(".sidebar-brand, .sidebar-logo, .sidebar a").first(); + await logo.click(); + + await expect(page).toHaveURL(/\/dashboard/); + }); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts new file mode 100644 index 0000000..c786254 --- /dev/null +++ b/e2e/organization.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Organization section", () => { + test("pricing page loads with three pricing cards", async ({ page }) => { + await page.goto("/organization/pricing"); + await page.waitForSelector(".org-shell", { timeout: 15_000 }); + + const cards = page.locator(".pricing-card"); + await expect(cards).toHaveCount(3); + }); + + test("pricing cards show Starter, Team, Enterprise tiers", async ({ + page, + }) => { + await page.goto("/organization/pricing"); + await page.waitForSelector(".org-shell", { timeout: 15_000 }); + + await expect(page.locator(".pricing-card", { hasText: "Starter" })).toBeVisible(); + await expect(page.locator(".pricing-card", { hasText: "Team" })).toBeVisible(); + await expect(page.locator(".pricing-card", { hasText: "Enterprise" })).toBeVisible(); + }); + + test("organization dashboard loads with billing stats", async ({ page }) => { + await page.goto("/organization/dashboard"); + await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 }); + + await expect(page.locator(".page-header")).toContainText("Organization"); + await expect(page.locator(".org-stats-bar")).toBeVisible(); + await expect(page.locator(".org-stat").first()).toBeVisible(); + }); + + test("member table is visible on org dashboard", async ({ page }) => { + await page.goto("/organization/dashboard"); + await page.waitForSelector(".org-dashboard-page", { timeout: 15_000 }); + + await expect(page.locator(".org-table")).toBeVisible(); + await expect(page.locator(".org-table thead")).toContainText("Name"); + await expect(page.locator(".org-table thead")).toContainText("Email"); + await expect(page.locator(".org-table thead")).toContainText("Role"); + }); +}); diff --git a/e2e/providers.spec.ts b/e2e/providers.spec.ts new file mode 100644 index 0000000..63ea082 --- /dev/null +++ b/e2e/providers.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Providers page", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/providers"); + await page.waitForSelector(".providers-page", { timeout: 15_000 }); + }); + + test("providers page loads with header", async ({ page }) => { + await expect(page.locator(".page-header")).toContainText("Providers"); + }); + + test("provider dropdown has Ollama selected by default", async ({ + page, + }) => { + const providerSelect = page + .locator(".form-group") + .filter({ hasText: "Provider" }) + .locator("select"); + + await expect(providerSelect).toHaveValue(/ollama/i); + }); + + test("changing provider updates the model dropdown", async ({ page }) => { + const providerSelect = page + .locator(".form-group") + .filter({ hasText: "Provider" }) + .locator("select"); + + // Get current model options + const modelSelect = page + .locator(".form-group") + .filter({ hasText: /^Model/ }) + .locator("select"); + const initialOptions = await modelSelect.locator("option").allTextContents(); + + // Change to a different provider + await providerSelect.selectOption({ label: "OpenAI" }); + + // Wait for model list to update + await page.waitForTimeout(500); + const updatedOptions = await modelSelect.locator("option").allTextContents(); + + // Model options should differ between providers + expect(updatedOptions).not.toEqual(initialOptions); + }); + + test("save button shows confirmation feedback", async ({ page }) => { + const saveBtn = page.locator("button", { hasText: "Save Configuration" }); + await saveBtn.click(); + + await expect(page.locator(".form-success")).toBeVisible({ timeout: 5_000 }); + await expect(page.locator(".form-success")).toContainText("saved"); + }); +}); diff --git a/e2e/public.spec.ts b/e2e/public.spec.ts new file mode 100644 index 0000000..afd7f3b --- /dev/null +++ b/e2e/public.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Public pages", () => { + test("landing page loads with heading and nav links", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".landing-logo").first()).toHaveText("CERTifAI"); + await expect(page.locator(".landing-nav-links")).toBeVisible(); + await expect(page.locator('a[href="#features"]')).toBeVisible(); + await expect(page.locator('a[href="#how-it-works"]')).toBeVisible(); + await expect(page.locator('a[href="#pricing"]')).toBeVisible(); + }); + + test("landing page Log In link navigates to login route", async ({ + page, + }) => { + await page.goto("/"); + + const loginLink = page + .locator(".landing-nav-actions a, .landing-nav-actions Link") + .filter({ hasText: "Log In" }); + await loginLink.click(); + + await expect(page).toHaveURL(/\/login/); + }); + + test("impressum page loads with legal content", async ({ page }) => { + await page.goto("/impressum"); + + await expect(page.locator("h1")).toHaveText("Impressum"); + await expect( + page.locator("h2", { hasText: "Information according to" }) + ).toBeVisible(); + await expect(page.locator(".legal-content")).toContainText( + "CERTifAI GmbH" + ); + }); + + test("privacy page loads with privacy content", async ({ page }) => { + await page.goto("/privacy"); + + await expect(page.locator("h1")).toHaveText("Privacy Policy"); + await expect( + page.locator("h2", { hasText: "Introduction" }) + ).toBeVisible(); + await expect( + page.locator("h2", { hasText: "Your Rights" }) + ).toBeVisible(); + }); + + test("footer links are present on landing page", async ({ page }) => { + await page.goto("/"); + + const footer = page.locator(".landing-footer"); + await expect(footer.locator('a:has-text("Impressum")')).toBeVisible(); + await expect( + footer.locator('a:has-text("Privacy Policy")') + ).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 0072209..8ad4cbb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/bun": "latest" }, "peerDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d58df60 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [["html"], ["list"]], + timeout: 30_000, + + use: { + baseURL: process.env.BASE_URL ?? "http://localhost:8000", + actionTimeout: 10_000, + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + projects: [ + { + name: "setup", + testMatch: /auth\.setup\.ts/, + }, + { + name: "public", + testMatch: /public\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "authenticated", + testMatch: /\.spec\.ts$/, + testIgnore: /public\.spec\.ts$/, + dependencies: ["setup"], + use: { + ...devices["Desktop Chrome"], + storageState: "e2e/.auth/user.json", + }, + }, + ], +});