165 lines
6.3 KiB
Python
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))
|