# QA methodology The full test loop, gotchas, rubric, and output format for the `qa` skill. Everything below runs through **browser-harness** (the `browser-harness <<'PY' … PY` heredoc form — each call is a fresh shell, daemon auto-starts). ## Always test on a cloud browser (tunnel localhost) **Run every QA test on a Browser Use cloud browser — never the user's local Chrome.** This is the default for *all* targets, public or local. A cloud browser is a clean, real-user environment: no logged-in sessions, no extensions, no clobbering the tab the user is working in, and it produces a `liveUrl` the user (and the report) can point to. Testing on the user's own Chrome contaminates the result with their state and is not what QA wants. This means a `localhost` site is **not** an exception that lets you skip the cloud browser — it's the case that *requires the tunnel*. A cloud browser lives on the public internet and cannot reach `localhost`, so you expose the dev server with a tunnel first, then point the cloud browser at the public URL. Do **not** fall back to the local daemon just because the site is local; tunnel it out. **0. Get a Browser Use API key — the only credential this skill uses.** The cloud browser authenticates with `BROWSER_USE_API_KEY`, and browser-harness is the single source of it: it auto-loads a `.env` (from its repo root and `agent-workspace/`) on every call, with the process env winning over that. Use *only* that key — never substitute another credential, and **never fall back to the user's local Chrome** if it's absent. Don't assume it's missing just because it isn't echoed in your shell; the authoritative test is whether a cloud browser starts (step 2's `start_remote_daemon(...)` returns a `liveUrl`). If no key is resolvable, do **not** proceed on local Chrome. Pick one of exactly two paths (ask the user which, if it's unclear), then make the key available and retry step 2: - **A — Wait for the user to provide one** (they grab one at `cloud.browser-use.com/new-api-key`). - **B — Self-sign-up, no human needed** — run the agent challenge-response flow documented in `docs.browser-use.com/llms.txt` (the browser-harness README links it under setup) to get a free key. The challenge is a *randomized, often obfuscated* word problem (leetspeak, foreign-language numerals, multi-step) — not always simple arithmetic. If one is hard to decode confidently, just re-request a fresh challenge; difficulty varies and re-rolling is the fastest unblock. To make the key available, prefer the **inline form** — `export BROWSER_USE_API_KEY=bu_… ` at the top of every `browser-harness` call. This always works and sidesteps a real trap: the package that *runs* (a uv/pip install) often lives in a **different tree** than the skill/docs directory you're reading, so "browser-harness's `.env`" is ambiguous. If you do want to persist it via `.env`, write to the **running package's** repo root (`parents[2]` of the executing `helpers.py`/`admin.py`, e.g. `~/Developer/browser-harness/.env`) — not necessarily the folder these skill files live in. **A tunnel binary must also be installed and authed** — but only for a **localhost** target (a public URL skips the tunnel entirely, so don't block on this for public sites). Check before you rely on it, and recover if it's missing: ```bash # ngrok is the default; cloudflared is the no-account fallback. command -v ngrok && ngrok config check # installed AND authed? command -v cloudflared # fallback that needs no account/auth ``` - **ngrok present and `config check` passes** → use it (step 1). - **ngrok present but `config check` fails** (no authtoken) → it's installed but unauthed. Either ask the user to run `ngrok config add-authtoken ` (free token at `dashboard.ngrok.com`), or fall through to cloudflared. - **Neither installed** → don't silently give up. Prefer **cloudflared** — it needs no account, no auth, and has no interstitial: install it (`brew install cloudflared` on macOS, or the binary from `github.com/cloudflare/cloudflared/releases`) and tunnel with `cloudflared tunnel --url http://localhost:PORT`. If you can't install either, **stop and tell the user** which one to install (don't fall back to local Chrome — that violates the cloud-browser rule). **1. Tunnel the local port — with host-header rewrite.** ngrok is the default (already on `$PATH`); cloudflared is a friction-free alternative if installed. **Use `--host-header=rewrite`:** modern dev servers (Vite, Next, webpack, CRA) reject requests whose `Host` is an unknown public domain with a `403 Blocked request / host not allowed` (Vite's `server.allowedHosts`). Rewriting the Host to `localhost:PORT` makes the dev server see a local request. Start it in the background and read the assigned URL from ngrok's agent API — don't scrape stdout. ```bash # dev server is on, say, http://localhost:3000 ngrok http 3000 --host-header=rewrite --log=stdout > /tmp/qa-ngrok.log 2>&1 & sleep 3 PUBLIC_URL=$(curl -s http://localhost:4040/api/tunnels \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["tunnels"][0]["public_url"])') echo "$PUBLIC_URL" | tee /tmp/qa-public-url.txt # Verify the tunnel reaches the APP, not a 403/interstitial, before spending a cloud browser: curl -s -H "ngrok-skip-browser-warning: true" "$PUBLIC_URL" | head -c 200 # cloudflared alternative (no account, no interstitial): cloudflared tunnel --url http://localhost:3000 ``` For a **public** target, skip the tunnel — just use the URL directly in step 2. **2. Spin up a cloud browser — with the BU proxy DISABLED — and drive the public URL.** `start_remote_daemon` creates the cloud browser, prints its `liveUrl`, and wires the daemon to `BU_NAME`. **Pass `proxyCountryCode=None`:** Browser Use's default residential proxy mangles ngrok's TLS, so the cloud browser lands on `chrome-error://` / `ERR_SSL_PROTOCOL_ERROR` even though `curl` and other sites work fine. Disabling the proxy fixes it. ```bash browser-harness <<'PY' start_remote_daemon("qa", proxyCountryCode=None) # proxy off — required for ngrok TLS to work PY PUBLIC_URL=$(cat /tmp/qa-public-url.txt) BU_NAME=qa browser-harness <= 400) )] print(len(errs), "error events") for e in errs[:10]: print(e["method"], e["params"]) PY ``` ## The rubric | Score | Meaning | |-------|---------| | **5** | Task completes flawlessly. No errors, no friction, responsive and polished. A real user would have zero complaints. | | **4** | Task completes. Minor cosmetic or UX nits (a slow load, awkward copy, one console warning) but nothing that blocks or confuses. | | **3** | Task completes, but with real friction — a confusing step, a workaround needed, a non-blocking error, or a rough edge case. Usable, not good. | | **2** | Task only partially works. A significant bug blocks part of the flow, or success requires luck/retries. Most users would get stuck. | | **1** | Task cannot be completed. Critical failure: dead button, hard crash, infinite spinner, page won't load, data lost. | Anchor the score to **task completion first**, then modify for errors and polish. "It worked but threw three console errors" is a 3–4, not a 5. "It looked beautiful but the submit button does nothing" is a 1, not a 4 — looks don't rescue a broken flow. When the prompt asks about several things (e.g. "test search *and* filters"), score each sub-task, then report an overall score that reflects the weakest critical path — don't average a broken checkout up to a 3 because the homepage was nice. ## Output format Return a compact, skimmable verdict. Lead with the number. ``` Score: 3/5 Task: Sign up with a new email and reach the dashboard. Result: Completed, but with friction. What worked: - Form accepted valid input, account created, redirected to dashboard. Issues: - [blocker?] no — "Email already in use" error rendered as raw "[object Object]" (saw it on retry). - [console] TypeError in analytics.js on every page (Runtime.exceptionThrown, see /tmp/qa-03.png). - [ux] no loading indicator on submit; looked frozen for ~4s. Edge cases tried: empty email (handled, inline error ✓), 8s submit latency (no spinner ✗). Evidence: /tmp/qa-01-landing.png, /tmp/qa-03-error.png ``` Keep it honest and specific — "the submit button at the bottom of the form did nothing and logged a 500" beats "signup is broken". Cite screenshots and the actual error text so the score is defensible. ## Tips - **Don't over-trust a clean screenshot.** Always `drain_events()` — many failures (analytics crashes, 4xx/5xx APIs, unhandled promise rejections) are invisible in pixels. - **Reset state between runs** when a flow is one-shot (signup with a used email): use a fresh email/value, or a clean tab, so a failure is the *site's* fault not stale state. - **Time things that matter.** A 12-second load or a spinner that never resolves is a scoring fact, not a footnote. - **Stop at auth/payment walls you can't legitimately pass.** Score what you could verify, and state plainly what you couldn't reach and why. - **Mind real, billed, or destructive actions.** When the app under test itself provisions resources (spins up VMs/browsers, sends emails, charges cards), exercise the path *minimally* — one item, not a burst — and avoid bulk-destructive controls ("delete all", "stop all") that could nuke state you didn't create (including your own test harness or the user's pre-existing data). Clean up only what you made, ideally via API so you can target it precisely. If you can't test something without collateral damage, say so instead of doing it. - **Be reproducible.** Note the exact URL, the steps, and the inputs you used so someone can rerun your test.