> ui testing + evidence for claude code
Easy UI
Not just another browser controller. Easy UI is a local UI-testing MCP server: an AI agent such as Claude Code drives a real browser step by step, and every session ends with a structured report you can read and share.
- A report for every sessionA JSON file and a self-contained HTML report: screenshots, step labels, pass or fail, and the action log. Nothing to set up.
- 26 tools over MCPHTTP/SSE on localhost:8765, built for testing: sessions, labelled steps, and separate tools to assert, check and wait. Playwright Chromium inside Docker.
- Local, no LLM insideOne
docker compose up. No cloud account, no API key, no model calls in the container. The agent reasons; the server runs and records.
Why Easy UI
General browser automation answers “can the agent click this?”. Easy UI answers “did the flow work, and where is the proof?”.
- Structured reportsevery session
A JSON file and a self-contained HTML report with screenshots, step labels, pass or fail, and logs.
reports/index.htmllists them all. - Testing primitivessessions and steps
ui_start_sessionandui_end_sessionbracket a run.ui_stepnames each stage.ui_assert,ui_checkandui_wait_forare three tools on purpose. - A verdict you can trustno false green
A hard failure fails the session. Console errors, page errors, failed requests and HTTP 4xx/5xx are listed under Browser problems, so a green run cannot hide a throwing console.
- Local firstone command
docker compose up -d --build. No cloud account and no API key. Nothing leaves your machine. - No LLM insidetools only
The server runs each tool and records the result. The agent does the reasoning, so cost and behaviour stay predictable.
- Made for Claude CodeHTTP/SSE and stdio
One
claude mcp addregisters it. One connection runs up to 3 sessions side by side.dist/stdio.jsserves hosts that wrap stdio servers.
Easy UI is a local UI-testing MCP that gives your agent structured, shareable test reports. See A session for the flow and Reports for what the report shows.
How it works
Claude Code sends one tool call at a time. The server never decides what to do next.
request · one tool call, left to right
- Claude Codedecides each step
- MCP over HTTPlocalhost:8765/mcp
- easy-ui-mcpNode server in Docker
- Playwrightheadless Chromium
- Page under testyour app, on the host
return · the answer and the evidence
- Page respondswhat Chromium sees
- Playwrightreturns the outcome
- easy-ui-mcp logs itevery action, server-side
- Claude Code readsthe result text
- reports/JSON + HTML on ui_end_session
More detail
It exposes 26 tools built on Playwright over HTTP/SSE. Claude Code decides what to click; the server only performs each action, logs it, and writes the report when the session ends. No test scripts to write, and no LLM runs inside the container.
The reports directory is volume-mounted, so the JSON and HTML land in ./reports/ on your machine. A thin REST wrapper (POST /api/run-test) covers callers that cannot speak MCP.
A session
Every flow is bracketed by ui_start_session and ui_end_session. In between, look, act and check as many times as the flow needs.
-
1 · open
- Startui_start_session
- Label (optional)ui_step
- Go to the pageui_navigate
-
2 · loop, as often as needed
- Lookui_get_page_state
- Actui_click · ui_fill · ui_type · …
- Checkui_check · ui_assert · ui_wait_for
repeat until the flow is done
-
3 · close
- Endui_end_session
- ReportJSON + HTML in reports/
A hard failure (ui_assert false, a ui_wait_for timeout) marks the whole session failed; ui_check only records an observation. See Writing a flow.
Before and after
What changes for the agent once the server tells it what the page looks like.
Choosing a selector
before
Guess a CSS selector
ui_click selector: ".btn.primary"
ui_click fails unless exactly one element matches, so a guess that matches 0 or 3 is a wasted call.
after
Ask the page, pass the ref
ui_get_page_state
-> @e2 button "Sign in"
ui_click selector: @e2
A ref is checked against the live page when you use it. If the page changed, the call fails and asks for a new snapshot.
A native dialog
before
The confirm blocks the page
ui_click selector: "button:text-is(\"Delete\")"
-> waits on a dialog nobody can answer
A browser confirm stalls the click that opened it.
after
The call returns at once, you answer
ui_click selector: "button:text-is(\"Delete\")"
-> Browser is waiting on a confirm dialog: "Delete?"
ui_dialog action: "accept"
The reply to ui_dialog says what the click did next.
Quick start
-
01 · Build and start
$ docker compose up -d --buildBuilds the image and starts the container.
-
02 · Check it is alive
$ curl http://localhost:8765/health # {"status":"ok"}Port 8765 on localhost.
-
03 · Connect Claude Code
$ claude mcp add --transport http easy-ui-mcp http://localhost:8765/mcpRegisters the MCP server for this project.
Then ask Claude Code to navigate to a page and take a screenshot. It will call the tools below and report back.
Use from another repo
MCP registration is per project: run claude mcp add from that repo's root too. The container only needs to run once and is shared across repos.
$ cd /path/to/your-app
$ claude mcp add --transport http easy-ui-mcp http://localhost:8765/mcp
Running over stdio (Glama, mcp-proxy)
Hosts that wrap a stdio MCP server, such as Glama's mcp-proxy, run dist/stdio.js instead of the HTTP server. It speaks the same tools over stdin/stdout, binds no port, and exits when stdin closes.
# build
$ pnpm install && pnpm build && pnpm exec playwright install --with-deps chromium
# start
$ mcp-proxy -- node dist/stdio.js
One stdio connection is one client, so the legacy shared page (the one ui_navigate uses without a session) is not shared with anyone else. The Docker/HTTP flow above is unchanged.
Networking
The browser lives inside the container, so it must reach localhost:<port> on your machine, where your app's dev server runs. The container therefore uses the host network.
- Chromiumin the container, network_mode: host
- localhost:<port>the same network as your machine
- Your dev serverfor example http://localhost:8766
More detail
The container runs with network_mode: host in docker-compose.yml, not a published port on a bridge network. This is required: the browser Playwright drives inside the container must reach localhost:<port> on your host machine, where the dev server of the app you are testing runs. On a default bridge network the container has no route back to the host, so targets such as http://localhost:8766 hang or fail with ERR_CONNECTION_REFUSED, and http://<host-LAN-IP>:8766 will just time out, even if the target server is listening and reachable via curl from the host shell.
Where network_mode: host is not available (for example Docker Desktop on macOS or Windows), use host.docker.internal as the hostname instead of localhost when calling ui_navigate, and add this to docker-compose.yml:
extra_hosts:
- "host.docker.internal:host-gateway"
As one line: extra_hosts: ["host.docker.internal:host-gateway"].
Writing a flow
A session is marked failed if any hard action fails, so how you verify decides whether the report tells the truth.
- ui_assertfalse: the session fails
A claim about the app: "the toggle is now on".
- ui_checkfalse: recorded, the run continues
An observation for the report that should not condemn the run.
- ui_wait_forfalse: keeps polling; a timeout fails
Waiting for the page to render or settle.
Never call ui_assert in a retry loop to wait for something: the first false result permanently fails the run even if the app is fine. That is what ui_wait_for is for. For ui_check and ui_wait_for, a condition that cannot run at all (no page open, or the expression throws) is always a hard failure.
More detail
Label your steps
ui_step(label) groups everything that follows under a plain-language heading until the next ui_step. The label is the report's only caller-authored statement of intent. A session with no ui_step calls still renders, under one implicit group.
ui_start_session target: "Account Access toggle smoke"
ui_step label: "Open the Settings page"
ui_navigate ...
ui_wait_for ...
ui_step label: "Turn Manual Invoice access on"
ui_click ...
ui_assert ...
ui_end_session
Use the refs from page state
ui_get_page_state returns the page as short text: one line per visible interactive element, @eN role "name", with its non-default state. Pass the ref as the selector of any tool instead of guessing a CSS selector. A ref means the latest snapshot of that page and follows the element that was listed: if that element is gone or replaced, the call fails and says to call ui_get_page_state again, and it never acts on a different element. Reports show the real selector of each action; a refused ref is a soft step that does not fail the session. format: "full" returns the JSON with a verified selector per element instead, and no refs.
url: https://shop.example/login
title: Sign in
@e1 textbox "Email" required
@e2 button "Sign in" disabled
ui_click selector: @e2
Page state also reports what the page looks like right now. An element line carries only its non-default state (disabled, checked, value, expanded, selected, pressed, required, invalid; a password value is never shown). overlay @o1 lines list each open dialog topmost first and are absent when none is open. focused: names the element holding focus. Custom widgets such as comboboxes, switches and rich-text editors are listed too. To read text, call ui_get_text.
Several sessions
One connection can hold several open sessions, for example to compare two URLs. Each has its own browser, page, step label and report.
up to 3 sessions (MAX_SESSIONS_PER_CONNECTION), each picked with session_id
- Session A
- own browser
- own page
- own step label
- own report
- Session B
- own browser
- own page
- own step label
- own report
- Session C
- own browser
- own page
- own step label
- own report
- A 4th session
- refused: the cap is 3
- end one first
- Idle 10 minThe session is closed automatically and its report is written with status
timed_out. - Connection closesEvery session it still owns is ended and reported.
- One ownerA session is reachable only from the connection that started it.
More detail
One connection can hold several open sessions (up to 3; set MAX_SESSIONS_PER_CONNECTION to change it), for example to compare two URLs or a logged-in and a logged-out user. ui_start_session returns a session id; pass it as session_id to any other tool to choose the session it acts on. Each session has its own browser, page, step label and report.
ui_start_session target: "staging" -> Session <id-A> started
ui_start_session target: "production" -> Session <id-B> started
ui_navigate url: ... session_id: <id-A>
ui_navigate url: ... session_id: <id-B>
ui_end_session session_id: <id-A>
ui_end_session session_id: <id-B>
With exactly one open session session_id can be left out. With several open, leaving it out is an error that lists the open ids, so a call never lands on the wrong page. A session is reachable only from the connection that started it. Another connection passing its id gets Unknown session. A session with no tool call for 10 minutes (SESSION_TIMEOUT_MS, in milliseconds) is closed automatically: its browser is shut down and its report is written with status timed_out. A call with that id then returns Session <id> was closed after 10 min idle. When a connection closes, every session it still owns is ended and reported.
Tools
All 26 tools, grouped by the job they do. A session is bracketed by ui_start_session and ui_end_session; the actions in between are logged into the report.
Session3
ui_start_sessionui_stepui_end_session
Navigate3
ui_navigateui_navigate_historyui_scroll
Read the page3
ui_get_page_stateui_get_textui_take_screenshot
Act8
ui_clickui_fillui_typeui_select_optionui_press_keyui_hoverui_set_checkedui_drag
Sizes1
ui_set_viewport
Check and wait3
ui_checkui_assertui_wait_for
Dialogs and overlays2
ui_dialogui_click_outside
Tabs, files and logs3
ui_tabsui_upload_fileui_get_logs
What each tool does
| tool | purpose |
|---|---|
ui_start_session | Open a fresh browser context and start logging into a report. Optional device, viewport, color scheme, locale and timezone. |
ui_end_session | Write the JSON and HTML report and return their paths. |
ui_step | Label what you are about to do; following actions are grouped under it in the report. |
ui_navigate | Navigate the browser to a URL. |
ui_get_page_state | Return URL, title, up to 200 visible interactive elements (custom widgets included) as one short line each with an @eN ref and their state, the open dialogs, the focused element, and visible alert messages. viewport: true keeps only elements on screen and within only those inside one element; a note says how many were left out. format: "full" returns JSON with selectors instead. |
ui_get_text | Read the visible text of one element, or of the whole page, to check a value, heading or error. |
ui_take_screenshot | Capture the viewport, one element or the full page as a PNG in the reports output; optionally return the image so the agent can look at it. |
ui_click | Click the single element matching a selector; optionally right, double or modifier click, at an offset, or forced. |
ui_click_outside | Click just outside an element, such as a modal backdrop, to close it the way a user would. |
ui_dialog | Answer a native alert, confirm or prompt the browser is holding open, or pre-set the answer for the next one. |
ui_fill | Fill the single input matching a selector with a value. |
ui_select_option | Choose an option of a <select> by value or visible label. |
ui_press_key | Press a key or combo such as Enter, Tab, Escape or Control+a, on an element or on whatever has focus. |
ui_hover | Move the pointer over an element to reveal hover content such as dropdown menus. |
ui_set_checked | Set a checkbox or radio button to checked or unchecked. |
ui_scroll | Scroll an element into view, or one step in any direction on the page or inside a scroll area; says when the end is reached. |
ui_set_viewport | Resize the page, for example to a 375px phone or a tablet. |
ui_type | Type into an input one key at a time, like a user (autocomplete, masked inputs, OTP boxes). |
ui_navigate_history | Go back, go forward or reload, keeping the browser history. |
ui_get_logs | Read the console errors, page errors, failed requests and HTTP 4xx/5xx responses captured so far, mid-flow. |
ui_tabs | List, switch to or close the session's browser tabs (popups and target=_blank links open new ones). |
ui_upload_file | Upload the built-in default_draft.png to a file input or to the chooser a button opens. Takes no path. |
ui_drag | Drag one element onto another: drag-and-drop lists, kanban cards, sortables. |
ui_assert | Hard check of a JS expression: false fails the whole session. |
ui_check | Soft check of a JS expression: recorded in the report, the run continues. |
ui_wait_for | Poll a JS expression until it is true; a timeout fails the session. |
Seeing the page
Before you click, ask the page what it looks like right now. ui_get_page_state returns:
- url, title
- Where the browser is.
- elements
- Up to 200 visible interactive elements, one line each: an
@eNref, the role, the name and, when it differs from the default, its state. Custom widgets included. - messages
- Visible alert and status text, such as validation errors.
- overlays
- Each open dialog as an
overlay @oNline, topmost first. Absent when nothing is open. - focused
- The element holding focus. Absent on the page body, in an iframe or in a shadow root.
ui_get_text reads text: one element, or the whole page, cut at 4000 characters.
ui_get_page_state
ui_get_text selector: "[data-testid=\"order-total\"]"
ui_get_text
More detail
Before you click, ask the page what it looks like right now. ui_get_page_state lists up to 200 visible interactive elements, each with an @eN ref and, when it differs from the default, its state. A ref is valid for the latest snapshot only and fails, rather than misfires, once the page has changed; format: "full" returns JSON with a verified selector per element and no refs. On a long page, viewport: true lists only what is on screen and within only what is inside one element; a scoped reply says how many elements it left out. Custom widgets are included: combobox, listbox, option, switch, slider, spinbutton, searchbox, treeitem, summary, contenteditable regions, and anything with a role and tabindex >= 0.
overlay @o1 dialog "Confirm" modal lists each open dialog, topmost first, and its ref works wherever a selector does (for example ui_click_outside). No overlay line means nothing is open, so a modal opening or closing shows up as the line appearing or disappearing. focused: @e2 names the element holding focus, and is absent when focus is on the page body, in an iframe or in a shadow root.
ui_get_text reads text: the trimmed visible text of one element, or of the whole page body with no selector, cut at 4000 characters with a note of the real length. It fails on 0 or more than 1 match and on an element that is not visible. The text it reads is returned to you but not stored in the report.
The reply in detail
ui_get_page_state returns the page as short text: a url: line, a title: line, then one line per visible interactive element (up to 200) as @eN role "name" followed by its non-default state, and messages: (visible alert/status text such as validation errors). Pass the ref as the selector of ui_click, ui_fill, ui_select_option, ui_press_key, ui_hover, ui_set_checked, ui_scroll, ui_get_text, ui_take_screenshot and ui_upload_file (and as from / to of ui_drag, container of ui_scroll, within of ui_get_page_state) instead of guessing a CSS selector.
A ref means the latest snapshot of that page and nothing older. A compact snapshot does no selector work; the selector behind a ref is built and checked against the live page when you first use the ref. A ref follows the element that was listed: if that element is gone or replaced by a look-alike, the call fails with a message that names the ref and says to call ui_get_page_state again. It never acts on a different element, and nothing is clicked or typed. A ref from another session or tab, one used before any snapshot, and an unknown number fail the same way. Reports and the action log show the real selector of each successful action. A refused ref is listed as a soft failed step with the ref the agent tried; it does not fail the session or take a failure screenshot. CSS and Playwright selectors still work everywhere; elements without an accessible name are not listed, so reach those with a CSS selector.
format: "full" returns the earlier JSON instead: the elements as {role, name, selector, state?} with a selector checked against the live page, plus overlays, focused and messages. It has no refs, and taking it ends the refs of the previous snapshot. A selector containing nth= is positional: call ui_get_page_state again after the page changes before reusing it.
Page state also tells you what the page looks like right now, so you do not have to guess:
- An element line carries only its non-default state, as
keyfortrueandkey=valueotherwise:disabled,checked(checked,checked=falseorchecked="mixed"),value="..."(inputs, textarea, select; cut at 100 characters; never shown for a password field),expanded,selected,pressed,required,invalid. Names are quoted JSON strings, so a quote or a line break in a name cannot make a second line. - Custom widgets are listed too:
combobox,listbox,option,switch,slider,spinbutton,searchbox,treeitem,menuitemcheckbox/menuitemradio,summary,contenteditableregions, and anything with a role andtabindex >= 0. overlay @o1 dialog "Confirm" modallists each open dialog, topmost first (role=dialog,alertdialog,dialog[open],aria-modal);@o1is accepted wherever a selector is, for example byui_click_outside. No overlay line means nothing is open, so a modal opening or closing shows up as the line appearing or disappearing.focused: @e2names the element holding focus (asrole "name"when it is not in the list). It is absent when focus is on the page body, in an iframe or in a shadow root.note:appears when the list was cut at 200 elements (it then suggestsviewportandwithin), or when you scoped the list and it left elements out.
To read less on a long page, scope the list. Both arguments are optional and off by default, so with neither the reply is exactly as described above.
viewport: truelists only elements whose bounding box is at least partly on screen at the current scroll position.within(a selector or an@eN/@oNref) lists only the elements inside that one element; it fails, listing nothing, if it matches no element or several. An element with nothing interactive inside gives an empty list, not an error.- Pass both and an element must satisfy both.
overlays,focusedandmessagesare never scoped. Refs number the scoped list and work as usual. - A scoped reply that left elements out says so, for example
note: Showing 37 of 180 elements (viewport only); scroll, or call without viewport, for the rest. The 200-element cap applies after scoping, and its note can follow on the same line.
Closing overlays and answering dialogs
A browser alert, confirm, prompt or beforeunload blocks the page, so the server holds it open and asks you.
- Clickui_click
- Dialog opensthe call returns at once: "Browser is waiting on a confirm dialog"
- Other tools refuseduntil you answer
- Answerui_dialog accept or dismiss
For a modal, ui_click_outside(selector) clicks just outside the panel you name, the way a user clicks the backdrop.
ui_click selector: "button:text-is(\"Delete\")"
# -> Browser is waiting on a confirm dialog: "Delete?"
ui_dialog action: "accept"
ui_click_outside selector: "[role=\"dialog\"]"
More detail
Click outside
ui_click_outside(selector) clicks just outside the element you name (pass the modal panel, not the backdrop) and says what it hit, the way a user dismisses a modal by clicking its backdrop. If nothing safe exists around it, it fails and suggests ui_press_key with Escape.
ui_click also takes optional button (right fires contextmenu), clickCount (2 = double-click), modifiers (for example ["Shift"]), position (an offset inside the element) and force (click through a covering element). Without them it is a plain left click.
Native dialogs
A browser alert, confirm, prompt or beforeunload blocks the page, so the server holds it open and asks you. The call that opened it returns at once with Browser is waiting on a confirm dialog: "Delete?". Every other tool is refused until you answer with ui_dialog (accept or dismiss, plus text for a prompt); its reply tells you what the interrupted action did next.
Calling ui_dialog when nothing is waiting pre-sets the answer for the next dialog, which is then answered without pausing: use it in scripted flows. A dialog left unanswered for DIALOG_TIMEOUT_MS (default 120000) is dismissed and the next response says so. Every dialog is logged in the report with who answered it.
Real sizes and typing
ui_start_session takes these optional settings. They apply to that session only and appear in the report header.
- device
- A Playwright device name such as
"iPhone 13": mobile user agent, touch, pixel ratio, size. An unknown name is an error that lists the valid ones. - viewport
{width, height}; overrides the device size.- colorScheme
lightordark.- locale, timezoneId
- The language and time zone the page sees.
- (none of them)
- A 1280x720 desktop.
ui_set_viewport(width, height) resizes mid-flow (320-3840 by 320-2160). ui_type types key by key, for autocomplete and OTP boxes.
ui_start_session target: "checkout on a phone" device: "iPhone 13" colorScheme: "dark"
ui_set_viewport width: 768 height: 1024
More detail
ui_start_session takes optional device (a Playwright device name such as "iPhone 13": mobile user agent, touch, pixel ratio, size), viewport {width, height} (overrides the device size), colorScheme (light or dark), locale and timezoneId. An unknown device is an error that lists the valid names. With none of them the browser is a 1280x720 desktop. They apply to that session only, and appear in the report header. ui_set_viewport(width, height) resizes mid-flow (320-3840 by 320-2160); layouts can change, so call ui_get_page_state again afterwards.
ui_fill sets the whole value at once. ui_type(selector, text, delayMs?) types key by key (keydown, input and keyup per character, appended to what is there), for autocomplete, search-as-you-type, masked inputs and OTP boxes. The report only says how many characters were typed. The text itself is replaced by •••• (in the reply and the stored step) when the target is a password, one-time-code or card-number (autocomplete) field, or uses -webkit-text-security.
ui_navigate_history(action) with back, forward or reload keeps the history and form state that a second ui_navigate would lose. ui_scroll also takes container (a scroll area such as a chat panel or virtual table) and direction (down, up, left, right, top, bottom), moves one step per call and says when the end is reached, so a loop over a lazy list knows when to stop.
ui_take_screenshot also takes selector (just that element), fullPage (the whole page, cut at 16000 device pixels tall and 3840px wide) and inline: true, which returns the PNG as image content (scaled to fit 1280x8000px) as well as writing it to reports/.
ui_type selector: "[name=\"city\"]" text: "Han" delayMs: 50
ui_navigate_history action: "reload"
ui_take_screenshot selector: "[role=\"dialog\"]" inline: true
Debugging and working across tabs
When an action seemed to do nothing, or the flow leaves the page, these four tools show why.
- Logs
ui_get_logsreads console errors, page errors, failed requests and HTTP 4xx/5xx so far, at most 50 lines per call. - Tabs
ui_tabslists, switches or closes tabs. A new tab is announced; the session does not switch on its own. - Files
ui_upload_filesends one built-in file,default_draft.png. It takes no path.ui_dragdrags one element onto another. - DownloadsSaved to
reports/downloads/, at most 20 per session of up to 25 MB each.
ui_click selector: "a:text-is(\"Invoice (opens in a new tab)\")"
# -> ... New tab opened: https://app.test/invoice/42 (tab 2)
ui_tabs action: "switch" index: 2
ui_get_logs since: "start"
More detail
ui_get_logs reads the browser problems captured so far while the flow runs: console errors, uncaught page errors, failed requests and HTTP 4xx/5xx responses (method and URL, never bodies or headers). since: last (default) returns only what is new, start returns everything, and level: warning adds console warnings. Repeats are grouped as ×N, at most 50 lines per call. Use it when an action seemed to do nothing.
A target=_blank link or window.open popup opens a tab the session tracks: the next response says New tab opened: <url> (tab 2), but the session does not switch to it. ui_tabs with list, switch or close moves between tabs (numbered from 1), and every other tool acts on the current tab.
ui_upload_file(selector) uploads one built-in file, default_draft.png (a tiny PNG), to a file input or to the chooser a button opens. It takes no path, so no other file in the container can be sent to the site under test. ui_drag(from, to) drags one element onto another, for HTML5 drag-and-drop and pointer-based sortables.
Downloads are saved to reports/downloads/<session>/, logged as an action and announced on the next response (Downloaded: <name> (<bytes> bytes)). A session keeps at most 20 downloads of up to 25 MB each; a download over either cap is discarded and the next response says so (once, for the count cap). Tabs, logs and downloads exist only inside a ui_start_session bracket and belong to one session: with several open, pass session_id.
ui_upload_file selector: "input[type=file]"
ui_drag from: "[data-testid=\"card-1\"]" to: "[data-testid=\"column-done\"]"
Reports
Reports are written to ./reports/ on the host (the directory is volume-mounted) and named:
session-<YYYYMMDD-HHMMSS>-<slug>-<id8>.{json,html}
<YYYYMMDD-HHMMSS>UTC start time<slug>readable slug of the session target<id8>first 8 characters of the session id
An HTML report shows:
- Verdict boxStatus, target, step, action and failure counts, and duration.
- Labelled stepsThe run as steps, with per-step outcomes and elapsed time.
- Browser problemsConsole errors, page errors and failed requests, up to 50 per session. They never change the verdict.
- Raw action logCollapsed behind a disclosure.
More detail
That is the UTC start time, a readable slug of the session target, and the first 8 characters of the session id. reports/index.html lists the sessions, newest first and capped at 500 (older reports stay on disk, unlisted), and is rewritten after every report.
An HTML report shows a verdict box (status, target, step, action and failure counts, and duration); the run as labelled steps, with per-step outcomes and elapsed time; browser problems (console errors, uncaught page errors and network-level request failures, captured automatically and never changing the verdict, up to 50 per session); and the raw action log, collapsed behind a disclosure.
HTTP 4xx/5xx responses (a 500 is a successful request to Playwright, so requestfailed never fires) are captured as httperror as <status> <METHOD> <url>, with the query string dropped because it may hold tokens. Auto-failure screenshots are budgeted per session (FAILURE_SCREENSHOT_BUDGET, default 3), and identical screenshot content is embedded only once.
REST wrapper
For callers that cannot speak MCP, one request runs one navigate-and-screenshot test. For multi-step flows, drive the MCP tools from Claude Code instead.
- Any HTTP clientcurl, a script
- POST /api/run-testa URL and an optional label
- One testnavigate and screenshot
- JSONstatus, report_url, screenshots, logs
$ curl -s -X POST http://localhost:8765/api/run-test \
-H "Content-Type: application/json" \
-d '{"app_url_or_package": "https://example.com", "flow_description": "Smoke test", "platform": "web"}'
More detail
POST /api/run-test runs one navigate-and-screenshot test and returns a report.
| field | required | description |
|---|---|---|
app_url_or_package | yes | URL to navigate to. Non-empty string, else 400. |
flow_description | no | Label for the run in reports. Defaults to the URL. |
platform | no | Only "web" is supported in v1; anything else returns 400. |
timeout | no | Milliseconds, a positive number. Default 30000. |
The response is JSON with status (passed, failed or timeout), report_url, screenshots and logs. report_url is a path inside the container: read the report from the host under ./reports/ using its basename. GET /health returns {"status":"ok"}.
Measured
Numbers recorded while building this, and limits read from the code.
- 26tools registered in
src/server.ts - 303tests, 303 passing in the last full run (T035)
- 200elements at most in one page state
- 3sessions at most per connection
Report size after a soft-check run
T013. The bloat was one full-screen PNG captured on soft failures; a soft failure no longer captures one. Before, reports ran 144-357 KB.
Wait after a ui_click
T033 re-verify. Only clicks that may open a tab or start a download wait a moment for it; the rest return at once.
Troubleshooting
| symptom | likely cause | remediation |
|---|---|---|
"port is already allocated" on docker compose up | A stale container from a previous session or worktree holds port 8765. | docker ps -a | grep 8765, then docker stop <id> && docker rm <id> and retry. |
PORTS column empty in docker compose ps though up -d succeeded | A transient Docker Compose quirk seen in this environment. | docker compose down, then docker compose up -d again (a full cycle, not just up). |
npm test hangs or fails with EADDRINUSE | Port 8765 is held, usually by the running container: two test files start their own server on that port. | docker compose stop, run npm test, then docker compose up -d. |
Target localhost URLs hang or give ERR_CONNECTION_REFUSED | The container is not on the host network, so it cannot reach your dev server. | Keep network_mode: host, or use host.docker.internal as described under Networking. |
Malformed JSON POST to /mcp returns a raw stack trace | The JSON-parse error handler is missing (fixed in T001). | Check the JSON-parse error middleware is registered immediately after express.json() in src/server.ts. |
Development
Install dependencies and run the tests locally. npm test needs port 8765 free: stop the Docker container first, because the tests start their own server on that port.
$ npm ci
$ docker compose stop
$ npm test
$ docker compose up -d
More detail
A single test file can be run on its own:
$ node --import tsx --test test/<file>.test.ts
This page is plain static HTML in site/index.html. Vercel serves the site/ directory as-is, with no install and no build (see vercel.json).
Scope (v1)
Web only (Chromium) and local only. The server runs over HTTP/SSE by default and over stdio through dist/stdio.js (see Running over stdio). Android (Appium) work is on hold: the infrastructure is in the image, but no android_* tools are registered.
More docs
See AGENTS.md for the architecture and full MCP connection guide, and HARNESS.md for the REST API reference. Deploy/rollback procedures are in RUNBOOK.md. See PRD.md for full product intent and PROJECT_SPEC.md for architecture decisions.