agentsclimarketplace

Goose gui e2e

Skill Miyamura80/MCP-Template/.agents/skills/goose-gui-e2e

Drive the real Goose desktop GUI (Electron) as an MCP client via Playwright to end-to-end test this template's MCP Apps - asserting the ui:// iframe actually renders and round-trips in a real host. Use to run, add, or debug MCP-App e2e scenarios.From its SKILL.md

Install
npx -y skills add Miyamura80/MCP-Template --skill goose-gui-e2e

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

12.0 KB, ~3.1k tokens by cl100k_base, as published. Nobody here has run it

Goose-GUI end-to-end testing of the MCP Apps

Goose (Block's open-source agent, an Agentic AI Foundation reference MCP client) renders MCP Apps natively - it fetches a tool's ui:// resource and mounts the HTML in a sandboxed iframe. This skill stands up the real Goose desktop app (Electron), wires this template's /mcp mount into it as a streamable-HTTP extension, drives a chat scenario with Playwright-Electron (DOM selectors, no pixel coordinates), and decides PASS/FAIL from two records the mock LLM cannot fake: the rendered iframe DOM and the tool-call round-trip log.

This closes the L4 "rendered iframe in a real host" gap from issue #37 - the layer tests/test_mcp_e2e.py (wire) and the per-app vitest (component) deliberately don't cover. It's ported from Edison-Watch's goose-gui-e2e harness; the Goose build under $E2E_HOME/goose_src is repo-agnostic and shared with it.

   scenario (JSON: prompt + tool plan + expect)
        │  Playwright-Electron drives the DOM
        ▼
   mock LLM ──emits webhook_settings──► Goose desktop GUI ──/mcp──► template server
   (scenario engine)                    (Electron, real)            (FastMCP + enhancer
        ▲                                     │  renders ui://       attaches ui://mymcp/settings)
        │                                     ▼
        └──────────────────────────  sandboxed iframe (Settings app)
                                             │
                     assert: iframe DOM (pw_result) + tool round-trip (toolcalls.jsonl)

Quickstart

cd .agents/skills/goose-gui-e2e/scripts     # or the symlink under .claude/skills/
export E2E_HOME="$HOME/goose-e2e"           # working dir (default; override freely)

bash setup.sh                # ONE TIME: build goose + Electron + bundles (~6-8 min). Idempotent.
bash up.sh                   # seed e2e DB + API key; start template server + mock + vite (idempotent)
bash run_test.sh settings_render   # drive the GUI + assert -> PASS/FAIL, screenshot in $E2E_HOME
bash run_all.sh              # run every scenario, print a summary
bash down.sh                 # stop test services  (down.sh --reset also drops the e2e DB + key)

make test_apps_e2e runs up.sh && run_all.sh. Each run_test.sh writes a screenshot of the real Goose GUI (with the Settings app rendered) to $E2E_HOME/shot_<scenario>.png.

Where this runs in the suite

Heavy tier - not in make ci (which stays Node/Rust/Electron-free):

  • Local / make: make test_apps_e2e (after the one-time setup.sh).
  • Full pytest suite: tests/test_apps_e2e.py is a collectable member, skipped by default; opt in with RUN_APPS_E2E=1 (e.g. RUN_APPS_E2E=1 uv run pytest tests/test_apps_e2e.py --no-cov). It shells out to up.sh + run_all.sh. (--no-cov keeps a single-file run from tripping pytest.ini's coverage gate.)
  • CI: .github/workflows/apps_e2e.yaml - workflow_dispatch + weekly cron. It builds Goose via setup.sh (open egress, so the npmmirror preconditions below don't apply on GitHub runners) and runs the guarded pytest entry.

Preconditions (verify before setup)

  • npmmirror on the network allowlist. Electron's binary is fetched from a mirror because GitHub release assets are egress-blocked in the sandbox. In the environment's Network access → Custom, add registry.npmmirror.com and cdn.npmmirror.com (keep the default package-manager list checked). setup.sh preflights this and fails loudly if it's missing.
  • Sandbox provides: cargo, pnpm/node, bun, uv, xvfb-run. setup.sh uses git clone (allowed) + the mirror - none of the GitHub-blocked paths.

How a scenario works

A scenario is JSON in scripts/scenarios/<name>.json:

{
  "prompt": "Open my settings.",
  "plan": [ { "match": "webhook_settings" } ],
  "final": "Opened your settings.",
  "app_uri": "ui://mymcp/settings",
  "expect": {
    "tool_called": "webhook_settings",
    "round_trip": true,
    "app_rendered": true,
    "dom_contains": ["Settings", "Gmail"]
  }
}
  • prompt - the user message typed into Goose.
  • plan - the tool-call sequence the mock emits. match is a substring of the tool name Goose offers (Goose may namespace extension tools, so substring not exact). A plan step may carry "args" (e.g. {"match": "gmail_get_thread", "args": {"thread_id": "t-1001"}}) which the mock passes through as the tool call's arguments. Pick an app-returning tool: webhook_settingsui://mymcp/settings renders offline with zero external deps, and gmail_get_threadui://mymcp/gmail_inbox renders offline because the stack sets GMAIL_FAKE_BACKEND=1 (see below).
  • expect - checked by mcp_probe.py: tool_called + round_trip (from the tool-call log) and app_rendered + dom_contains (from the rendered iframe).

Rendering the Gmail apps offline (GMAIL_FAKE_BACKEND)

The gmail_inbox / gmail_composer apps normally need a linked Google account: gmail_get_thread_get_gmail_client() raises GmailNotConnectedError (ConnectRequiredError) with no token row, so the tool errors before send_app and the iframe never mounts. To render them offline, up.sh starts the server with GMAIL_FAKE_BACKEND=1, which makes _get_gmail_client return a fixture-serving fake (services/_gmail_fake_backend.py) instead of hitting Google - no linked account, OAuth, or network. The fixtures are raw-Gmail-API shaped, so the real service parsing → Pydantic GmailThread runs end-to-end; only the network boundary is faked. The flag is hard-refused under DEV_ENV=prod (guard in services.gmail_svc._maybe_fake_gmail_client), so it can never stand in for a real mailbox in production.

Why the fake also serves the curated inbox: the gmail_inbox app registers its ontoolresult handler in a mount effect, and if the host delivers the thread result before that runs, the app misses it and falls back to a curated-inbox refresh (gmail_inbox.refreshgmail_curate_inbox) after ~800ms. So a thread-open renders the reader when it wins that race and the curated list when it loses. The fake serves both paths from the same fixture thread, and gmail_thread_render asserts on content present in both views, so the scenario is deterministic instead of ~30% flaky. gmail_get_thread's round-trip is still proven independently via the tool-call log regardless of which view renders.

Optional: a click → callServerTool → re-render step

A scenario may add an interact block to drive a real control inside the app iframe after it renders, exercising a user-initiated server round-trip (the settings_subscribe fixture does this - clicks "Add endpoint" → settings.subscribe):

"expect": { "...": "...", "interaction_rendered": true },
"interact": {
  "fill":  { "selector": "input[type=url]", "value": "https://webhook.example.com/e2e" },
  "click": { "text": "Add endpoint" },
  "expect_dom_contains": ["Signing secret", "webhook.example.com"]
}

The iframe's callServerTool goes app → Goose → /mcp directly, bypassing the mock LLM, so this round-trip never appears in the tool-call log - the re-rendered DOM (expect_dom_contains, here the returned signing secret) is the only proof, and only the server's real response can produce it. For post-interaction DOM that plain text can't see, interact also accepts "expect_selectors": ["css", ...] - CSS selectors that must each match at least one element (e.g. img[src^="data:image/png;base64,"] to prove a fetched image's data URI was written into the iframe DOM - presence, not paint). interaction_rendered: true makes mcp_probe.py require it, and pw_scenario.mjs fails the drive if the post-click DOM never matches. This needs the "Add endpoint" control, which renders only when push_available is true - up.sh sets GMAIL_PUBSUB_TOPIC to ungate it (no Pub/Sub is ever contacted).

Why the mock cannot fake a PASS

The mock only follows the plan and returns canned narration; it never inspects tool results. The verdict is computed by mcp_probe.py from two records the mock doesn't control:

  • the rendered iframe - pw_scenario.mjs frameLocators into the real Goose DOM and asserts the Settings app's text. The iframe only exists if the server returned the app resource and Goose fetched + mounted it - a full round-trip.
  • the tool-call log - the calls Goose actually issued to /mcp and the results the server returned.

Two gates stop a no-op run from passing on stale data:

  • Drive must render. pw_scenario.mjs exits non-zero unless the app iframe rendered with the expected DOM; run_test.sh checks it (PIPESTATUS[0]).
  • Log must be new. run_test.sh snapshots the tool-call-log line count before driving; mcp_probe.py FAILs if no new entries were appended for this run.

Mock vs. real LLM

  • Mock (default): deterministic, offline, CI-friendly. configure_goose.sh mock.
  • Real LLM: configure_goose.sh real anthropic $KEY (model via $GOOSE_REAL_MODEL). The agent decides tool calls from the prompt; requires an API key and the provider host on the allowlist. The render/round-trip assertions are unchanged.

Sandbox gotchas already handled (do not re-discover)

Baked into the scripts; listed so you recognize them if something drifts:

SymptomCause → handled by
Electron binary 403GitHub release assets blocked → mirror + checksum (setup.sh)
@electron/node-gyp 403 on pnpm installgit tarball on codeload blocked → registry override (setup.sh)
Electron SIGSEGV "Missing X server"needs a display → xvfb-run owns Xvfb per test
Electron crash under Xvfb--no-sandbox --disable-gpu (NOT --in-process-gpu)
CDP /json empty / debug-port hangPlaywright-Electron launches over a CDP pipe
bg service dies next tool callsetsid ... & disown (start_detached); re-run up.sh to recover
/mcp 401the extension must send the seeded X-API-KEY header (configure_goose.sh)
app iframe not foundassert only on an app-returning tool; the Settings app renders offline

Files

FileRole
lib.shpaths/ports + bounded-wait/setsid helpers
setup.shone-time provisioning: build goose, Electron via mirror, pnpm install, dev bundle
seed.pycreate the e2e SQLite schema + one API key; prints the raw key
up.sh / down.shidempotent stack bring-up / teardown
configure_goose.shwrite Goose config for mock/real, wiring the mymcp extension at /mcp
mock_llm.pyscenario-engine mock LLM (follows the plan, logs tool calls)
pw_scenario.mjsPlaywright-Electron driver + rendered-iframe DOM assertion
mcp_probe.pyassertion oracle - rendered iframe (pw_result) + tool round-trip (toolcalls.jsonl)
run_test.sh / run_all.shdrive + assert one / all scenarios
scenarios/settings_render.jsonscenario 1: LLM opens the Settings app; assert it renders
scenarios/settings_subscribe.jsonscenario 2: click "Add endpoint" → settings.subscribe → assert the re-render
scenarios/gmail_thread_render.jsonscenario 3: LLM opens a thread via gmail_get_thread; assert the gmail_inbox reader iframe renders (needs GMAIL_FAKE_BACKEND)
scenarios/gmail_remote_images.jsonscenario 4: remote-image pipeline - assert the blocked-by-default "Show images" banner renders, click it, and assert the gmail_inbox.fetch_image round-trip re-renders as "Retry" (fixture URL is .invalid, so the SSRF guard rejects it deterministically offline and in CI)

Also outside the skill: tests/test_apps_e2e.py (guarded pytest entry) and .github/workflows/apps_e2e.yaml (opt-in CI).

What ships with it: 19 files

53.6 KB alongside SKILL.md, 12 of them executable

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.