Skip to content
Open
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With workers: 1 this is a no-op, but raising workers before Stage 1 isolation lands will cause tests to race against the shared stub. Consider setting this to false until then.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — Fixed in 08b3cfc — set fullyParallel: false to match workers: 1, so both stay off until Stage 1 isolation lands. Thanks @mesutoezdil !

@mesutoezdil mesutoezdil Jul 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — Fixed in 08b3cfc — set fullyParallel: false to match workers: 1, so both stay off until Stage 1 isolation lands. Thanks @mesutoezdil !

pls write your own sentences when interacting with me, not llm outputs. thx!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my bad! I will keep it human moving forward 🤝

// 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

server.mjs reads STUB_PORT from env but this block doesn't pin it, so a shell-set STUB_PORT makes the stub bind to the wrong port and the health check times out

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pinned STUB_PORT in the stub's webServer env so it always goes to the health endpoint or whatever BACKEND_INTERNAL_URL is set to

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`,
},
},
],
});
87 changes: 87 additions & 0 deletions ui/playwright/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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/ # (Stage 1) reusable drivers: page, forms, select, dialog, nav
mocks/
server.mjs # stub backend (runtime source of happy-path data)
data.ts # typed spec-side builders (mirror server.mjs shapes)
fixtures/
test.ts # import { test, expect } from here in every spec
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:** helper/driver library (`helpers/*`) + per-test scenario overrides
via the stub's `/__mock/scenario` endpoint. Prefer stateless scenario selection
keyed by request content (e.g. `?namespace=empty-ns` → `[]`); fall back to the
control endpoint with `workers: 1` for endpoints lacking a discriminator.
- **Stage 2:** feature flows (gap-scoped), ordered by importance —
Create Agent → Chat/session (A2A SSE mock) → Models → MCP → Onboarding completion.
29 changes: 29 additions & 0 deletions ui/playwright/fixtures/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Shared test fixture. Import { test, expect } from here in every spec (not
// directly from @playwright/test) so all specs get the same setup.
//
// For now it resets the stub backend before each test. Reset is a no-op until
// Stage 1 adds per-test scenarios, but establishing the pattern now means specs
// don't change when scenarios land.

import { test as base, expect } from "@playwright/test";

const STUB_URL = "http://127.0.0.1:8899";

export const test = base.extend({
page: async ({ page, request }, use) => {
try {
await request.post(`${STUB_URL}/__mock/reset`);
} catch (err) {
// Stub should always be reachable in CI (managed via webServer). Fail fast.
if (process.env.CI) throw err;
}
// 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 use(page);
},
});

export { expect };
Loading