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 <noreply@anthropic.com>
This commit is contained in:
24
e2e/auth.setup.ts
Normal file
24
e2e/auth.setup.ts
Normal file
@@ -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 });
|
||||
});
|
||||
72
e2e/auth.spec.ts
Normal file
72
e2e/auth.spec.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
75
e2e/dashboard.spec.ts
Normal file
75
e2e/dashboard.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
33
e2e/developer.spec.ts
Normal file
33
e2e/developer.spec.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
52
e2e/navigation.spec.ts
Normal file
52
e2e/navigation.spec.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
41
e2e/organization.spec.ts
Normal file
41
e2e/organization.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
55
e2e/providers.spec.ts
Normal file
55
e2e/providers.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
60
e2e/public.spec.ts
Normal file
60
e2e/public.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user