diff --git a/.github/workflows/ui-playwright.yaml b/.github/workflows/ui-playwright.yaml new file mode 100644 index 000000000..90b1db9e3 --- /dev/null +++ b/.github/workflows/ui-playwright.yaml @@ -0,0 +1,56 @@ +name: UI Playwright E2E + +# Page-level browser E2E for the UI against a mocked backend. +# Kept separate from CI so the main workflow does not need paths-filter or +# conditional steps. No cluster required — the stub backend is booted by Playwright. + +on: + push: + branches: [main, "release/**"] + paths: + - "ui/**" + pull_request: + branches: [main, "release/**"] + paths: + - "ui/**" + workflow_dispatch: + +jobs: + playwright: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + working-directory: ./ui + env: + CYPRESS_INSTALL_BINARY: 0 + run: npm ci + + - name: Install Playwright browser + working-directory: ./ui + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: ./ui + run: npm run test:pw + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + ui/playwright-report + ui/playwright/test-results + retention-days: 14 diff --git a/ui/.gitignore b/ui/.gitignore index 9352a9952..adb14e302 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -42,3 +42,8 @@ next-env.d.ts # storybook /storybook-static + +# playwright +/playwright-report +/playwright/test-results +/playwright/.cache diff --git a/ui/package-lock.json b/ui/package-lock.json index 545766788..788e11f5b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -55,6 +55,7 @@ "@chromatic-com/storybook": "^5.2.1", "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.4.1", + "@playwright/test": "^1.61.0", "@storybook/addon-a11y": "^10.4.6", "@storybook/addon-docs": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6", @@ -4026,6 +4027,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -16038,13 +16055,13 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", - "dev": true, + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -16057,10 +16074,10 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", - "dev": true, + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -16073,7 +16090,6 @@ "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, "optional": true, "os": [ diff --git a/ui/package.json b/ui/package.json index aeda2e3a4..55751a765 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,9 @@ "test:watch": "jest --watch", "test:e2e:cypress": "cypress run --e2e", "test:e2e": "start-server-and-test dev http://localhost:8001 test:e2e:cypress", + "test:pw": "playwright test", + "test:pw:ui": "playwright test --ui", + "test:pw:debug": "playwright test --debug", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "chromatic": "chromatic --exit-zero-on-changes --storybook-build-dir storybook-static --project-token chpt_3e29f54d624610f" @@ -70,6 +73,7 @@ "@chromatic-com/storybook": "^5.2.1", "@eslint/eslintrc": "^3.3.3", "@jest/globals": "^30.4.1", + "@playwright/test": "^1.61.0", "@storybook/addon-a11y": "^10.4.6", "@storybook/addon-docs": "^10.4.6", "@storybook/addon-onboarding": "^10.4.6", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 000000000..ac77c405b --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright E2E config for the kagent UI. + * + * Data is fetched server-side (Next.js server actions), so browser-level + * `page.route` cannot intercept `/api/**`. Instead we boot a standalone stub + * backend (playwright/mocks/server.mjs) and point Next at it via + * BACKEND_INTERNAL_URL — `getBackendUrl()` (src/lib/utils.ts) checks that env + * var first. Both servers are started by the `webServer` block below. + * + * See playwright/README.md for the full test strategy. + */ + +const CI = !!process.env.CI; + +const STUB_PORT = 8899; +const STUB_URL = `http://127.0.0.1:${STUB_PORT}`; +const APP_URL = "http://localhost:8001"; + +export default defineConfig({ + testDir: "./playwright/tests", + outputDir: "./playwright/test-results", + // Parallelism stays off until Stage 1 per-test data isolation lands: one + // shared stub backend + one Next server means concurrent tests would race + // against shared state (see README). Flip both `fullyParallel` and `workers` + // together when isolation is in place. + fullyParallel: false, + forbidOnly: CI, + retries: CI ? 1 : 0, + workers: 1, + timeout: 30_000, + expect: { timeout: 10_000 }, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL: APP_URL, + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "off", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: [ + { + command: "node playwright/mocks/server.mjs", + url: `${STUB_URL}/__mock/health`, + reuseExistingServer: !CI, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + // Pin the port so a shell-exported STUB_PORT can't make the stub bind + // somewhere other than the health-check / BACKEND_INTERNAL_URL address. + env: { STUB_PORT: String(STUB_PORT) }, + }, + { + command: "npm run dev", + url: APP_URL, + // Never reuse an existing dev server: the BACKEND_INTERNAL_URL below is + // only applied to a server Playwright starts. A reused server (e.g. a + // hand-started `npm run dev`) would silently bypass the stub. Always + // boot our own so the redirect is guaranteed; a busy port fails loudly. + reuseExistingServer: false, + timeout: 120_000, + env: { + // Redirect the server-side backend fetch to our stub. + BACKEND_INTERNAL_URL: `${STUB_URL}/api`, + }, + }, + ], +}); diff --git a/ui/playwright/README.md b/ui/playwright/README.md new file mode 100644 index 000000000..662bd448f --- /dev/null +++ b/ui/playwright/README.md @@ -0,0 +1,90 @@ +# Playwright E2E tests + +Page-level browser end-to-end tests for the kagent UI, run against a **mocked +backend**. This suite fills the one gap nothing else covers: **multi-step user +flows across components**. + +## What this suite does and does not cover + +| Layer | Tool | Not Playwright's job | +|---|---|---| +| Atoms (`src/components/ui/*`) | shadcn primitives | skip | +| Visual / render states | Storybook + Vitest-browser + Chromatic | skip | +| Unit / logic | Jest (`*.test.ts(x)`) | skip | +| Page-load smoke (`h1` renders, page reachable, onboarding steps 1–2, model-edit page opens) | Cypress (`cypress/e2e/smoke.cy.ts`) | skip | +| **Multi-step flows: form submission, payload correctness, streaming, wizard completion, error/edge states** | **Playwright (this suite)** | — | + +Rule: if Cypress / Chromatic / Jest already assert it, Playwright does not. + +## How mocking works (important) + +Nearly every `/api/**` call runs **server-side** inside Next.js server actions +(`src/app/actions/*.ts`, all `"use server"`). The browser sends an opaque RPC +POST to the route; the Node server does the actual backend `fetch`. So browser +`page.route("**/api/**")` intercepts **nothing** on load. + +Instead we run a standalone **stub backend** (`mocks/server.mjs`) and point Next +at it with `BACKEND_INTERNAL_URL` — `getBackendUrl()` (`src/lib/utils.ts`) checks +that env var first. Playwright's `webServer` (in `../playwright.config.ts`) boots +both the stub (`:8899`) and `next dev` (`:8001`). + +The one exception is A2A chat streaming (`POST /a2a/**`, SSE), which **is** +browser-originated and `page.route`-able — used in the chat spec (Stage 2). + +## Layout + +``` +playwright/ + tests/ # *.spec.ts, one per feature area + helpers/ # reusable drivers: page, nav (forms/select/dialog land with Stage 2) + mocks/ + server.mjs # stub backend (happy-path data + /__mock/scenario overrides) + data.ts # typed spec-side builders + ok() envelope (mirror server.mjs shapes) + control.ts # semantic mock seam: mock.noAgents(), mock.agentsError(), … + fixtures/ + test.ts # import { test, expect } from here; provides the `mock` fixture + tsconfig.json +``` + +## Running + +```bash +cd ui +npm install +npx playwright install chromium # first time only +npm run test:pw # headless +npm run test:pw:ui # interactive UI mode +npm run test:pw:debug # step-through debugger +``` + +Playwright starts the stub + dev server automatically. To drive the app manually +against the stub: + +```bash +node playwright/mocks/server.mjs & +BACKEND_INTERNAL_URL=http://127.0.0.1:8899/api npm run dev +# open http://localhost:8001 +``` + +## Conventions + +- Import `{ test, expect }` from `../fixtures/test`, never `@playwright/test`. +- One spec file per feature area (`tests/agents/`, `tests/chat/`, …); use + `test.step()` for sub-phases of a multi-step flow. +- **`data-testid` policy:** prefer `getByRole` / `getByLabel`. Add `data-testid` + only where role/text is ambiguous or unstable (list rows, per-item action + buttons, wizard steps, combobox options). Add incrementally — no upfront sweep. + Do **not** remove/rename the existing `data-test` model-edit hooks; Cypress + depends on them. + +## Roadmap + +- **Stage 0 (done):** foundation — config, stub backend, CI, one smoke test. +- **Stage 1 (done):** page/nav helpers (`helpers/*`) + per-test scenario overrides — + the `mock` fixture drives the stub's `/__mock/scenario` endpoint via `mocks/control.ts` + (e.g. `mock.noAgents()`, `mock.agentsError()`), verified by the home + nav specs. + Runs serially (`workers: 1`) against the shared stub; raising the worker count later + needs per-worker servers or stateless request-keyed scenarios. +- **Stage 2:** feature flows (gap-scoped), ordered by importance — Create Agent → + Chat/session (A2A SSE mock) → Models → MCP → Onboarding completion. Adds the + `forms`/`select`/`dialog` helpers demand-driven against the create-agent form. diff --git a/ui/playwright/fixtures/test.ts b/ui/playwright/fixtures/test.ts new file mode 100644 index 000000000..10969e7de --- /dev/null +++ b/ui/playwright/fixtures/test.ts @@ -0,0 +1,38 @@ +// Shared test fixture. Import { test, expect } from here in every spec (not +// directly from @playwright/test) so all specs get the same setup: +// - `mock`: the stub-backend control seam (mock.noAgents(), mock.agentsError(), …) +// - the stub is reset before every test (page depends on mock), so scenarios +// set by one test never leak into the next. +// - onboarding wizard is bypassed on every navigation. + +import { test as base, expect } from "@playwright/test"; +import { makeMock, type MockBackend } from "../mocks/control"; + +export const test = base.extend<{ mock: MockBackend }>({ + // Param is named `run` (not the Playwright-idiomatic `use`) to avoid the + // react-hooks lint rule mistaking it for React 19's use() hook. + mock: async ({ request }, run) => { + const mock = makeMock(request); + // Reset scenario state before each test. The stub is managed by Playwright's + // webServer, so in CI an unreachable stub is a real failure — fail fast. + try { + await mock.reset(); + } catch (err) { + if (process.env.CI) throw err; + } + await run(mock); + }, + // Depend on `mock` so the reset above runs before every test that uses a page, + // even specs that don't touch scenarios (e.g. smoke). + page: async ({ page, mock }, run) => { + void mock; + // Bypass the first-run onboarding wizard (AppInitializer redirects to it when + // this key is unset). Runs before any page script, on every navigation. + await page.addInitScript(() => { + window.localStorage.setItem("kagent-onboarding", "true"); + }); + await run(page); + }, +}); + +export { expect }; diff --git a/ui/playwright/helpers/nav.ts b/ui/playwright/helpers/nav.ts new file mode 100644 index 000000000..a6b27b5ff --- /dev/null +++ b/ui/playwright/helpers/nav.ts @@ -0,0 +1,40 @@ +// Navigation helpers for the persistent header (src/components/Header.tsx). +// +// Routes live inside two Radix dropdown menus ("Create" and "View"), not flat +// links. Menu items only enter the DOM (as role="menuitem") once the menu is +// open. Header markup is duplicated for desktop/mobile, but the hidden block is +// out of the accessibility tree, so role-based locators resolve to the visible +// one on a desktop viewport. + +import { type Page } from "@playwright/test"; + +async function openMenu(page: Page, trigger: "Create" | "View"): Promise { + await page.getByRole("button", { name: trigger, exact: true }).click(); +} + +async function chooseFrom( + page: Page, + trigger: "Create" | "View", + item: string, + urlGlob?: string | RegExp, +): Promise { + await openMenu(page, trigger); + await page.getByRole("menuitem", { name: item }).click(); + if (urlGlob) await page.waitForURL(urlGlob); +} + +/** Open the "View" menu and go to a listing page, e.g. gotoView(page, "Models", "**\/models"). */ +export function gotoView(page: Page, item: string, urlGlob?: string | RegExp): Promise { + return chooseFrom(page, "View", item, urlGlob); +} + +/** Open the "Create" menu and go to a creation page, e.g. gotoCreate(page, "New Agent", "**\/agents/new"). */ +export function gotoCreate(page: Page, item: string, urlGlob?: string | RegExp): Promise { + return chooseFrom(page, "Create", item, urlGlob); +} + +/** Click the direct "Home" link in the header. */ +export async function gotoHome(page: Page): Promise { + await page.getByRole("link", { name: "Home" }).first().click(); + await page.waitForURL(/\/(agents)?$/); +} diff --git a/ui/playwright/helpers/page.ts b/ui/playwright/helpers/page.ts new file mode 100644 index 000000000..eb5fd0739 --- /dev/null +++ b/ui/playwright/helpers/page.ts @@ -0,0 +1,37 @@ +// Page-level driver helpers. Backend-agnostic — they assert on rendered DOM +// (roles/text), so they work regardless of how data is mocked. + +import { expect, type Page } from "@playwright/test"; + +/** Navigate to a path and optionally assert the page's

is the expected title. */ +export async function loadPage( + page: Page, + path: string, + opts: { heading?: string } = {}, +): Promise { + await page.goto(path); + if (opts.heading) { + await expect(page.getByRole("heading", { level: 1, name: opts.heading })).toBeVisible(); + } +} + +/** Assert the ErrorState component ("Error Encountered") is not on the page. */ +export async function expectNoErrors(page: Page): Promise { + await expect(page.getByText("Error Encountered")).toHaveCount(0); +} + +type ToastType = "success" | "error" | "warning" | "info"; + +/** + * Assert a sonner toast with the given text is visible. Toasts auto-dismiss, so + * call this promptly after the triggering action. Pass `type` to also assert the + * severity (data-type on the toast
  • ). + */ +export async function expectToast( + page: Page, + text: string | RegExp, + opts: { type?: ToastType } = {}, +): Promise { + const selector = opts.type ? `[data-sonner-toast][data-type="${opts.type}"]` : "[data-sonner-toast]"; + await expect(page.locator(selector).filter({ hasText: text })).toBeVisible(); +} diff --git a/ui/playwright/mocks/control.ts b/ui/playwright/mocks/control.ts new file mode 100644 index 000000000..19368c525 --- /dev/null +++ b/ui/playwright/mocks/control.ts @@ -0,0 +1,73 @@ +// Semantic control seam over the stub's /__mock/* HTTP endpoints. Specs express +// intent (mock.noAgents()) instead of transport (POST /__mock/scenario ...), so +// if the mock mechanism ever changes, only this file does. +// +// Used via the `mock` fixture in playwright/fixtures/test.ts. Set scenarios +// BEFORE page.goto — fetch is cache:"no-store" and each test gets a fresh +// context, so a fresh navigation re-fetches and picks up the override. + +import type { APIRequestContext } from "@playwright/test"; +import { ok } from "./data"; +import type { AgentResponse } from "@/types"; + +const STUB_URL = "http://127.0.0.1:8899"; + +type EndpointSlug = + | "agents" + | "models" + | "modelconfigs" + | "namespaces" + | "toolservers" + | "tools" + | "substrate"; + +interface ScenarioOptions { + status?: number; + body?: unknown; +} + +export interface MockBackend { + /** Clear all scenario overrides (back to the happy path). */ + reset(): Promise; + /** Low-level override for any endpoint. */ + setScenario(endpoint: EndpointSlug, opts: ScenarioOptions): Promise; + + setAgents(agents: AgentResponse[]): Promise; + noAgents(): Promise; + agentsError(status?: number): Promise; + + noModelConfigs(): Promise; + modelConfigsError(status?: number): Promise; + + noToolServers(): Promise; + toolServersError(status?: number): Promise; +} + +export function makeMock(request: APIRequestContext): MockBackend { + const setScenario: MockBackend["setScenario"] = async (endpoint, opts) => { + await request.post(`${STUB_URL}/__mock/scenario`, { + data: { endpoint, status: opts.status, body: opts.body }, + }); + }; + + const errorBody = (endpoint: EndpointSlug) => ({ error: `stubbed ${endpoint} error` }); + + return { + reset: async () => { + await request.post(`${STUB_URL}/__mock/reset`); + }, + setScenario, + + setAgents: (agents) => setScenario("agents", { body: ok(agents) }), + noAgents: () => setScenario("agents", { body: ok([]) }), + agentsError: (status = 500) => setScenario("agents", { status, body: errorBody("agents") }), + + noModelConfigs: () => setScenario("modelconfigs", { body: ok([]) }), + modelConfigsError: (status = 500) => + setScenario("modelconfigs", { status, body: errorBody("modelconfigs") }), + + noToolServers: () => setScenario("toolservers", { body: ok([]) }), + toolServersError: (status = 500) => + setScenario("toolservers", { status, body: errorBody("toolservers") }), + }; +} diff --git a/ui/playwright/mocks/data.ts b/ui/playwright/mocks/data.ts new file mode 100644 index 000000000..405da30ea --- /dev/null +++ b/ui/playwright/mocks/data.ts @@ -0,0 +1,87 @@ +// Typed, spec-side mock builders. Use these in specs (and the control seam in +// control.ts) to construct payloads for assertions or to POST scenarios to the +// stub's /__mock/scenario endpoint. The stub backend's runtime happy-path lives +// in server.mjs; keep the shapes here in sync with it. +// +// Typed against the app's own types (via the @/ alias, see playwright/tsconfig.json) +// so drift from the real API surface fails at compile time. + +import type { + AgentResponse, + BaseResponse, + ModelConfig, + ProviderModelsResponse, + RemoteMCPServerResponse, + ToolsResponse, +} from "@/types"; + +export type Namespace = { name: string; status: string }; +export type ToolServerListEntry = RemoteMCPServerResponse; + +/** Wrap data in the backend's success envelope: `{ message, data }`. */ +export function ok(data: T, message = "OK"): BaseResponse { + return { message, data }; +} + +export function mockAgentResponse( + overrides: Partial = {}, +): AgentResponse { + return { + id: "1", + agent: { + metadata: { name: "e2e-agent", namespace: "default" }, + spec: { type: "Declarative", description: "Seeded E2E agent" }, + }, + model: "gpt-4o", + modelProvider: "OpenAI", + modelConfigRef: "default/default-model-config", + tools: [], + deploymentReady: true, + accepted: true, + ...overrides, + }; +} + +export function mockModelsResponse(): ProviderModelsResponse { + return { openai: [{ name: "gpt-4o", function_calling: true }] }; +} + +export function mockModelConfig( + overrides: Partial = {}, +): ModelConfig { + return { + ref: "default/default-model-config", + spec: { model: "gpt-4o", provider: "OpenAI" }, + ...overrides, + }; +} + +export function mockNamespace(overrides: Partial = {}): Namespace { + return { name: "default", status: "Active", ...overrides }; +} + +export function mockToolServer( + overrides: Partial = {}, +): ToolServerListEntry { + return { + ref: "default/e2e-tool-server", + groupKind: "RemoteMCPServer.kagent.dev", + discoveredTools: [], + ...overrides, + }; +} + +export function mockTool( + overrides: Partial = {}, +): ToolsResponse { + return { + id: "e2e-tool", + server_name: "e2e-tool-server", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + deleted_at: "", + description: "Seeded E2E tool", + group_kind: "RemoteMCPServer.kagent.dev", + ...overrides, + }; +} diff --git a/ui/playwright/mocks/server.mjs b/ui/playwright/mocks/server.mjs new file mode 100644 index 000000000..b9445d200 --- /dev/null +++ b/ui/playwright/mocks/server.mjs @@ -0,0 +1,178 @@ +// Standalone stub backend for Playwright E2E. +// +// The kagent UI fetches data server-side (Next.js server actions), so the +// backend fetch happens in the Node process, not the browser — browser-level +// page.route cannot mock it. Instead we run this tiny HTTP server and point +// Next at it via BACKEND_INTERNAL_URL (see playwright.config.ts). getBackendUrl() +// in src/lib/utils.ts checks BACKEND_INTERNAL_URL first, so every /api/* call +// lands here. +// +// Dependency-free (Node built-in http only) so it runs without a TS/build step. +// Payloads mirror the shapes in src/types/index.ts; the typed spec-side builders +// live in playwright/mocks/data.ts and the semantic control seam in control.ts. +// +// Scenarios: default is the happy path below. A test can override any endpoint +// via POST /__mock/scenario { endpoint, status?, body? } (used by control.ts to +// force empty/error/custom responses) and clear all overrides via POST +// /__mock/reset. State is a single in-memory map — correct only while the runner +// is serial (workers: 1); see playwright/README.md. + +import { createServer } from "node:http"; + +const PORT = Number(process.env.STUB_PORT ?? 8899); + +// region Payloads (happy path) + +// Success envelope used by the Go backend: { message, data }. +const ok = (data, message = "OK") => ({ message, data }); + +const agent = { + id: "1", + agent: { + metadata: { name: "e2e-agent", namespace: "default" }, + spec: { type: "Declarative", description: "Seeded E2E agent" }, + }, + model: "gpt-4o", + modelProvider: "OpenAI", + modelConfigRef: "default/default-model-config", + tools: [], + deploymentReady: true, + accepted: true, +}; + +const modelConfig = { + ref: "default/default-model-config", + spec: { model: "gpt-4o", provider: "OpenAI" }, +}; + +const toolServer = { + ref: "default/e2e-tool-server", + groupKind: "RemoteMCPServer.kagent.dev", + discoveredTools: [], +}; + +const tool = { + id: "e2e-tool", + server_name: "e2e-tool-server", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + deleted_at: "", + description: "Seeded E2E tool", + group_kind: "RemoteMCPServer.kagent.dev", +}; + +const substrateStatus = { + enabled: false, + workerPools: [], + actorTemplates: [], + actors: [], + workers: [], +}; + +// Default happy-path body per endpoint slug. +const DEFAULTS = { + agents: () => ok([agent], "Successfully fetched agents"), + models: () => ok({ openai: [{ name: "gpt-4o", function_calling: true }] }), + modelconfigs: () => ok([modelConfig]), + namespaces: () => ok([{ name: "default", status: "Active" }], "Namespaces fetched successfully"), + toolservers: () => ok([toolServer]), + tools: () => ok([tool]), + substrate: () => ok(substrateStatus, "Substrate status fetched"), +}; + +// GET pathname -> endpoint slug (query string stripped before lookup). +const PATH_TO_SLUG = { + "/api/agents": "agents", + "/api/models": "models", + "/api/modelconfigs": "modelconfigs", + "/api/namespaces": "namespaces", + "/api/toolservers": "toolservers", + "/api/tools": "tools", + "/api/substrate/status": "substrate", +}; + +// endregion + +// region Scenario state + +// slug -> { status, body } override set by POST /__mock/scenario. Empty = happy path. +let overrides = {}; + +// endregion + +// region Server + +const json = (res, status, body) => { + const payload = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); +}; + +const readJsonBody = (req) => + new Promise((resolve) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch { + resolve(null); + } + }); + req.on("error", () => resolve(null)); + }); + +const server = createServer(async (req, res) => { + // req.method/url are typed string|undefined; default them so a malformed + // request can't throw on the split below. + const method = req.method ?? "GET"; + const url = req.url ?? "/"; + const pathname = url.split("?")[0]; + + // Control + health endpoints. + if (pathname === "/__mock/health") return json(res, 200, { status: "ok" }); + + if (method === "POST" && pathname === "/__mock/reset") { + overrides = {}; + return json(res, 200, { status: "reset" }); + } + + if (method === "POST" && pathname === "/__mock/scenario") { + const body = await readJsonBody(req); + if (!body || typeof body.endpoint !== "string") { + return json(res, 400, { error: "scenario requires { endpoint: string }" }); + } + overrides[body.endpoint] = { status: body.status ?? 200, body: body.body }; + console.log(`[stub] scenario set: ${body.endpoint} -> ${body.status ?? 200}`); + return json(res, 200, { status: "scenario-set", endpoint: body.endpoint }); + } + + if (method === "GET") { + const slug = PATH_TO_SLUG[pathname]; + if (slug) { + const override = overrides[slug]; + if (override) { + console.log(`[stub] ${method} ${url} -> ${override.status} (override)`); + return json(res, override.status, override.body ?? {}); + } + console.log(`[stub] ${method} ${url} -> 200`); + return json(res, 200, DEFAULTS[slug]()); + } + } + + // Anything unmocked is a real gap — make it loud so we notice leaks. + console.warn(`[stub] UNHANDLED ${method} ${url} -> 404`); + return json(res, 404, { error: `No stub for ${method} ${pathname}` }); +}); + +server.listen(PORT, "127.0.0.1", () => { + console.log(`[stub] kagent mock backend listening on http://127.0.0.1:${PORT}`); +}); + +// endregion diff --git a/ui/playwright/tests/home.spec.ts b/ui/playwright/tests/home.spec.ts new file mode 100644 index 000000000..6570116df --- /dev/null +++ b/ui/playwright/tests/home.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "../fixtures/test"; +import { loadPage, expectNoErrors } from "../helpers/page"; + +// Exercises the scenario-override engine (via the `mock` fixture) across the +// three states of the Agents list. Also the first real consumer of page.ts. +test.describe("home / agents list", () => { + test("renders the agent (happy path)", async ({ page }) => { + await loadPage(page, "/", { heading: "Agents" }); + await expect(page.getByText("e2e-agent")).toBeVisible(); + await expectNoErrors(page); + }); + + test("shows the empty state when there are no agents", async ({ page, mock }) => { + await mock.noAgents(); + await loadPage(page, "/", { heading: "Agents" }); + await expect(page.getByRole("heading", { level: 2, name: "No agents yet" })).toBeVisible(); + }); + + test("shows the error state when the agents request fails", async ({ page, mock }) => { + await mock.agentsError(); + await page.goto("/"); + await expect(page.getByText("Error Encountered")).toBeVisible(); + // ErrorState early-returns, so the page

    never renders. + await expect(page.getByRole("heading", { level: 1, name: "Agents" })).toHaveCount(0); + }); +}); diff --git a/ui/playwright/tests/nav.spec.ts b/ui/playwright/tests/nav.spec.ts new file mode 100644 index 000000000..8c4f6164b --- /dev/null +++ b/ui/playwright/tests/nav.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "../fixtures/test"; +import { loadPage } from "../helpers/page"; +import { gotoView } from "../helpers/nav"; + +// Exercises nav.ts: dropdown-based navigation between listing pages, asserting +// each destination's

    . Covers real client-side routing (not just page loads). +test("navigates between sections via the View menu", async ({ page }) => { + await loadPage(page, "/", { heading: "Agents" }); + + await gotoView(page, "Models", "**/models"); + await expect(page.getByRole("heading", { level: 1, name: "Models" })).toBeVisible(); + + await gotoView(page, "MCP & tools", "**/mcp"); + await expect(page.getByRole("heading", { level: 1, name: "MCP & tools" })).toBeVisible(); +}); diff --git a/ui/playwright/tests/smoke.spec.ts b/ui/playwright/tests/smoke.spec.ts new file mode 100644 index 000000000..3bbc1eba5 --- /dev/null +++ b/ui/playwright/tests/smoke.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "../fixtures/test"; + +// Stage 0 smoke test: proves the whole rig works end to end — Playwright boots +// the stub backend + `next dev`, the server-side fetch is redirected to the stub +// (via BACKEND_INTERNAL_URL), and the home page renders the mocked agent list. +test("home page renders the Agents list against the mocked backend", async ({ page }) => { + const fatalErrors: string[] = []; + page.on("pageerror", (err) => fatalErrors.push(err.message)); + + await page.goto("/"); + + // PageHeader renders

    Agents

    on successful load + // (present in both populated and empty states; absent in loading/error states). + await expect(page.getByRole("heading", { level: 1, name: "Agents" })).toBeVisible(); + + // ErrorState (early-return branch) renders this heading — it must not appear. + await expect(page.getByText("Error Encountered")).toHaveCount(0); + + expect(fatalErrors, `uncaught page errors: ${fatalErrors.join("; ")}`).toEqual([]); +}); diff --git a/ui/playwright/tsconfig.json b/ui/playwright/tsconfig.json new file mode 100644 index 000000000..b84b7846f --- /dev/null +++ b/ui/playwright/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts"] +}