Easy UI ui testing · mcp server
GitHub

> 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.html lists them all.

  • Testing primitivessessions and steps

    ui_start_session and ui_end_session bracket a run. ui_step names each stage. ui_assert, ui_check and ui_wait_for are 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 add registers it. One connection runs up to 3 sessions side by side. dist/stdio.js serves 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

  1. Claude Codedecides each step
  2. MCP over HTTPlocalhost:8765/mcp
  3. easy-ui-mcpNode server in Docker
  4. Playwrightheadless Chromium
  5. Page under testyour app, on the host

return · the answer and the evidence

  1. Page respondswhat Chromium sees
  2. Playwrightreturns the outcome
  3. easy-ui-mcp logs itevery action, server-side
  4. Claude Code readsthe result text
  5. 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. 1 · open

    1. Startui_start_session
    2. Label (optional)ui_step
    3. Go to the pageui_navigate
  2. 2 · loop, as often as needed

    1. Lookui_get_page_state
    2. Actui_click · ui_fill · ui_type · …
    3. Checkui_check · ui_assert · ui_wait_for

    repeat until the flow is done

  3. 3 · close

    1. Endui_end_session
    2. 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

  1. 01 · Build and start

    $ docker compose up -d --build

    Builds the image and starts the container.

  2. 02 · Check it is alive

    $ curl http://localhost:8765/health
    # {"status":"ok"}

    Port 8765 on localhost.

  3. 03 · Connect Claude Code

    $ claude mcp add --transport http easy-ui-mcp http://localhost:8765/mcp

    Registers 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.

  1. Chromiumin the container, network_mode: host
  2. localhost:<port>the same network as your machine
  3. 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.

One MCP connectionClaude Code

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_session
  • ui_step
  • ui_end_session

Navigate3

  • ui_navigate
  • ui_navigate_history
  • ui_scroll

Read the page3

  • ui_get_page_state
  • ui_get_text
  • ui_take_screenshot

Act8

  • ui_click
  • ui_fill
  • ui_type
  • ui_select_option
  • ui_press_key
  • ui_hover
  • ui_set_checked
  • ui_drag

Sizes1

  • ui_set_viewport

Check and wait3

  • ui_check
  • ui_assert
  • ui_wait_for

Dialogs and overlays2

  • ui_dialog
  • ui_click_outside

Tabs, files and logs3

  • ui_tabs
  • ui_upload_file
  • ui_get_logs
What each tool does
toolpurpose
ui_start_sessionOpen a fresh browser context and start logging into a report. Optional device, viewport, color scheme, locale and timezone.
ui_end_sessionWrite the JSON and HTML report and return their paths.
ui_stepLabel what you are about to do; following actions are grouped under it in the report.
ui_navigateNavigate the browser to a URL.
ui_get_page_stateReturn 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_textRead the visible text of one element, or of the whole page, to check a value, heading or error.
ui_take_screenshotCapture 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_clickClick the single element matching a selector; optionally right, double or modifier click, at an offset, or forced.
ui_click_outsideClick just outside an element, such as a modal backdrop, to close it the way a user would.
ui_dialogAnswer a native alert, confirm or prompt the browser is holding open, or pre-set the answer for the next one.
ui_fillFill the single input matching a selector with a value.
ui_select_optionChoose an option of a <select> by value or visible label.
ui_press_keyPress a key or combo such as Enter, Tab, Escape or Control+a, on an element or on whatever has focus.
ui_hoverMove the pointer over an element to reveal hover content such as dropdown menus.
ui_set_checkedSet a checkbox or radio button to checked or unchecked.
ui_scrollScroll 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_viewportResize the page, for example to a 375px phone or a tablet.
ui_typeType into an input one key at a time, like a user (autocomplete, masked inputs, OTP boxes).
ui_navigate_historyGo back, go forward or reload, keeping the browser history.
ui_get_logsRead the console errors, page errors, failed requests and HTTP 4xx/5xx responses captured so far, mid-flow.
ui_tabsList, switch to or close the session's browser tabs (popups and target=_blank links open new ones).
ui_upload_fileUpload the built-in default_draft.png to a file input or to the chooser a button opens. Takes no path.
ui_dragDrag one element onto another: drag-and-drop lists, kanban cards, sortables.
ui_assertHard check of a JS expression: false fails the whole session.
ui_checkSoft check of a JS expression: recorded in the report, the run continues.
ui_wait_forPoll 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 @eN ref, 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 @oN line, 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 key for true and key=value otherwise: disabled, checked (checked, checked=false or checked="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, contenteditable regions, and anything with a role and tabindex >= 0.
  • overlay @o1 dialog "Confirm" modal lists each open dialog, topmost first (role=dialog, alertdialog, dialog[open], aria-modal); @o1 is accepted wherever a selector is, for example by 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 (as role "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 suggests viewport and within), 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: true lists only elements whose bounding box is at least partly on screen at the current scroll position.
  • within (a selector or an @eN / @oN ref) 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, focused and messages are 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.

  1. Clickui_click
  2. Dialog opensthe call returns at once: "Browser is waiting on a confirm dialog"
  3. Other tools refuseduntil you answer
  4. 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
light or dark.
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.

  • Logsui_get_logs reads console errors, page errors, failed requests and HTTP 4xx/5xx so far, at most 50 lines per call.
  • Tabsui_tabs lists, switches or closes tabs. A new tab is announced; the session does not switch on its own.
  • Filesui_upload_file sends one built-in file, default_draft.png. It takes no path. ui_drag drags 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.

  1. Any HTTP clientcurl, a script
  2. POST /api/run-testa URL and an optional label
  3. One testnavigate and screenshot
  4. 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.

fieldrequireddescription
app_url_or_packageyesURL to navigate to. Non-empty string, else 400.
flow_descriptionnoLabel for the run in reports. Defaults to the URL.
platformnoOnly "web" is supported in v1; anything else returns 400.
timeoutnoMilliseconds, 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

Beforelargest report seen357 KB
Afterlive soft-check report4.0 KB

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

Plain elementa div~33 ms
Likely openera button, or a link with target or download~185 ms

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

symptomlikely causeremediation
"port is already allocated" on docker compose upA 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 succeededA 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 EADDRINUSEPort 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_REFUSEDThe 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 traceThe 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.