#!/usr/bin/env python3 """Browser check of the AI agent panel (Chromium via Playwright, scripted provider, no network).""" from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import base64 import shutil import json import os from pathlib import Path import subprocess import sys import tempfile from threading import Thread import time from support import ROOT, Site, write_json PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") folder = tempfile.mkdtemp(prefix="raiju-agent-browser-") log_file = Path(folder) / "provider-requests.jsonl" def call(name, arguments, call_id): return {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(arguments)}} def turn(*calls): return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": list(calls)}, "finish_reason": "tool_calls"}], "usage": {"prompt_tokens": 120, "completion_tokens": 30}} def say(text): return {"choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 120, "completion_tokens": 30}} NEWS = {"lang": "en", "title": "Agent news", "date": "2026-10-10", "excerpt": "Short", "contentHtml": "
News body written by the agent.
", "imagePath": "/assets/images/placeholder-image.webp"} def scripted_turn(goal, messages): """The scripted agent: its answer after the last user message of the conversation.""" last_user = max(i for i, m in enumerate(messages) if m["role"] == "user") since = sum(1 for m in messages[last_user:] if m["role"] == "assistant") text = messages[last_user]["content"] if "[flow:slow]" in goal: time.sleep(4) return turn(call("get_site_overview", {}, f"s{since}")) if "[flow:risk]" in goal: risk = [ turn(call("stage_set_field", {"target": "page:en_services__test", "path": "sections.0.content", "value": "See our offer
"}, "r1")), turn(call("validate_changeset", {}, "r2")), turn(call("present_changeset", {"summary": "Linked the offer."}, "r3")), ] return risk[since] if since < len(risk) else say("Done.") if "[flow:upload]" in goal: return turn(call("list_run_media", {}, "u1")) if since == 0 else say("I can see your image. Where should it go?") first = [ turn(call("get_page_capabilities", {"target": "page:en_services__test"}, "c1")), turn(call("stage_set_field", {"target": "page:en_services__test", "path": "sections.0.content", "value": "Updated by the agent
"}, "c2"), call("stage_create_news", NEWS, "c3")), turn(call("validate_changeset", {}, "c4")), turn(call("present_changeset", {"summary": "Updated the services page and drafted a news post.Shorter text
"}, "c6")), turn(call("validate_changeset", {}, "c7")), turn(call("present_changeset", {"summary": "Made the services text shorter."}, "c8")), ] if text.strip().lower() == "yes": return turn(call("present_changeset", {"summary": "Nothing is applied by a chat reply."}, "y1")) if since == 0 else say("Done.") steps = revise if "shorter" in text.lower() else first return steps[since] if since < len(steps) else say("Done.") def dashboard_turn(messages): """The dashboard assistant: cards instead of answers.""" text = messages[-1]["content"].lower() if messages[-1]["role"] == "user" else "" if "dark" in text: return turn(call("change_setting", {"setting": "adminTheme", "value": "dark"}, "d1")) if "language settings" in text: return turn(call("open_section", {"section": "settings", "settingsTab": "language"}, "d2")) if "german" in text: return turn(call("change_setting", {"setting": "adminLanguage", "value": "de"}, "d3")) if "blue" in text: return turn(call("change_setting", {"setting": "accentColor", "value": "#0b5cad"}, "d4")) return say("Plain dashboard answer.") class Provider(BaseHTTPRequestHandler): def log_message(self, *_): pass def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) with log_file.open("a") as handle: handle.write(json.dumps(body) + "\n") messages = body["messages"] goal = next((m["content"] for m in messages if m["role"] == "user"), "") names = {tool["function"]["name"] for tool in body.get("tools", [])} payload = dashboard_turn(messages) if "open_section" in names else scripted_turn(goal, messages) data = json.dumps(payload).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) provider = ThreadingHTTPServer(("127.0.0.1", 0), Provider) Thread(target=provider.serve_forever, daemon=True).start() os.environ["PHP_CLI_SERVER_WORKERS"] = "8" # The model lists of fal.ai and Comet point at a closed local address: the settings page must not reach the internet in this check. os.environ["RAIJU_AGENT_TEST_NET"] = json.dumps({"imageListBases": {"fal": "http://127.0.0.1:9", "comet": "http://127.0.0.1:9"}}) site = Site(folder + "/site") root = site.root write_json(root / "content/settings.json", {"modules": {"ai": True}, "privacy": {"analyticsEnabled": False}}) defaults = json.loads(subprocess.run(["php", "-r", "require 'includes/ai/ai-helper.php'; echo json_encode(raijuAiDefaults());"], cwd=root, text=True, capture_output=True, check=True).stdout) defaults.update(enabled=True, provider="openai-compatible", allowLocalProvider=True, chatModel="gpt-4.1-mini", baseUrl=f"http://127.0.0.1:{provider.server_port}", apiKey="test-key") defaults["providerCredentials"]["openai-compatible"]["baseUrl"] = defaults["baseUrl"] defaults["limits"].update(dailyRequests=1000, dailyTextRequests=1000, monthlyBudgetCents=100000, maxInputTokens=60000) defaults["features"]["backendAssistant"] = True defaults["agent"] = {"enabled": True, "limits": {"runsPerHour": 100, "stepsPerMinute": 600}} write_json(root / "content/ai-settings.json", defaults) (Path(folder) / "upload.png").write_bytes(PNG) available = subprocess.run(["php", "-r", "$_SESSION = ['admin_role' => 'editor']; require 'includes/ai/agent/agent-config.php'; $reason = ''; echo raijuAgentAvailable($reason) ? 'yes' : 'no: ' . $reason;"], cwd=root, text=True, capture_output=True) assert available.stdout == "yes", "the agent should be available in this fixture: " + available.stdout + available.stderr site.start() screenshots = Path(tempfile.mkdtemp(prefix="raiju-browser-evidence-")) try: environment = dict(os.environ, REVIEW_PASSWORD=site.password, REVIEW_URL=f"http://127.0.0.1:{site.port}", REVIEW_SCREENSHOTS=str(screenshots), REVIEW_IMAGE=str(Path(folder) / "upload.png"), REVIEW_SITE=str(root)) script = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "tests" / "agent-check.js") subprocess.run(["node", script], env=environment, cwd=ROOT, check=True, timeout=240) # The AI settings form: legacy installs, effort per purpose, the agent question, own prices, key removal subprocess.run(["node", str(ROOT / "tests" / "ai-settings-check.js")], env=environment, cwd=ROOT, check=True, timeout=240) # Dropdowns of the dashboard (no squeezed rows) and the forms (storage notice, field cards) for name in ("dropdown-check.js", "forms-ui-check.js"): subprocess.run(["node", str(ROOT / "tests" / name)], env=environment, cwd=ROOT, check=True, timeout=240) requests = [json.loads(line) for line in log_file.read_text().splitlines()] uploads = [r for r in requests if "[flow:upload]" in json.dumps(r["messages"])] assert uploads and "[Attached images:" in uploads[0]["messages"][-1]["content"], "the attached image is announced to the agent" assert all("