Guide #6 Use Case — Engineering 20 min read March 2026

The AI QA Engineer: Test Every User Flow After Every Deploy

After every deployment, an agent opens your app in parallel browser containers, navigates every critical user flow using computer-use vision, and posts a pass/fail report with annotated screenshots to Slack. It adapts when your UI changes — no selector maintenance. Cost: $0.15 per run.

The QA that nobody has time for

Your CI pipeline runs unit tests. Maybe integration tests. Maybe even a Playwright suite that somebody wrote six months ago and nobody maintains. But the thing that actually catches regressions — a human opening the app and clicking through the main user flows — doesn't happen. Not on every deploy. Not even on most deploys.

The reason is obvious: it takes too long. A QA engineer clicking through signup, login, dashboard, settings, billing, and checkout takes 20–30 minutes. You deploy 3–5 times a day. Nobody is doing 90 minutes of manual QA every day. So you ship and pray.

The AI QA engineer does the clicking. After every deploy, it opens your app in browser containers — multiple browsers in parallel, testing different flows simultaneously. It doesn't use CSS selectors or XPath. It looks at the screen with Claude's vision, the same way a human tester would, and navigates by recognizing buttons, forms, and text. When your UI changes, the agent adapts. No selector maintenance. No flaky test repairs.

Before: Playwright / Cypress test suites

  • Break every time the UI changes — selector rot
  • 30–50% of engineering time spent maintaining tests
  • Flaky tests that nobody trusts, everyone ignores
  • No visual verification — tests pass even if the page looks broken
  • Written by engineers who'd rather be building features
  • Coverage declines as the product grows

After: AI vision-based QA

  • Adapts to UI changes automatically — no selectors
  • Zero maintenance on test definitions
  • Describes flows in English, not code
  • Takes screenshots — visual verification built in
  • Written by anyone who can describe a user flow
  • Adding a new flow takes 5 minutes
$0.15
Cost per full QA run
3 min
Wall clock (parallel)
0
Selectors to maintain

How it works

A deploy webhook triggers the QA agent. It creates one browser container per user flow, each running a real Chrome instance. The agent navigates each flow using vision — taking a screenshot at every step, reasoning about what's on screen, and deciding what to click. After all flows complete, it compiles a report with pass/fail status and annotated screenshots, then posts it to Slack.

Deploy webhook fires
Browser containers (parallel)
Chrome #1
Signup flow
Chrome #2
Login + dashboard
Chrome #3
Billing + checkout
Chrome #4
Settings + profile
Each browser: navigate with vision → verify assertions → screenshot every step
↓ results + screenshots
Compile report → post to Slack → release all browsers

Defining user flows in English

This is the key difference from traditional test automation. You describe flows in plain English. The agent interprets the description and navigates accordingly. No selectors, no page objects, no test framework.

json — flows.json
[
  {
    "name": "Signup flow",
    "base_url": "https://staging.yourapp.com",
    "steps": [
      "Navigate to /signup",
      "Fill in email with 'test-{timestamp}@example.com'",
      "Fill in password with 'TestPassword123!'",
      "Click the 'Create Account' button",
      "Verify: the page shows a welcome message or redirects to /dashboard",
      "Screenshot: the dashboard after signup"
    ],
    "success_criteria": "User lands on dashboard with their email visible"
  },
  {
    "name": "Login + dashboard",
    "base_url": "https://staging.yourapp.com",
    "steps": [
      "Navigate to /login",
      "Enter email 'qa-user@yourcompany.com' and password 'QaPass456!'",
      "Click 'Sign In'",
      "Verify: dashboard loads with at least one widget or data panel visible",
      "Click 'Settings' in the navigation",
      "Verify: settings page loads with user profile section",
      "Screenshot: the settings page"
    ],
    "success_criteria": "Login succeeds, dashboard and settings both load correctly"
  },
  {
    "name": "Billing + checkout",
    "base_url": "https://staging.yourapp.com",
    "steps": [
      "Login as 'qa-user@yourcompany.com'",
      "Navigate to /billing",
      "Verify: current plan is shown",
      "Click 'Upgrade' or 'Change Plan'",
      "Verify: pricing table or plan selection appears",
      "Select the 'Pro' plan if available",
      "Verify: checkout form or Stripe widget loads",
      "Screenshot: the checkout page (do NOT submit payment)"
    ],
    "success_criteria": "Billing page loads, upgrade flow reaches checkout without errors"
  },
  {
    "name": "Mobile viewport",
    "base_url": "https://staging.yourapp.com",
    "viewport": {"width": 375, "height": 812},
    "steps": [
      "Navigate to /",
      "Verify: mobile navigation (hamburger menu) is visible",
      "Click the hamburger menu",
      "Verify: navigation drawer opens with links",
      "Screenshot: the mobile nav open state",
      "Navigate to /login",
      "Verify: login form is usable on mobile (no overflow, no hidden fields)"
    ],
    "success_criteria": "All pages render correctly at mobile viewport"
  }
]

The vision-based test runner

The core engine connects to a browser container via CDP, takes screenshots, and uses Claude to navigate each step. Here's the implementation.

python — qa_runner.py
import asyncio, json, time, os, base64, httpx, anthropic
from playwright.async_api import async_playwright

SANDBOX_URL = os.environ["SANDBOX_URL"]
API_KEY = os.environ["SANDBOX_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
claude = anthropic.Anthropic()


async def create_browser(client, viewport=None):
    resp = await client.post(f"{SANDBOX_URL}/api/browsers", headers=HEADERS, json={
        "browser_type": "chrome", "enable_debug_port": True,
        "idle_timeout_minutes": 10,
    })
    return resp.json()


async def wait_assigned(client, cid):
    for _ in range(40):
        s = (await client.get(f"{SANDBOX_URL}/api/containers/{cid}/status",
            headers=HEADERS)).json()
        if s["state"] == "assigned": return s
        if s["state"] == "failed": raise RuntimeError("Container failed")
        await asyncio.sleep(3)
    raise TimeoutError("Browser not ready")


async def run_flow(client, flow: dict) -> dict:
    """Run a single user flow in its own browser container."""
    flow_name = flow["name"]
    print(f"[{flow_name}] Starting...")
    screenshots = []

    browser_data = await create_browser(client)
    cid = browser_data["container_id"]

    try:
        status = await wait_assigned(client, cid)
        cdp_url = status["cdp_url"]

        async with async_playwright() as p:
            browser = await p.chromium.connect_over_cdp(cdp_url)
            ctx = browser.contexts[0]
            page = await ctx.new_page()

            viewport = flow.get("viewport", {"width": 1280, "height": 720})
            await page.set_viewport_size(viewport)

            # Execute each step using vision
            for i, step in enumerate(flow["steps"]):
                print(f"  [{flow_name}] Step {i+1}: {step[:60]}...")
                result = await execute_step(page, step, flow["base_url"])

                if result["action"] == "screenshot" or step.lower().startswith("screenshot"):
                    img = await page.screenshot()
                    screenshots.append({
                        "step": i + 1,
                        "description": step,
                        "image": base64.b64encode(img).decode(),
                    })

                if result.get("failed"):
                    # Screenshot on failure
                    img = await page.screenshot()
                    screenshots.append({
                        "step": i + 1, "description": f"FAILURE: {step}",
                        "image": base64.b64encode(img).decode(),
                    })
                    return {
                        "flow": flow_name, "status": "FAIL",
                        "failed_step": i + 1, "reason": result["reason"],
                        "screenshots": screenshots,
                    }

        return {
            "flow": flow_name, "status": "PASS",
            "screenshots": screenshots,
        }

    except Exception as e:
        return {"flow": flow_name, "status": "ERROR", "reason": str(e), "screenshots": screenshots}
    finally:
        await client.post(f"{SANDBOX_URL}/api/containers/{cid}/release", headers=HEADERS)


async def execute_step(page, step: str, base_url: str) -> dict:
    """Use Claude vision to execute one test step."""
    # Handle direct navigation steps without vision
    if step.lower().startswith("navigate to"):
        path = step.split("navigate to", 1)[1].strip().strip("'").strip('"')
        url = f"{base_url}{path}" if path.startswith("/") else path
        await page.goto(url, wait_until="networkidle", timeout=15000)
        await page.wait_for_timeout(1500)
        return {"action": "navigate"}

    # Screenshot-only steps
    if step.lower().startswith("screenshot"):
        return {"action": "screenshot"}

    # Vision-guided steps (fill, click, verify)
    img = await page.screenshot()
    b64 = base64.b64encode(img).decode()

    response = claude.messages.create(
        model="claude-sonnet-4-6-20250514",
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {
                    "type": "base64", "media_type": "image/png", "data": b64}},
                {"type": "text", "text": f"""You are a QA test agent. Execute this test step:

{step}

Look at the screenshot. Respond with ONE action:
  CLICK x y        — click at pixel coordinates
  TYPE text         — type into the focused input
  KEY combo         — press keys (e.g., KEY Tab, KEY Return)
  VERIFY_PASS note  — the verification passes, explain why
  VERIFY_FAIL note  — the verification fails, explain why
  WAIT seconds      — wait for content to load

Reply with ONLY the action line."""},
            ],
        }],
    )

    action = response.content[0].text.strip()

    if action.startswith("CLICK"):
        _, x, y = action.split()
        await page.mouse.click(int(x), int(y))
        await page.wait_for_timeout(2000)
        return {"action": "click"}
    elif action.startswith("TYPE"):
        await page.keyboard.type(action[5:], delay=30)
        return {"action": "type"}
    elif action.startswith("KEY"):
        await page.keyboard.press(action[4:])
        return {"action": "key"}
    elif action.startswith("VERIFY_PASS"):
        return {"action": "verify", "note": action[12:]}
    elif action.startswith("VERIFY_FAIL"):
        return {"action": "verify", "failed": True, "reason": action[12:]}
    elif action.startswith("WAIT"):
        await page.wait_for_timeout(int(action.split()[1]) * 1000)
        return {"action": "wait"}

    return {"action": "unknown"}

The orchestrator: parallel flows, one report

python — qa_orchestrator.py
async def run_qa_suite(flows_file: str = "flows.json"):
    with open(flows_file) as f:
        flows = json.load(f)

    print(f"Running {len(flows)} flows in parallel...")
    start = time.time()

    async with httpx.AsyncClient(timeout=300) as client:
        tasks = [run_flow(client, flow) for flow in flows]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    elapsed = time.time() - start
    passed = [r for r in results if isinstance(r, dict) and r["status"] == "PASS"]
    failed = [r for r in results if isinstance(r, dict) and r["status"] == "FAIL"]
    errors = [r for r in results if isinstance(r, dict) and r["status"] == "ERROR"]

    # Build Slack report
    status_icon = "✅" if not failed and not errors else "❌"
    report = f"""{status_icon} *QA Run Complete* ({elapsed:.0f}s)

*Passed:* {len(passed)}/{len(flows)}
*Failed:* {len(failed)}
*Errors:* {len(errors)}
"""

    for f in failed:
        report += f"\n❌ *{f['flow']}* — Step {f['failed_step']}: {f['reason']}"

    for e in errors:
        report += f"\n⚠️ *{e['flow']}* — {e['reason']}"

    print(report)

    # Save results + screenshots
    with open("qa_results.json", "w") as f:
        json.dump([r for r in results if isinstance(r, dict)], f, indent=2)

    return results

Triggering on every deploy

Wire the QA suite to your deploy pipeline. After the staging deploy completes, a webhook creates a terminal sandbox that runs the orchestrator.

bash — post-deploy hook
# In your CI pipeline, after deploy to staging:
SANDBOX=$(curl -s -X POST "$SANDBOX_URL/api/sandboxes" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"qa-run","instance_type":"ab0t.micro","auto_stop_minutes":10}')
SID=$(echo "$SANDBOX" | jq -r '.sandbox_id')

# Wait for boot, then run QA
sleep 45
curl -s -X POST "$SANDBOX_URL/api/sandboxes/$SID/execute" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"command\": \"cd /workspace && git clone https://github.com/your-org/qa-flows.git . && pip install httpx playwright anthropic && python qa_orchestrator.py\"}"

# Cleanup
curl -s -X DELETE "$SANDBOX_URL/api/sandboxes/$SID" \
  -H "Authorization: Bearer $SANDBOX_API_KEY"

Testing desktop applications

For desktop apps (Electron, Java Swing, Qt), use a desktop container instead of a browser container. The agent opens the installed application, interacts via VNC-based vision, and verifies the UI the same way.

json — desktop app test flow
{
  "name": "Desktop app — main workflow",
  "container_type": "desktop",
  "desktop_type": "ubuntu-xfce",
  "setup": "sudo dpkg -i /workspace/myapp.deb && myapp &",
  "steps": [
    "Wait for the application window to appear",
    "Click 'File' menu, then 'New Project'",
    "Type 'QA Test Project' in the project name field",
    "Click 'Create'",
    "Verify: project workspace opens with empty canvas",
    "Screenshot: the new project workspace"
  ]
}

What it costs

ItemQuantityUnit costTotal
Browser containers4 × ~3 min~$0.03/hr$0.006
Terminal sandbox (orchestrator)1 × ~4 min$0.01/hr$0.001
Claude Sonnet (vision steps)~30 screenshots~$0.005/each$0.15
Total per run~$0.16

Run it on every deploy, 5 times a day, 22 working days/month: $17.60/month for continuous QA on every deployment. Compare to a QA engineer at $100K/year ($8,300/month) or a Playwright test suite that takes 20% of an engineer's time to maintain ($1,600/month).

Vision QA vs Playwright/Cypress

DimensionPlaywright / CypressAI vision QA
Test definitionCode (TypeScript/JS)English descriptions
Selector maintenanceConstant — breaks on every UI changeNone — uses visual recognition
Visual verificationNo (passes if assertions match, even if page looks broken)Yes (sees the screen, catches visual regressions)
Speed per testFast (~5s per flow)Moderate (~45s per flow)
FlakinessHigh — timing issues, selector rotLow — waits for visual state, no selectors to rot
Adding a new flow30–60 min of coding5 min of writing English
Who can write testsEngineers onlyAnyone: PMs, designers, QA, support
Desktop app supportNo (browser only)Yes (via desktop containers)
They're complementary, not competing.

Keep Playwright for your fast unit-level browser tests (component rendering, API contract checks). Use AI vision QA for end-to-end user flow verification where you need visual correctness, selector-free resilience, and the ability for non-engineers to define tests. The AI agent catches the regressions that pass Playwright because "the button still exists, it just moved off-screen."

Production tips

Use a dedicated staging environment

Run QA against staging, not production. The agent creates test accounts, fills forms, and clicks buttons — you don't want that in prod. If you must test prod, use read-only flows (verify pages load, check content, don't submit forms).

Seed test accounts before each run

Create a qa-user@yourcompany.com account with known credentials and predictable data. Reset the account state before each QA run so the agent always starts from a known baseline.

Keep flow definitions version-controlled

Store flows.json alongside your app code. When a feature changes, update the flow description in the same PR. This keeps tests in sync with the product without maintaining a separate test codebase.

Start with your 5 most critical flows

Don't try to test everything on day one. Pick the 5 flows that, if broken, would generate the most support tickets: signup, login, core workflow, billing, and password reset. Add more flows incrementally.


What's next

QA every deploy, automatically

Parallel browsers. Vision-based navigation. No selectors to maintain. A pass/fail report on every deploy for $0.15.

Get Started Free