Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ui-playwright.yaml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,8 @@ next-env.d.ts

# storybook
/storybook-static

# playwright
/playwright-report
/playwright/test-results
/playwright/.cache
36 changes: 26 additions & 10 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions ui/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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`,
},
},
],
});
90 changes: 90 additions & 0 deletions ui/playwright/README.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions ui/playwright/fixtures/test.ts
Original file line number Diff line number Diff line change
@@ -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 };
40 changes: 40 additions & 0 deletions ui/playwright/helpers/nav.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await page.getByRole("button", { name: trigger, exact: true }).click();
}

async function chooseFrom(
page: Page,
trigger: "Create" | "View",
item: string,
urlGlob?: string | RegExp,
): Promise<void> {
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<void> {
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<void> {
return chooseFrom(page, "Create", item, urlGlob);
}

/** Click the direct "Home" link in the header. */
export async function gotoHome(page: Page): Promise<void> {
await page.getByRole("link", { name: "Home" }).first().click();
await page.waitForURL(/\/(agents)?$/);
}
Loading