Files
kandev/apps/web/e2e
Luan Martins 1a467b969f fix(web): wire permission approval UI for Kandev MCP tools (#1037)
* fix(web): wire permission approval UI for Kandev MCP tools

KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.

Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.

* fix(web): collapse tool + permission status into resolved Kandev row

After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.

Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.

* fix(web): keep Kandev tool status independent of permission decision

The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".

Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.

Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.

* test(web): tolerate duplicate-render backend race in Kandev MCP e2e

CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.

The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.

* fix: persist Deny clicks as rejected, not approved

Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").

Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.

* test(web): scope Kandev MCP permission e2e to the Kandev row

Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.

Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.

* chore(orchestrator): log rejected flag in RespondToPermission

Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.
2026-05-26 00:22:45 +01:00
..
2026-03-02 08:54:58 +00:00
2026-05-25 10:04:40 +01:00

Kandev Web — E2E Test Suite

Playwright-based end-to-end tests. Each Playwright worker spawns its own real Go backend (no mocks of internal services) on isolated ports and a real Next.js standalone frontend, then drives a real Chromium against them.

Project layout

Folder What's in it
tests/ Spec files, grouped by feature (chat/, docker/, git/, integrations/, kanban/, pr/, search/, session/, ssh/, layout/, …).
fixtures/ Worker-scoped fixtures that own the backend lifecycle (backend.ts, docker-test-base.ts, ssh-test-base.ts, test-base.ts).
helpers/ Reusable building blocks for specs (api-client.ts, docker.ts, ssh.ts, git-helper.ts, ws-capture.ts, …).
pages/ Page Objects (one class per top-level UI surface — SessionPage, KanbanPage, JiraSettingsPage, SSHSettingsPage, …).
playwright.config.ts Project definitions, timeouts, sharding config.
global-setup.ts Pre-flight checks for required binaries (kandev, mock-agent, standalone next build).

Playwright projects

The suite is split into three projects. Pick one with --project=<name>.

chromium (default)

The everyday surface — runs in every CI shard. Excludes the heavyweight containers specs and the mobile specs.

pnpm e2e

mobile-chrome

Same as chromium but on Playwright's Pixel-5 viewport, gated on tests/**/mobile-*.spec.ts. Runs in the same CI shard matrix as chromium.

containers — Docker required

This is the "real-infra heavyweight" project. Despite the name, it covers more than just the Docker executor — it's where any test that needs Docker on the host as a runtime lives:

  • Docker executor tests (tests/docker/*.spec.ts) — verify kandev launches real kandev-agent:e2e containers, recovers them across backend restarts, cleans them up on archive/delete, etc.
  • SSH executor tests (tests/ssh/*.spec.ts) — verify kandev SSHes into a real kandev-sshd:e2e container, uploads agentctl, runs an agent end-to-end, recovers across backend restarts, etc. The SSH executor's remote target is a Docker container in tests, even though the SSH connection itself is a real SSH connection.

This project:

  • Skips entirely when no Docker daemon is reachable. Contributors without Docker can still run chromium + mobile-chrome.
  • Builds container images on demand. First run builds kandev-agent:e2e (slim Node base + git) and kandev-sshd:e2e (Alpine + openssh-server + git + pre-baked mock-agent). Subsequent runs hit Docker's layer cache.
  • Has a longer per-test timeout (180s vs 60s) because container starts + agent setup are slow.

How to run it locally (requires Docker running):

KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers

Or a single spec:

KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers tests/ssh/launch-task.spec.ts

Why "containers" instead of "docker"?

This project used to be named docker. It was renamed to containers once SSH e2e tests joined it — calling it docker was misleading because SSH tests have nothing to do with the Docker executor; they just happen to use Docker as the runtime that hosts the sshd target.

KANDEV_E2E_DOCKER=1 is still honored as a deprecated alias for KANDEV_E2E_CONTAINERS=1 for one release. Local scripts and stale CI configs won't break, but new code should use the new name.

Commands

Command What it does
pnpm e2e Run the default (chromium) project headless.
pnpm e2e:ui Open Playwright's UI mode for interactive runs.
pnpm e2e:headed Run headless project but with a visible browser.
pnpm e2e --project=containers Run container-backed tests (needs Docker).
pnpm e2e --project=mobile-chrome Run mobile responsive tests.
E2E_DEBUG=1 pnpm e2e Surface Docker build output + extra logging.

Common flags: --shard=1/4, -g "fragment of test name", --repeat-each=3 (flake hunting).

Backend isolation per worker

Every Playwright worker gets:

  • A unique backend port in BACKEND_BASE_PORT + workerIndex (default 18080+).
  • A unique frontend port in FRONTEND_BASE_PORT + workerIndex (default 13000+).
  • A fresh tmpdir (HOME, KANDEV_HOME_DIR, worktree base, repo clone base — all under that tmpdir).
  • A unique agentctl instance port range (30001 + E2E_PORT_OFFSET * 1000 + workerIndex * 200).
  • Its own SQLite DB.

Workers run in parallel across CI shards (--shard=N/M); within a worker, tests run serially because the testPage fixture calls e2eReset on the shared backend before each test.

Mocked vs real

  • Mocked: Jira (KANDEV_MOCK_JIRA=true), Linear (KANDEV_MOCK_LINEAR=true), GitHub (KANDEV_MOCK_GITHUB=true), the agent process itself (KANDEV_MOCK_AGENT=only). These are third-party services or external processes we don't want CI to depend on.
  • Real: Everything inside the kandev backend — orchestrator, lifecycle manager, agentctl, SSH/SFTP, Docker SDK, git, worktree manager. The point of e2e is to exercise the real code paths.

The SSH executor specifically has no mock controller. Tests use a real Docker-hosted sshd as the remote target, and fault-injection (host-key rotation, dropped traffic, killed pids) is done by operating on the container itself.

Adding a new spec

  1. Pick a directory under tests/ (or create one for a new feature).
  2. Decide which project it belongs to. Anything that needs Docker → tests/docker/ or tests/ssh/ (lands in containers). Anything mobile-specific → name it mobile-*.spec.ts. Otherwise it joins chromium automatically.
  3. Import the right test base:
    • import { test, expect } from "../../fixtures/test-base"; for normal tests.
    • import { test, expect } from "../../fixtures/docker-test-base"; for Docker executor tests.
    • import { test, expect } from "../../fixtures/ssh-test-base"; for SSH executor tests.
  4. Use getByTestId for selectors. If the surface you're testing lacks stable testids, add them — drift-prone CSS / text selectors are not worth the maintenance cost.

CI

.github/workflows/e2e-tests.yml defines two jobs:

  • e2e — matrixed chromium + mobile-chrome shards.
  • e2e-containers — single job, runs --project=containers, needs Docker.

Both upload blob reports that e2e-report merges into a single HTML artifact.