Files
OpenHands/tests/e2e
Rohit Malhotraandopenhands bd894b0708 feat: add mock-LLM E2E test infrastructure (#833)
* feat: add mock-LLM E2E test infrastructure

Add a new category of E2E tests that exercise the full UI → agent-server →
LLM stack using a scripted mock LLM server instead of real LLM credentials.

The mock server uses openhands-sdk's TestLLM to serve deterministic OpenAI-
compatible responses (tool calls and text replies) over HTTP, so these tests
are fully reproducible and need no API keys.

The Playwright test drives the real UI:
  1. Creates an LLM profile via Settings > LLM Profiles
  2. Sets the profile as active (points at the mock server)
  3. Starts a new conversation from the home page
  4. Sends a user message and verifies the agent responds

Verification is three-layered:
  - Events API: polls for a successful terminal observation
  - Chat UI: asserts the bash output token appears in rendered messages
  - Chat UI: asserts the agent's final reply token appears

New files:
  - tests/e2e/mock-llm/scripts/mock-llm-server.py  (TestLLM HTTP server)
  - tests/e2e/mock-llm/utils/mock-llm-helpers.ts    (shared Playwright helpers)
  - tests/e2e/mock-llm/mock-llm-conversation.spec.ts (the test spec)
  - playwright.mock-llm.config.ts                    (dedicated Playwright config)

Run with: npm run test:e2e:mock-llm

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: make mock-LLM E2E assertions real + add CI workflow

Fixes three broken verification checks in the mock-LLM test:

1. User message no longer contains BASH_TOKEN or REPLY_TOKEN.
   The mock LLM ignores the prompt anyway (TestLLM pops scripted
   responses from a deque), so embedding tokens in the prompt just
   caused the UI assertions to pass vacuously from the user's own
   message text.

2. waitForNonUserMessageText now searches only agent/environment
   output containers (agent-message, environment-message,
   model-messages, event-group) instead of the whole document body.
   This is a positive selector strategy — no risk of false positives
   from sidebar text, nav labels, or user input.

3. Error banner assertion no longer swallows failures. The previous
   .catch(() => {}) meant the step could never fail even when an
   error banner was visible.

Also adds .github/workflows/mock-llm-e2e.yml — triggered on PRs
with the 'e2e-tests' label or manual workflow_dispatch. No secrets
needed (the mock LLM server is self-contained).

Co-authored-by: openhands <openhands@all-hands.dev>

* feat: add PR comment with test results to mock-LLM E2E workflow

The CI workflow now:
1. Captures test exit code without failing the step (so later steps run)
2. Renders a markdown report from Playwright's JSON output showing each
   test name with pass/fail/skip status, duration, and retry count
3. Posts (or updates) a PR comment via the existing upsert-pr-comment.mjs
   script, using a dedicated '<!-- mock-llm-e2e-report -->' marker
4. Expands failure details in a collapsible section with the error message
5. Writes the same report to the GitHub Actions step summary
6. Links to the workflow run and uploaded test artifacts
7. Fails the job at the end if the test exit code was non-zero

Also adds the json reporter to playwright.mock-llm.config.ts.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: use venv for openhands-sdk in CI to avoid PEP 668 error

Ubuntu 24.04's system Python is externally managed (PEP 668), so
`uv pip install --system` fails. Fix by creating a dedicated venv
for the mock LLM server and passing the venv's python path via
MOCK_LLM_PYTHON env var.

The Playwright config reads `MOCK_LLM_PYTHON` (default: 'python3')
for the webServer command, so local usage is unchanged.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: retry loop for mock LLM server verification in CI

The litellm import takes ~7 seconds on CI, so the fixed 'sleep 3'
was too short. Replace with a 30-second retry loop that polls the
server every second until it responds, then performs the JSON
validation.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: add GET / health check to mock LLM server

Playwright's webServer readiness probe sends GET / to the configured
URL. The mock server only handled POST, returning 501 for everything
else. Playwright interpreted this as 'not ready' and timed out after
30 seconds.

Add a do_GET handler that returns 200 with a simple JSON status.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci: post fresh PR comment per run + cache Playwright browsers

- Post PR comment: switch from upsert-pr-comment.mjs (which found and
  updated a single marker-tagged comment) to `gh pr comment` so each
  CI trigger leaves its own comment with full test results history.
  Remove the COMMENT_MARKER from render-mock-llm-report.mjs since it
  was only used for the dedup lookup.

- Cache Playwright: add actions/cache for ~/.cache/ms-playwright keyed
  on package-lock.json hash. On cache hit, only install system deps
  (fast apt layer) instead of re-downloading the full Chromium binary.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci: remove Playwright cache (caused extraction hang)

The actions/cache@v4 step for ~/.cache/ms-playwright reproducibly
caused npx playwright install to hang during Chrome zip extraction
(7+ min with no output, vs 24s without caching). The uncached install
completes in ~24s which is fast enough — remove caching for now.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci: move Playwright install before uv/openhands-sdk setup

Playwright's Chrome zip extraction hangs reproducibly when run after
the uv venv + openhands-sdk install steps (7+ min with no output).
The snapshot-tests workflow, which installs Playwright right after
npm ci, completes in ~21s on the same commit at the same time.

Move Playwright install immediately after npm ci — before uv, SDK,
and mock-server verification — to match the working step order.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci: split Playwright install into deps + browser download

Split 'npx playwright install --with-deps chromium' into two steps:
1. install-deps (apt packages only, no browser download)
2. install (browser download + extraction only)

This isolates which phase is hanging: the combined --with-deps flag
runs both in a single process, and the extraction hangs reproducibly
in this workflow despite identical config to snapshot-tests (which
works in 21s). Splitting may avoid whatever interaction causes the
extraction to stall.

Co-authored-by: openhands <openhands@all-hands.dev>

* ci: pin Node 24.15 to fix Playwright install hang

Node 24.16.0 introduced a zip-extraction regression (nodejs/node#63487)
that causes 'playwright install' to hang indefinitely after download
completes for Playwright < 1.60.0. This repo uses Playwright 1.59.1.

The hang was reproduced 4 times on this workflow — download finishes
in ~3s but extraction never completes (7+ minutes of silence).
Meanwhile snapshot-tests (same config) worked because its runner
resolved to Node 24.15.0.

Pin to 24.15.x until the project upgrades to Playwright >= 1.60.0,
which includes a fix for the extract-zip interaction.

Also revert the split install-deps / install experiment back to the
original single 'npx playwright install --with-deps chromium' command.

Ref: microsoft/playwright#41000, microsoft/playwright#40724

Co-authored-by: openhands <openhands@all-hands.dev>

* ci: tighten mock-LLM test timeouts and remove CI retries

Mock LLM responses are instant, so the generous timeouts were causing
CI to hang for 10+ minutes when a test fails:
- retries: 1→0 in CI (mock tests should be deterministic)
- test timeout: 120s→60s (mock responses are instant)
- polling timeouts: 60s→30s for bash observation and chat text checks

Before: 3 tests × 120s timeout × 2 attempts (retry) = up to 12 min
After:  3 tests × 60s timeout × 1 attempt = up to 3 min on failure

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: step 3 conversation creation + add global timeout

Step 3 was clicking the home-chat-launcher container div (a passive
wrapper) instead of using the chat input to create a conversation.
The div click did nothing, and the test timed out waiting for
navigation to /conversations/<id>.

Fix: type into the home-page chat input and click submit — this is how
real users create conversations from the home page.

Also:
- Add globalTimeout (10 min in CI) to cap the entire Playwright run
  so teardown hangs don't waste CI time
- Reduce job timeout-minutes from 20 to 15

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: teardown hang via exec + add diagnostic logging

1. Prefix webServer command with 'exec env' so the shell is replaced
   by the npm process. Without exec, Playwright's SIGTERM kills the
   shell but npm's children (uvx, agent-server, vite) survive as
   orphans, causing the step to hang for 6+ minutes after tests finish.

2. Add diagnostic logging to waitForSuccessfulBashObservation — on
   timeout, the error message now includes the count and kinds of
   events the API actually returned, so we can tell whether the
   conversation never started vs the observation format changed.

3. Add a pre-flight API check in step 3 that verifies the mock-LLM
   profile's base_url is active in server settings before creating a
   conversation. If steps 1+2 didn't persist correctly, this fails
   fast with a clear message instead of timing out on empty events.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: profile check via /api/profiles + timeout wrapper for teardown

1. The pre-flight check was querying /api/settings which doesn't
   contain profile-based LLM config. Fix: query /api/profiles and
   assert active_profile matches the expected profile name.

2. Wrap the Playwright command in 'timeout --kill-after=30 8m' so
   if webServer teardown hangs (orphaned agent-server/vite processes
   ignoring SIGTERM), the entire process tree gets SIGKILL'd after
   8.5 minutes instead of waiting for the 15-min job timeout.

Co-authored-by: openhands <openhands@all-hands.dev>

* debug: dump first observation's raw structure on failure

The events API is returning events (agent-server logs show the bash
command was executed), but isSuccessfulBashObservation can't find a
match. Dump the first observation's full JSON structure so the next
CI run shows exactly what fields the API returns.

Co-authored-by: openhands <openhands@all-hands.dev>

* debug: dump raw event structures to discover API format

Previous diagnostic showed 8 events all with 'unknown' kind —
meaning the events don't have action.kind or observation.kind
properties. Dump the full JSON of the first 3 events to discover
the actual field structure.

Co-authored-by: openhands <openhands@all-hands.dev>

* debug: dump ALL event kinds + first non-stats event structure

Previous dump only showed first 3 events (all stats/state updates).
The observation events are likely in positions [3]-[7]. New diagnostic
shows all event kinds and dumps the first non-stats event so we can
see the actual action/observation format.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: use correct APIs for mock-LLM E2E verification

The agent-server's conversation events API returns MessageEvents (not
nested ActionEvent/ObservationEvent), and tool executions live in the
separate bash events API (/api/bash/bash_events/search).

Changes:
- waitForSuccessfulBashObservation: now queries /api/bash/bash_events/search
  with kind__eq=BashOutput, checks stdout/stderr for BASH_TOKEN
- waitForAgentMessageContaining: new helper that checks conversation
  events API for agent MessageEvents containing a given token
- Step 3 verification now:
  1. Bash tool execution via bash events API
  2. Agent reply via conversation events API
  3. Reply token in chat UI (proves full round-trip)
  (Removed BASH_TOKEN UI check — it may not render in chat)

Co-authored-by: openhands <openhands@all-hands.dev>

* perf: cache Playwright browser binaries in CI

Split 'playwright install --with-deps chromium' into two steps:
1. 'playwright install chromium' (only on cache miss) — downloads ~200MB
   of browser binaries, cached via actions/cache keyed on PW version + OS
2. 'playwright install-deps chromium' (always) — installs apt system
   libraries needed by the browser (fast, mostly pre-installed on runner)

This should save ~30-60s on cache-hit runs since the browser download
is the slowest part of the Playwright setup.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: accept BashOutput with null stdout (exit_code=0 proves execution)

The bash events API returns BashOutput events where stdout can be null
even for successful commands (order:0 event with exit_code:0). Accept
null stdout with exit_code 0 as proof of successful execution.
Also dump all bash events (not just first) for CI diagnostics.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: don't fail CI when tests pass but teardown hangs

The Playwright webServer teardown can hang when the agent-server process
doesn't respond to SIGTERM (a known issue with uvicorn child processes).
The timeout wrapper kills the process tree after 8 min, but this was
incorrectly mapped to test failure.

Now when timeout triggers:
1. Check if test-results-mock-llm/results.json exists (Playwright writes
   this before teardown starts)
2. Parse it: if every spec/test has status 'passed', mark as success
3. Only fail if results.json is missing or has actual test failures

Also includes the Playwright cache and bash events API fix.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: background Playwright so shell survives teardown timeout

The previous 'timeout' wrapper killed the entire process group including
our bash shell, so the results.json check never ran. Now:
1. Run Playwright in background (&)
2. Poll every 2s up to 8 min
3. If still running, SIGTERM then SIGKILL the process group
4. Our shell is still alive → check results.json
5. If all tests passed, mark as success despite teardown hang

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: poll for results.json during run, not after kill

Playwright's JSON reporter writes results.json after all tests complete
but before webServer teardown. Poll for the file appearance during the
run (Phase 1), then only kill the hanging process if tests are done
(Phase 2). This way we catch results.json while Playwright is still
alive but stuck in teardown, and can correctly determine pass/fail.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: parse Playwright stdout for pass/fail (not results.json)

Playwright's JSON reporter only writes results.json on process exit,
which is blocked by the webServer teardown hang. The line reporter
prints test results to stdout in real-time BEFORE teardown starts.

New approach:
- Capture stdout with tee to a log file
- Poll the log for 'N passed' summary line
- After killing the hanging process, check the log:
  if 'N passed' exists and no 'N failed' or 'N timed out', mark success

This lets us correctly report passing tests even when the agent-server
process doesn't respond to SIGTERM during webServer teardown.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: write PW output to file directly, add debug logging

Process substitution >(tee ...) is fragile with background kills —
the tee process might be killed alongside npm, leaving an incomplete
log. Write directly to file and tail separately for CI output.

Added explicit debug logging:
- 'Checking PW_LOG for pass/fail...'
- grep output showing what matched
- Different message for failure vs success

Co-authored-by: openhands <openhands@all-hands.dev>

* debug: add verbose logging to post-kill check

Need to see: does PW_LOG exist? What size is it? What does grep find?
Which branch of the if/else is taken? Where exactly does it stop?

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: use marker file written by test to detect pass/fail

Playwright's JSON reporter only flushes on clean process exit, and
stdout redirection is unreliable with backgrounded process trees.
Instead, the test itself writes a .all-passed marker file after all
assertions succeed. The CI wrapper polls for this file to detect test
completion, then safely kills the hanging teardown process.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: clean up debug diagnostics from helpers

Remove per-event JSON dumps and verbose diagnostic logging from
waitForSuccessfulBashObservation. Keep concise error messages.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: detect test completion immediately via custom reporter

Playwright's reporter onEnd() fires AFTER all tests complete but
BEFORE webServer teardown starts. A custom DoneMarkerReporter writes:

  .tests-done  — always (content: 'passed' or 'failed')
  .all-passed  — only when all tests pass

The CI wrapper polls for .tests-done, so it detects completion
immediately on both pass AND fail. Previously it only polled for
.all-passed, meaning test failures wasted the full 5-min polling
timeout before the step could finish.

This also moves the marker logic out of the test spec and into the
reporter, which is cleaner — the test code doesn't need to know
about CI infrastructure.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: write marker files outside Playwright's outputDir

Playwright clears its outputDir at the start of each run. Writing
markers to a separate .mock-llm-markers/ directory avoids interference.

Also wrapped onEnd() in try/catch and resolved paths via import.meta.url
to be robust against working directory changes.

Co-authored-by: openhands <openhands@all-hands.dev>

* debug: add console.log to reporter, use process.cwd()

import.meta.url may not work in Playwright's CJS reporter context.
Use process.cwd() instead. Add console.log in onBegin/onEnd to verify
the reporter is loaded and executing.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: write markers in onTestEnd, not onEnd

Playwright's lifecycle: onBegin → tests → onTestEnd → onEnd → cleanup.
WebServer teardown happens during 'cleanup', which hangs indefinitely.
onEnd() fires AFTER cleanup, so it never executes when teardown hangs.

onTestEnd() fires immediately after each test completes, before any
cleanup begins. Track total/completed test counts and write markers
after the last test finishes. This gives both pass and fail signals
before the teardown hang blocks everything.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: report script falls back to marker files when results.json missing

Playwright's JSON reporter only flushes results.json on clean process
exit. When the webServer teardown hangs and the process is killed,
results.json never gets written, so the report showed 0/0 tests.

The render script now checks .mock-llm-markers/.tests-done (written by
DoneMarkerReporter in onTestEnd, before teardown) as a fallback. This
gives correct pass/fail status in the PR comment even without
results.json.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: proper teardown and accurate test durations in PR comment

Two fixes:

1. **Teardown hang resolved**: The webServer command now bypasses npm
   and `exec`s directly into `node scripts/dev-safe.mjs`. Previously
   `exec ... npm run dev:minimal` was used, but npm does NOT forward
   SIGTERM to its child processes. When Playwright sent SIGTERM during
   teardown, npm died but node/uvx/vite survived as orphans, causing
   the hang. Now SIGTERM goes straight to dev-safe.mjs's signal handler
   which kills children via process groups and exits cleanly.

2. **Accurate durations**: DoneMarkerReporter now writes a `.results.json`
   with per-test title, status, duration, and error data (from
   `TestResult.duration` in onTestEnd). The report script reads this
   instead of showing 0ms. Falls back to `.tests-done` (pass/fail only)
   if the JSON is missing.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: reduce teardown grace period from 10s to 5s

The marker-based detection is immediate (onTestEnd fires before
teardown), so we don't need a long grace period. The remaining hang
is Playwright waiting for the multi-process agent-server tree to
fully exit — expected behavior, not a bug.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-28 14:23:28 -04:00
..