13 / 108 · 03 Agentic Testing Architectures · Mapping ReAct to CLI Browser Commands← prev⊞ allnext →☰ Read as one page
2.4Implementing the Agent in Python
import subprocess
import json
from pathlib import Path
class CliReActAgent:
"""A ReAct test agent driving the browser via playwright-cli."""
def __init__(self, llm, max_steps=15):
self.llm = llm
self.max_steps = max_steps
self.history = []
def cli(self, command: str) -> str:
"""Execute a playwright-cli command and return its (minimal) output."""
result = subprocess.run(
f"playwright-cli {command}",
shell=True, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
return f"ERROR: {result.stderr.strip()}"
return result.stdout.strip()
def observe(self) -> dict:
"""Gather current page state. Snapshot goes to disk; read it back."""
snapshot_path = self.cli("snapshot") # returns a file path
snapshot = ""
if snapshot_path and not snapshot_path.startswith("ERROR"):
snapshot = Path(snapshot_path.split()[-1]).read_text()[:2000]
return {
"url": self.cli('eval "location.href"'),
"snapshot": snapshot,
"errors": self.cli('eval "document.querySelectorAll(\'.error\').length"'),
}
def run(self, objective: str) -> dict:
"""Execute a test using the ReAct loop."""
for step in range(self.max_steps):
# OBSERVE
state = self.observe()
# THINK + decide
prompt = f"""
You are a QA test agent. Your objective: {objective}
Current state:
- URL: {state['url']}
- Page snapshot (YAML, first 2000 chars): {state['snapshot']}
- Error count: {state['errors']}
History (last 5 actions):
{json.dumps(self.history[-5:], indent=2)}
Respond with exactly ONE action:
CLI <command> — execute a playwright-cli command (use refs from the snapshot)
PASS <reason> — test passed, explain why
FAIL <reason> — test failed, explain why
"""
response = self.llm.generate(prompt).strip()
self.history.append({"step": step, "state_url": state["url"], "action": response})
# ACT or EVALUATE
if response.startswith("CLI "):
command = response[4:]
output = self.cli(command)
self.history[-1]["output"] = output
elif response.startswith("PASS "):
return {"status": "pass", "reason": response[5:], "steps": step + 1}
elif response.startswith("FAIL "):
return {"status": "fail", "reason": response[5:], "steps": step + 1}
return {"status": "timeout", "reason": f"Exceeded {self.max_steps} steps", "steps": self.max_steps}
Note what the disk-first design does to this loop: the full snapshot only enters the LLM prompt when observe() deliberately includes it. A production version would be smarter still — passing the snapshot path and letting the agent read it only when its reasoning requires.