Local/manual harness for running Claude Code against the development XcodeBuildMCP MCP server and auditing UI automation behavior.
The harness:
- reads a suite YAML file from
benchmarks/claude-ui/suites/ - reads the referenced prompt Markdown file from disk and feeds it to
claude -p - creates, boots, waits for, and opens a fresh temporary simulator before Claude launches for each suite run by default
- writes an isolated per-run MCP workspace config with the suite defaults and temporary
simulatorId - generates a Claude MCP config pointing at
node build/cli.js mcpwithXCODEBUILDMCP_CWDset to that isolated workspace - optionally preflights configured first-run prompts before Claude launches, outside the measured run
- deletes the temporary simulator at the end of the suite, best effort, using only the ID created by the harness
- writes artifacts under
out.nosync/claude-benchmarks/<suite>/<timestamp>/ - runs the bundled
parse_claude_conversation.pyparser against Claude's stream JSONL - audits tool counts, MCP calls, UI automation calls, wall clock, failures/stumbles, and expected tool sequence drift
- prints a structured per-suite report and (for
--all) an aggregate summary - optionally prints machine-readable JSON with
--json - can render an existing
result.jsonor artifact directory with--from-resultwithout rerunning Claude
This is intentionally not part of the normal test suite because it launches Claude and drives local simulators/apps.
Build first, then run a suite:
npm run build
npx tsx benchmarks/claude-ui/run.ts --suite weatherShortcut:
npm run bench:claude-ui -- --suite weatherRun every suite YAML:
npm run bench:claude-ui -- --allPrint machine-readable output from a new run:
npm run bench:claude-ui -- --suite reminders --jsonRender an existing result without rerunning Claude:
npm run bench:claude-ui -- --from-result out.nosync/claude-benchmarks/reminders/20260522T130926Z
npm run bench:claude-ui -- --from-result out.nosync/claude-benchmarks/reminders/20260522T130926Z/result.json --jsonNew runs use the bundled parser at benchmarks/claude-ui/parse_claude_conversation.py. Pass --parser /path/to/parse_claude_conversation.py or set CLAUDE_UI_BENCHMARK_PARSER only when testing a different parser. --from-result does not need a parser because it only re-renders existing artifacts.
name: weather
prompt: ../prompts/weather.md
workingDirectory: example_projects/Weather
sessionDefaults:
projectPath: Weather.xcodeproj
scheme: Weather
simulatorName: iPhone 17 Pro Max
temporarySimulator: true
firstRunPromptDismissals:
labels:
- Continue
- Not Now
timeoutSeconds: 12
baseline:
totalToolCalls: 19
mcpToolCalls: 18
uiAutomationCalls: 16
wallClockSeconds: 125
tools:
snapshot_ui: 1
tap: 9
allowedVariance:
totalToolCalls: 2
mcpToolCalls: 2
uiAutomationCalls: 2
wallClockSeconds: 45
toolCalls: 2
expectedToolSequence:
- session_show_defaults
- build_run_sim
- snapshot_ui
sequence:
mode: warn
failurePatterns:
- STALE_ELEMENT_REF
- SNAPSHOT_MISSING
- WAIT_TIMEOUTVariance is an upper bound: lower tool counts or faster runs are accepted, while values above baseline + allowedVariance fail. Defaults are totalToolCalls: 0, mcpToolCalls: 0, uiAutomationCalls: 0, toolCalls: 0, and wallClockSeconds: 30.
Tool sequence drift is warning-only by default (sequence.mode: warn) because real Claude runs can choose equally valid UI paths. Use sequence.mode: fail only for suites where exact MCP call order is part of the contract.
sessionDefaults are written to a harness-owned config at <run>/mcp-workspace/.xcodebuildmcp/config.yaml. The generated Claude MCP config sets XCODEBUILDMCP_CWD to <run>/mcp-workspace, so the dev MCP server reads only the benchmark config instead of any repo or example-project .xcodebuildmcp/config.yaml. Unknown keys fail fast. Relative path defaults such as projectPath, workspacePath, and derivedDataPath are resolved against the suite workingDirectory before being written because the MCP server cwd is the isolated workspace.
By default, each suite creates a fresh simulator before Claude launches. The harness uses sessionDefaults.simulatorName as the simctl create device type name, captures the returned simulator ID, boots that simulator, waits for simctl bootstatus <id> -b, opens Simulator.app to that device, applies a short UI-readiness delay, and writes the simulator ID as sessionDefaults.simulatorId in the isolated MCP workspace config. This makes Claude and the dev MCP server target a visible, booted, isolated simulator instead of reusing a previous run's state or spending benchmark calls on simulator boot/open setup.
Simulator setup is deliberately outside the benchmark measurement boundary. The measured wallClockSeconds starts when the harness spawns Claude and stops when Claude exits. Tool-call counts are parsed only from Claude's JSONL transcript. The result JSON still records temporary simulator setupDurationSeconds under run.temporarySimulator so setup cost is visible without being compared against Claude task-efficiency baselines.
Config contract:
- Omit
temporarySimulatorfor the default behavior: create and later delete a temporary simulator. - Set
temporarySimulator: falseto opt out and use the suite/project defaults as-is. - Set
sessionDefaults.simulatorIdto use an existing simulator. In this case the harness does not create or delete a simulator. - Do not set both
temporarySimulator: trueandsessionDefaults.simulatorId; the harness fails fast because deleting a user-provided simulator would be unsafe.
Temporary simulator setup is required when enabled. If creation, boot, bootstatus, or Simulator.app opening fails, the suite fails loudly before Claude starts. Deletion is best effort in a finally block: failures are logged but do not mask the benchmark result or original error.
firstRunPromptDismissals is an optional suite-level preflight for fresh simulator noise such as Apple first-run sheets. When configured, the harness launches sessionDefaults.bundleId before Claude starts, retries through transient UI-inspection failures, looks for any listed button labels, taps matching labels with AXe, then terminates the app. If the prompt state cannot be inspected or dismissed before timeoutSeconds, the suite fails before Claude starts. These preflight interactions are logged in simulator-lifecycle.log, but they are outside Claude's wall-clock measurement and do not appear in tool-call counts. Keep the labels generic and non-destructive, for example Continue, Not Now, or OK; do not configure sign-in, sync enablement, Settings, destructive, or data-deletion actions.
Lifecycle details are written to simulator-lifecycle.log, including the create, boot, bootstatus, open, readiness delay, optional first-run prompt preflight, and deletion steps. claude-command.log also records the simulator ID used for the run. The terminal report shows the temporary simulator ID plus setup duration as setup ... before Claude when a temporary simulator is used.
Each suite renders as a structured report with a status banner, aligned metric and tool tables, a failures/stumbles section (only when non-zero), and a sequence diff. When run with --all, an aggregate summary follows the per-suite reports.
────────────────────────────────────────────────────────────────────────
PASS weather 1m 38.6s
suite benchmarks/claude-ui/suites/weather.yml
artifacts out.nosync/claude-benchmarks/weather/20260522T214044Z
exit claude=0 parser=0
Metrics
METRIC ACTUAL BASELINE VARIANCE DELTA STATUS
totalToolCalls 13 19 +2 −6 PASS
mcpToolCalls 12 18 +2 −6 PASS
uiAutomationCalls 10 16 +2 −6 PASS
wallClockSeconds 98.62 125.00 +45.00 −26.38 PASS
Tool calls (baseline-tracked)
TOOL ACTUAL BASELINE DELTA STATUS
session_show_defaults 1 1 0 PASS
build_run_sim 1 1 0 PASS
snapshot_ui 1 1 0 PASS
tap 6 9 −3 PASS
batch 1 1 0 PASS
PASS failures/stumbles: 0
When the tool sequence drifts, the report includes unified-diff style hunks with expected/actual index columns. Drift is warning-only by default, so the overall status stays WARN rather than FAIL:
WARN tool sequence (warn): drift: 4 missing, 0 additional
@@ expected[8..15] actual[8..11] @@
8 8 tap
9 9 tap
10 − tap
11 10 swipe
12 11 tap
13 − swipe
14 − tap
15 − tap
− lines are expected calls Claude skipped; + lines are calls Claude made that were not expected. Dim lines are surrounding context.
When failures/stumbles is non-zero the report lists the first few tool failures and pattern matches, and surfaces an Inspect block with the relevant artifact paths:
FAIL failures/stumbles: 1
• tool failures: 1
boot_sim @ line 9: Boot failed: device not found
Inspect
result.json out.nosync/claude-benchmarks/reminders/20260522T213905Z/result.json
transcript out.nosync/claude-benchmarks/reminders/20260522T213905Z/claude.jsonl
stderr out.nosync/claude-benchmarks/reminders/20260522T213905Z/claude.stderr
run dir out.nosync/claude-benchmarks/reminders/20260522T213905Z
After --all (or multi-result --from-result) the harness appends:
════════════════════════════════════════════════════════════════════════
Claude UI Benchmarks · Summary
════════════════════════════════════════════════════════════════════════
Suites: 3 total · 2 passed · 1 failed · 2 sequence warnings
Duration: total 4m 49.8s · slowest reminders (1m 39.8s)
Artifacts: out.nosync/claude-benchmarks/
! WARN weather 1m 38.6s sequence warn: 4m/0a
✗ FAIL reminders 1m 39.8s 1 stumble · sequence warn: 7m/4a
! WARN contacts 1m 31.4s sequence warn: 2m/2a
════════════════════════════════════════════════════════════════════════
Nm/Ka denotes "N missing / K additional" calls vs. expectedToolSequence.
The renderer auto-detects TTY and adds ANSI color when stdout is a terminal and NO_COLOR is unset. Plain-text output (e.g. when piping to a file or under NO_COLOR=1) carries the same information without color codes.
--json output is unchanged by this renderer: the JSON payload remains a single BenchmarkResult for --suite / single-result --from-result, and an array for --all / multi-result --from-result.
Each run writes:
prompt.md— exact suite prompt fed to Claudemcp-config.json— generated Claude MCP configmcp-workspace/.xcodebuildmcp/config.yaml— isolated MCP server config with effective suite defaultsclaude.jsonl— Claude stream JSON outputclaude.stderr— Claude stderrclaude-command.log— command, cwd, simulator ID, exit status, wall clocksimulator-lifecycle.log— temporary simulator create, boot, bootstatus, open, readiness, deletion commands, and simulator IDparsed/— files written byparse_claude_conversation.pyparse.log/parse.log.stderr— parser outputresult.json— full benchmark result