From b83445bd0437bc00876e091012870a0ba43509f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Logro=C3=B1o=20Guerrero?= Date: Wed, 8 Jul 2026 11:32:18 -0400 Subject: [PATCH 1/7] config for playwright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carlos Logroño Guerrero --- .github/workflows/ui-playwright.yaml | 57 +++++++++++++ ui/.gitignore | 5 ++ ui/package-lock.json | 36 +++++--- ui/package.json | 4 + ui/playwright.config.ts | 60 ++++++++++++++ ui/playwright/README.md | 87 +++++++++++++++++++ ui/playwright/fixtures/test.ts | 26 ++++++ ui/playwright/mocks/data.ts | 72 ++++++++++++++++ ui/playwright/mocks/server.mjs | 120 +++++++++++++++++++++++++++ ui/playwright/tests/smoke.spec.ts | 20 +++++ ui/playwright/tsconfig.json | 7 ++ 11 files changed, 484 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ui-playwright.yaml create mode 100644 ui/playwright.config.ts create mode 100644 ui/playwright/README.md create mode 100644 ui/playwright/fixtures/test.ts create mode 100644 ui/playwright/mocks/data.ts create mode 100644 ui/playwright/mocks/server.mjs create mode 100644 ui/playwright/tests/smoke.spec.ts create mode 100644 ui/playwright/tsconfig.json diff --git a/.github/workflows/ui-playwright.yaml b/.github/workflows/ui-playwright.yaml new file mode 100644 index 0000000000..d1ccabe4fd --- /dev/null +++ b/.github/workflows/ui-playwright.yaml @@ -0,0 +1,57 @@ +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 + pull-requests: write + 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 9352a9952b..adb14e3028 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 545766788a..788e11f5b0 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 aeda2e3a43..55751a7658 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 0000000000..6b6e33ecdd --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,60 @@ +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", + fullyParallel: true, + forbidOnly: CI, + retries: CI ? 1 : 0, + // Single worker for now: one shared stub backend + one Next server. Per-test + // data isolation is a Stage 1 concern (see README). + workers: CI ? 1 : undefined, + 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", + }, + { + command: "npm run dev", + url: APP_URL, + reuseExistingServer: !CI, + 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 0000000000..96e8f55a1b --- /dev/null +++ b/ui/playwright/README.md @@ -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. diff --git a/ui/playwright/fixtures/test.ts b/ui/playwright/fixtures/test.ts new file mode 100644 index 0000000000..80e650aaa6 --- /dev/null +++ b/ui/playwright/fixtures/test.ts @@ -0,0 +1,26 @@ +// 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) => { + await request.post(`${STUB_URL}/__mock/reset`).catch(() => { + // Stub not reachable (e.g. reused external server) — non-fatal for Stage 0. + }); + // 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 }; diff --git a/ui/playwright/mocks/data.ts b/ui/playwright/mocks/data.ts new file mode 100644 index 0000000000..8d0ba6e126 --- /dev/null +++ b/ui/playwright/mocks/data.ts @@ -0,0 +1,72 @@ +// Typed, spec-side mock builders. Use these in specs to construct payloads for +// assertions or (from Stage 1) 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, + ModelConfig, + ProviderModelsResponse, + ToolsResponse, +} from "@/types"; + +export type Namespace = { name: string; status: string }; +export type ToolServerListEntry = { ref: string; groupKind: string; discoveredTools: unknown[] }; + +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 0000000000..43cead2954 --- /dev/null +++ b/ui/playwright/mocks/server.mjs @@ -0,0 +1,120 @@ +// 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. Keep the two in sync until Stage 1 unifies +// them behind the /__mock/scenario control endpoint. + +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: [], +}; + +// GET route table keyed by pathname (query string stripped before lookup). +const GET_ROUTES = { + "/api/agents": () => ok([agent], "Successfully fetched agents"), + "/api/models": () => ok({ openai: [{ name: "gpt-4o", function_calling: true }] }), + "/api/modelconfigs": () => ok([modelConfig]), + "/api/namespaces": () => ok([{ name: "default", status: "Active" }], "Namespaces fetched successfully"), + "/api/toolservers": () => ok([toolServer]), + "/api/tools": () => ok([tool]), + "/api/substrate/status": () => ok(substrateStatus, "Substrate status fetched"), +}; + +// 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 server = createServer((req, res) => { + const { method, url } = req; + const pathname = url.split("?")[0]; + + // Control + health endpoints. + if (pathname === "/__mock/health") return json(res, 200, { status: "ok" }); + if (method === "POST" && pathname === "/__mock/reset") { + // No scenario state yet — happy path only. Wired up in Stage 1. + return json(res, 200, { status: "reset" }); + } + if (method === "POST" && pathname === "/__mock/scenario") { + // Placeholder for per-test overrides (Stage 1). + return json(res, 200, { status: "noop" }); + } + + if (method === "GET" && GET_ROUTES[pathname]) { + console.log(`[stub] ${method} ${url} -> 200`); + return json(res, 200, GET_ROUTES[pathname]()); + } + + // 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/smoke.spec.ts b/ui/playwright/tests/smoke.spec.ts new file mode 100644 index 0000000000..3bbc1eba53 --- /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 0000000000..fd1b9b7035 --- /dev/null +++ b/ui/playwright/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts", "**/*.mjs"] +} From 6f3c96fd0ae0bcaa9b27f270c3c997a2937de266 Mon Sep 17 00:00:00 2001 From: cjlg-soloio Date: Wed, 8 Jul 2026 12:31:07 -0400 Subject: [PATCH 2/7] Run with 1 worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: cjlg-soloio Signed-off-by: Carlos Logroño Guerrero --- ui/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 6b6e33ecdd..235a078091 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ retries: CI ? 1 : 0, // Single worker for now: one shared stub backend + one Next server. Per-test // data isolation is a Stage 1 concern (see README). - workers: CI ? 1 : undefined, + workers: 1, timeout: 30_000, expect: { timeout: 10_000 }, reporter: [["html", { open: "never" }], ["list"]], From 6f1fd4b29f665e52809eedfecdc14b3eaa989a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Logro=C3=B1o=20Guerrero?= Date: Wed, 8 Jul 2026 12:34:40 -0400 Subject: [PATCH 3/7] fix: resolve Copilot review comments on Playwright config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Force single Playwright worker unconditionally (shared stub + Next server) - Default req.method/req.url before parsing in the stub server - Fail fast on stub reset failure in CI; stay non-fatal locally - Drop unused pull-requests:write permission from the workflow Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Carlos Logroño Guerrero --- .github/workflows/ui-playwright.yaml | 1 - ui/playwright/fixtures/test.ts | 9 ++++++--- ui/playwright/mocks/server.mjs | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ui-playwright.yaml b/.github/workflows/ui-playwright.yaml index d1ccabe4fd..90b1db9e39 100644 --- a/.github/workflows/ui-playwright.yaml +++ b/.github/workflows/ui-playwright.yaml @@ -20,7 +20,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/ui/playwright/fixtures/test.ts b/ui/playwright/fixtures/test.ts index 80e650aaa6..f3f95cb347 100644 --- a/ui/playwright/fixtures/test.ts +++ b/ui/playwright/fixtures/test.ts @@ -11,9 +11,12 @@ const STUB_URL = "http://127.0.0.1:8899"; export const test = base.extend({ page: async ({ page, request }, use) => { - await request.post(`${STUB_URL}/__mock/reset`).catch(() => { - // Stub not reachable (e.g. reused external server) — non-fatal for Stage 0. - }); + 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(() => { diff --git a/ui/playwright/mocks/server.mjs b/ui/playwright/mocks/server.mjs index 43cead2954..54d3d60ae4 100644 --- a/ui/playwright/mocks/server.mjs +++ b/ui/playwright/mocks/server.mjs @@ -89,7 +89,8 @@ const json = (res, status, body) => { }; const server = createServer((req, res) => { - const { method, url } = req; + const method = req.method ?? "GET"; + const url = req.url ?? "/"; const pathname = url.split("?")[0]; // Control + health endpoints. From be5ee4ed46d71f9bb00abfd63dd1d1a996e0eae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Logro=C3=B1o=20Guerrero?= Date: Wed, 8 Jul 2026 12:42:54 -0400 Subject: [PATCH 4/7] docs: explain defensive req.method/url defaults in stub server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Carlos Logroño Guerrero --- ui/playwright/mocks/server.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/playwright/mocks/server.mjs b/ui/playwright/mocks/server.mjs index 54d3d60ae4..8181dc6dcd 100644 --- a/ui/playwright/mocks/server.mjs +++ b/ui/playwright/mocks/server.mjs @@ -89,6 +89,8 @@ const json = (res, status, body) => { }; const server = createServer((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]; From 5d1b6171268081594532fce58713043f95d7ccfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Logro=C3=B1o=20Guerrero?= Date: Wed, 8 Jul 2026 15:59:59 -0400 Subject: [PATCH 5/7] test(ui): disable fullyParallel until Stage 1 isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs fullyParallel with workers: 1 so both stay off until per-test data isolation lands, preventing tests from silently racing against the shared stub backend if the worker count is raised. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Carlos Logroño Guerrero --- ui/playwright.config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 235a078091..d907923a15 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -21,11 +21,13 @@ const APP_URL = "http://localhost:8001"; export default defineConfig({ testDir: "./playwright/tests", outputDir: "./playwright/test-results", - fullyParallel: true, + // 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, - // Single worker for now: one shared stub backend + one Next server. Per-test - // data isolation is a Stage 1 concern (see README). workers: 1, timeout: 30_000, expect: { timeout: 10_000 }, From 6d5fdb42617fd656cd3b19622d552490288de048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Logro=C3=B1o=20Guerrero?= Date: Wed, 8 Jul 2026 17:22:18 -0400 Subject: [PATCH 6/7] fix(ui-test): address review comments on Playwright config and mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin STUB_PORT on the stub webServer so a shell-set STUB_PORT can't misbind it - Stop reusing the dev server so BACKEND_INTERNAL_URL always applies (no silent stub bypass) - Type ToolServerListEntry as the real RemoteMCPServerResponse for compile-time drift detection - Drop no-op **/*.mjs from the playwright tsconfig include Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Carlos Logroño Guerrero --- ui/playwright.config.ts | 9 ++++++++- ui/playwright/mocks/data.ts | 19 ++++++++++++++----- ui/playwright/tsconfig.json | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index d907923a15..ac77c405b7 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -47,11 +47,18 @@ export default defineConfig({ 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, - reuseExistingServer: !CI, + // 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. diff --git a/ui/playwright/mocks/data.ts b/ui/playwright/mocks/data.ts index 8d0ba6e126..3fa60f7418 100644 --- a/ui/playwright/mocks/data.ts +++ b/ui/playwright/mocks/data.ts @@ -10,13 +10,16 @@ import type { AgentResponse, ModelConfig, ProviderModelsResponse, + RemoteMCPServerResponse, ToolsResponse, } from "@/types"; export type Namespace = { name: string; status: string }; -export type ToolServerListEntry = { ref: string; groupKind: string; discoveredTools: unknown[] }; +export type ToolServerListEntry = RemoteMCPServerResponse; -export function mockAgentResponse(overrides: Partial = {}): AgentResponse { +export function mockAgentResponse( + overrides: Partial = {}, +): AgentResponse { return { id: "1", agent: { @@ -37,7 +40,9 @@ export function mockModelsResponse(): ProviderModelsResponse { return { openai: [{ name: "gpt-4o", function_calling: true }] }; } -export function mockModelConfig(overrides: Partial = {}): ModelConfig { +export function mockModelConfig( + overrides: Partial = {}, +): ModelConfig { return { ref: "default/default-model-config", spec: { model: "gpt-4o", provider: "OpenAI" }, @@ -49,7 +54,9 @@ export function mockNamespace(overrides: Partial = {}): Namespace { return { name: "default", status: "Active", ...overrides }; } -export function mockToolServer(overrides: Partial = {}): ToolServerListEntry { +export function mockToolServer( + overrides: Partial = {}, +): ToolServerListEntry { return { ref: "default/e2e-tool-server", groupKind: "RemoteMCPServer.kagent.dev", @@ -58,7 +65,9 @@ export function mockToolServer(overrides: Partial = {}): To }; } -export function mockTool(overrides: Partial = {}): ToolsResponse { +export function mockTool( + overrides: Partial = {}, +): ToolsResponse { return { id: "e2e-tool", server_name: "e2e-tool-server", diff --git a/ui/playwright/tsconfig.json b/ui/playwright/tsconfig.json index fd1b9b7035..b84b7846f8 100644 --- a/ui/playwright/tsconfig.json +++ b/ui/playwright/tsconfig.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node", "@playwright/test"] }, - "include": ["**/*.ts", "**/*.mjs"] + "include": ["**/*.ts"] } From a9077652a48fd50a3bd8cefe31f01e6d4f55fa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Logro=C3=B1o=20Guerrero?= Date: Thu, 9 Jul 2026 13:29:15 -0400 Subject: [PATCH 7/7] test(ui): Playwright helpers + per-test scenario overrides (stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the Stage 0 foundation to unlock non-happy-path testing and give specs a small, proven driver surface: - Scenario-override engine in the stub backend: POST /__mock/scenario sets a per-endpoint { status, body } override; /__mock/reset clears them. Default stays the happy path. - Typed control seam (mocks/control.ts) surfaced as a `mock` fixture (mock.noAgents(), mock.agentsError(), ...); the fixture resets the stub before every test so scenarios never leak between tests. - page.ts (loadPage, expectNoErrors, expectToast) and nav.ts (dropdown-aware gotoView/gotoCreate) driver helpers. - home spec (happy / empty / error) proving the engine, and a nav spec proving dropdown routing. Form/select/dialog helpers are deferred to Stage 2 (built demand-driven with the create-agent flow). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Carlos Logroño Guerrero --- ui/playwright/README.md | 23 ++++---- ui/playwright/fixtures/test.ts | 33 +++++++----- ui/playwright/helpers/nav.ts | 40 ++++++++++++++ ui/playwright/helpers/page.ts | 37 +++++++++++++ ui/playwright/mocks/control.ts | 73 +++++++++++++++++++++++++ ui/playwright/mocks/data.ts | 14 +++-- ui/playwright/mocks/server.mjs | 91 +++++++++++++++++++++++++------- ui/playwright/tests/home.spec.ts | 26 +++++++++ ui/playwright/tests/nav.spec.ts | 15 ++++++ 9 files changed, 308 insertions(+), 44 deletions(-) create mode 100644 ui/playwright/helpers/nav.ts create mode 100644 ui/playwright/helpers/page.ts create mode 100644 ui/playwright/mocks/control.ts create mode 100644 ui/playwright/tests/home.spec.ts create mode 100644 ui/playwright/tests/nav.spec.ts diff --git a/ui/playwright/README.md b/ui/playwright/README.md index 96e8f55a1b..662bd448f0 100644 --- a/ui/playwright/README.md +++ b/ui/playwright/README.md @@ -36,12 +36,13 @@ browser-originated and `page.route`-able — used in the chat spec (Stage 2). ``` playwright/ tests/ # *.spec.ts, one per feature area - helpers/ # (Stage 1) reusable drivers: page, forms, select, dialog, nav + helpers/ # reusable drivers: page, nav (forms/select/dialog land with Stage 2) mocks/ - server.mjs # stub backend (runtime source of happy-path data) - data.ts # typed spec-side builders (mirror server.mjs shapes) + 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 in every spec + test.ts # import { test, expect } from here; provides the `mock` fixture tsconfig.json ``` @@ -79,9 +80,11 @@ BACKEND_INTERNAL_URL=http://127.0.0.1:8899/api npm run dev ## 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. +- **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 index f3f95cb347..10969e7de1 100644 --- a/ui/playwright/fixtures/test.ts +++ b/ui/playwright/fixtures/test.ts @@ -1,28 +1,37 @@ // 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. +// 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"; -const STUB_URL = "http://127.0.0.1:8899"; - -export const test = base.extend({ - page: async ({ page, request }, use) => { +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 request.post(`${STUB_URL}/__mock/reset`); + await mock.reset(); } catch (err) { - // Stub should always be reachable in CI (managed via webServer). Fail fast. 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 use(page); + await run(page); }, }); diff --git a/ui/playwright/helpers/nav.ts b/ui/playwright/helpers/nav.ts new file mode 100644 index 0000000000..a6b27b5ff0 --- /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 0000000000..eb5fd07390 --- /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 0000000000..19368c525b --- /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 index 3fa60f7418..405da30eab 100644 --- a/ui/playwright/mocks/data.ts +++ b/ui/playwright/mocks/data.ts @@ -1,13 +1,14 @@ -// Typed, spec-side mock builders. Use these in specs to construct payloads for -// assertions or (from Stage 1) 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, 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, @@ -17,6 +18,11 @@ import type { 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 { diff --git a/ui/playwright/mocks/server.mjs b/ui/playwright/mocks/server.mjs index 8181dc6dcd..b9445d200c 100644 --- a/ui/playwright/mocks/server.mjs +++ b/ui/playwright/mocks/server.mjs @@ -9,8 +9,13 @@ // // 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. Keep the two in sync until Stage 1 unifies -// them behind the /__mock/scenario control endpoint. +// 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"; @@ -64,19 +69,37 @@ const substrateStatus = { workers: [], }; -// GET route table keyed by pathname (query string stripped before lookup). -const GET_ROUTES = { - "/api/agents": () => ok([agent], "Successfully fetched agents"), - "/api/models": () => ok({ openai: [{ name: "gpt-4o", function_calling: true }] }), - "/api/modelconfigs": () => ok([modelConfig]), - "/api/namespaces": () => ok([{ name: "default", status: "Active" }], "Namespaces fetched successfully"), - "/api/toolservers": () => ok([toolServer]), - "/api/tools": () => ok([tool]), - "/api/substrate/status": () => ok(substrateStatus, "Substrate status fetched"), +// 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) => { @@ -88,7 +111,24 @@ const json = (res, status, body) => { res.end(payload); }; -const server = createServer((req, res) => { +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"; @@ -97,18 +137,33 @@ const server = createServer((req, res) => { // Control + health endpoints. if (pathname === "/__mock/health") return json(res, 200, { status: "ok" }); + if (method === "POST" && pathname === "/__mock/reset") { - // No scenario state yet — happy path only. Wired up in Stage 1. + overrides = {}; return json(res, 200, { status: "reset" }); } + if (method === "POST" && pathname === "/__mock/scenario") { - // Placeholder for per-test overrides (Stage 1). - return json(res, 200, { status: "noop" }); + 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" && GET_ROUTES[pathname]) { - console.log(`[stub] ${method} ${url} -> 200`); - return json(res, 200, GET_ROUTES[pathname]()); + 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. diff --git a/ui/playwright/tests/home.spec.ts b/ui/playwright/tests/home.spec.ts new file mode 100644 index 0000000000..6570116df9 --- /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 0000000000..8c4f6164b3 --- /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(); +});