bifroest/tests/helpers.py
2026-10-27 08:34:17 +01:00

165 lines
6.3 KiB
Python

"""Hilfen für die Tests: angemeldeter Browser und häufige Abläufe."""
import re
from fastapi.testclient import TestClient
PASSWORD = "demo-passwort"
class Browser:
"""TestClient mit Anmeldung und CSRF-Token."""
def __init__(self, client: TestClient):
self.client = client
self.csrf = ""
def login(self, username: str) -> "Browser":
response = self.client.post("/login", data={"username": username, "password": PASSWORD},
follow_redirects=False)
assert response.status_code == 303, response.text
page = self.client.get("/workspaces/new")
self.csrf = re.search(r'name="_csrf" value="([^"]+)"', page.text).group(1)
return self
def get(self, url: str, **kwargs):
return self.client.get(url, **kwargs)
def post(self, url: str, data: dict | None = None, **kwargs):
return self.client.post(url, data={"_csrf": self.csrf, **(data or {})}, follow_redirects=False, **kwargs)
def create_workspace(b: Browser, team_id: int, name: str = "Projekt X") -> int:
response = b.post("/workspaces", {"team_id": team_id, "name": name})
assert response.status_code == 303, response.text
return int(response.headers["location"].rsplit("/", 1)[1])
def connect_repo(b: Browser, workspace_id: int, url: str, branch: str = "main") -> None:
response = b.post(f"/workspaces/{workspace_id}/sources",
{"name": "Repo", "url": url, "branch": branch, "exposure_confirmed": "1"})
assert response.status_code == 303, response.text
class FakeProvider:
"""Testdouble für den Provider-Adapter.
script: Liste von Runden; eine Runde ist Text (Antwort) oder eine Liste von
Tool-Aufrufen [(name, argumente)]. Ohne script antwortet es mit reply.
"""
calls: list[dict] = []
reply = "Antwort vom Modell."
script: list = []
error: Exception | None = None
# Verbrauch je Aufruf; None = Standardwerte
usage: dict | None = None
def __init__(self, timeout: float = 0):
pass
@classmethod
def reset(cls) -> None:
cls.calls, cls.reply, cls.script, cls.error, cls.usage = [], "Antwort vom Modell.", [], None, None
def respond(self, target, *, instructions, input, on_text, tools=None):
import json
from app.ai.provider import Result
cls = type(self)
cls.calls.append({"target": target, "instructions": instructions, "input": list(input), "tools": tools})
if self.error:
raise self.error
step = cls.script.pop(0) if cls.script else self.reply
if isinstance(step, list) and tools:
round_no = len(cls.calls)
items = [{"type": "function_call", "id": f"fc_{round_no}_{i}", "call_id": f"call_{round_no}_{i}",
"name": name, "arguments": json.dumps(args) if isinstance(args, dict) else args,
"status": "completed"} for i, (name, args) in enumerate(step)]
return Result(text="", items=items, model=target.model, usage=cls.usage or {"total_tokens": 3})
text = step if isinstance(step, str) else "Abschluss ohne Tools."
for word in text.split(" "):
on_text(word + " ")
return Result(text=text, model=target.model, usage=cls.usage or {"total_tokens": 7},
items=[{"type": "reasoning", "id": "rs_1", "encrypted_content": "verschluesselt", "summary": []},
{"type": "message", "role": "assistant", "id": "msg_1", "status": "completed",
"content": [{"type": "output_text", "text": text, "annotations": []}]}])
def list_models(self, target):
return ["gpt-6-luna"]
class McpTestServer:
"""MCP-Server (Streamable HTTP) im selben Prozess, für Tests mit echtem Transport."""
def __init__(self, server):
import socket
import threading
import uvicorn
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
self.port = s.getsockname()[1]
self.server = uvicorn.Server(uvicorn.Config(server.streamable_http_app(), host="127.0.0.1", port=self.port,
log_level="warning", lifespan="on"))
self.thread = threading.Thread(target=self.server.run, daemon=True)
@property
def url(self) -> str:
return f"http://localhost:{self.port}/mcp"
def __enter__(self) -> "McpTestServer":
import time
self.thread.start()
for _ in range(100):
if self.server.started:
return self
time.sleep(0.05)
raise RuntimeError("Testserver startet nicht")
def __exit__(self, *exc) -> None:
self.server.should_exit = True
self.thread.join(5)
# --- Schreibendes Testwerkzeug für die Freigabe-Mechanik (unabhängig von Git) ---
from pydantic import Field # noqa: E402
from app.tools.registry import Args, Proposal, Tool, register # noqa: E402
class DemoWriteArgs(Args):
title: str = Field(min_length=1, max_length=200, description="Titel")
text: str = Field("", description="Was geschrieben wird")
source: str | None = Field(None, description="Quelle")
class DemoTarget:
"""Fremdsystem des Testwerkzeugs: merkt sich Schreibvorgänge; crash simuliert einen Absturz danach."""
written: list[tuple[str, str]] = []
crash = False
@classmethod
def reset(cls) -> None:
cls.written, cls.crash = [], False
def demo_prepare(context, args: DemoWriteArgs) -> Proposal:
source = context.source("git", args.source)
return Proposal(source_id=source.id, arguments=args.model_dump(exclude_none=True) | {"source": source.name},
preview=f"+{args.text}", preconditions={"key": args.title}, summary=args.text)
def demo_execute(context, snapshot) -> tuple[str, dict]:
key = (snapshot["arguments"]["title"], snapshot["arguments"].get("text", ""))
if key not in DemoTarget.written: # idempotent wie echte Schreibtools
DemoTarget.written.append(key)
if DemoTarget.crash:
raise RuntimeError("Verbindung weg")
return f"Geschrieben: {key[0]}", {"written": key[0]}
register(Tool("demo_write", "git", "Testwerkzeug: schreibt in ein Fremdsystem.", DemoWriteArgs, "write_external",
prepare=demo_prepare, execute=demo_execute))