mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[arnav] test(backend): add API integration and unit coverage for outputs app
This commit is contained in:
@@ -0,0 +1,993 @@
|
||||
"""Integration tests for /api/outputs.
|
||||
|
||||
Boots the real FastAPI app through the shared `client` fixture so every
|
||||
route exercises auth middleware + lifespan. Anthropic + the model
|
||||
registry are monkeypatched per-test for the LLM-driven endpoints. The
|
||||
agent_manager-spawning endpoint reuses the existing `stub_agent_loop`
|
||||
fixture so the real `launch_agent` runs (creating an in-memory session)
|
||||
without spawning the SDK.
|
||||
|
||||
Layout mirrors `outputs.py`:
|
||||
- CRUD (/list, /create, /{id}, PUT, DELETE) + legacy migration
|
||||
- Workspace seed / read / write / delete
|
||||
- File serve (workspace + saved output, with token rewrite + _d
|
||||
payload injection)
|
||||
- Backend execute
|
||||
- vibe-code + auto-run (LLM-mocked)
|
||||
- auto-run-agent (stub_agent_loop + AgentConfig spy)
|
||||
- Auth control mirroring test_api_agents.test_protected_route_requires_auth
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_output(client, **overrides) -> dict:
|
||||
"""POST /create with sensible defaults, return the persisted output dict."""
|
||||
payload = {
|
||||
"name": "Test Output",
|
||||
"description": "test",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "integer"}},
|
||||
"required": ["x"],
|
||||
},
|
||||
"files": {"index.html": "<html><head></head><body></body></html>"},
|
||||
}
|
||||
payload.update(overrides)
|
||||
resp = client.post("/api/outputs/create", json=payload)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["output"]
|
||||
|
||||
|
||||
def _make_mock_anthropic(text_response: str) -> MagicMock:
|
||||
"""Build a mock Anthropic client.
|
||||
|
||||
The route does `await client.messages.create(...)` then reads
|
||||
`resp.content[0].text`. Mirror that shape.
|
||||
"""
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.content = [MagicMock(text=text_response)]
|
||||
client_mock = MagicMock()
|
||||
client_mock.messages.create = AsyncMock(return_value=fake_resp)
|
||||
return client_mock
|
||||
|
||||
|
||||
def _patch_anthropic(monkeypatch, text_response: str) -> MagicMock:
|
||||
"""Hook `_get_anthropic_client` in outputs.py to return a mock that
|
||||
responds with `text_response` on every messages.create call.
|
||||
|
||||
Returns the inner mock client so tests can assert call args.
|
||||
"""
|
||||
from backend.apps.outputs import outputs as outputs_mod
|
||||
|
||||
mock_client = _make_mock_anthropic(text_response)
|
||||
monkeypatch.setattr(outputs_mod, "_get_anthropic_client", lambda: mock_client)
|
||||
return mock_client
|
||||
|
||||
|
||||
def _patch_aux_model(monkeypatch, model_id: str = "claude-haiku-fake") -> None:
|
||||
"""Stub `resolve_aux_model` on the registry so vibe-code/auto-run never
|
||||
try to inspect the user's actual model connections."""
|
||||
from backend.apps.agents.providers import registry
|
||||
|
||||
async def _fake(_settings, preferred_tier="haiku"):
|
||||
return (model_id, None)
|
||||
|
||||
monkeypatch.setattr(registry, "resolve_aux_model", _fake)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD + legacy migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_empty_on_fresh_dir(client):
|
||||
resp = client.get("/api/outputs/list")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"outputs": []}
|
||||
|
||||
|
||||
def test_create_get_update_delete_round_trip(client):
|
||||
created = _create_output(client, name="Alpha")
|
||||
output_id = created["id"]
|
||||
assert created["name"] == "Alpha"
|
||||
|
||||
listed = client.get("/api/outputs/list").json()["outputs"]
|
||||
assert any(o["id"] == output_id for o in listed)
|
||||
|
||||
fetched = client.get(f"/api/outputs/{output_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "Alpha"
|
||||
|
||||
upd = client.put(
|
||||
f"/api/outputs/{output_id}",
|
||||
json={
|
||||
"name": "Beta",
|
||||
"auto_run_config": {
|
||||
"enabled": True,
|
||||
"prompt": "fetch X",
|
||||
"mode": "agent",
|
||||
"model": "sonnet",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert upd.status_code == 200
|
||||
body = upd.json()["output"]
|
||||
assert body["name"] == "Beta"
|
||||
assert body["auto_run_config"]["enabled"] is True
|
||||
assert body["auto_run_config"]["prompt"] == "fetch X"
|
||||
|
||||
deleted = client.delete(f"/api/outputs/{output_id}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
from backend.config.paths import OUTPUTS_DIR
|
||||
assert not os.path.exists(os.path.join(OUTPUTS_DIR, f"{output_id}.json"))
|
||||
|
||||
gone = client.get(f"/api/outputs/{output_id}")
|
||||
assert gone.status_code == 404
|
||||
|
||||
|
||||
def test_get_unknown_output_returns_404(client):
|
||||
resp = client.get("/api/outputs/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_create_migrates_legacy_frontend_backend_code(client):
|
||||
resp = client.post(
|
||||
"/api/outputs/create",
|
||||
json={
|
||||
"name": "Legacy",
|
||||
"frontend_code": "<html>old</html>",
|
||||
"backend_code": "result = {}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
output = resp.json()["output"]
|
||||
assert output["files"]["index.html"] == "<html>old</html>"
|
||||
assert output["files"]["backend.py"] == "result = {}"
|
||||
|
||||
|
||||
def test_update_unknown_output_returns_404(client):
|
||||
resp = client.put("/api/outputs/missing", json={"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_unknown_output_returns_404(client):
|
||||
resp = client.delete("/api/outputs/missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workspace_seed_with_explicit_files(client):
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
workspace_id = "ws-explicit"
|
||||
resp = client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={
|
||||
"workspace_id": workspace_id,
|
||||
"files": {
|
||||
"index.html": "<html>seeded</html>",
|
||||
"schema.json": '{"type":"object"}',
|
||||
},
|
||||
"meta": {"name": "X", "description": "y"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id)
|
||||
assert os.path.isfile(os.path.join(folder, "index.html"))
|
||||
assert os.path.isfile(os.path.join(folder, "schema.json"))
|
||||
assert os.path.isfile(os.path.join(folder, "SKILL.md"))
|
||||
|
||||
with open(os.path.join(folder, "meta.json")) as f:
|
||||
meta = json.load(f)
|
||||
assert meta["name"] == "X"
|
||||
|
||||
|
||||
def test_workspace_seed_empty_uses_default_template(client):
|
||||
from backend.apps.outputs.view_builder_templates import VIEW_TEMPLATE_FILES
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
workspace_id = "ws-default"
|
||||
resp = client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={"workspace_id": workspace_id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id)
|
||||
for rel_path in VIEW_TEMPLATE_FILES:
|
||||
assert os.path.isfile(os.path.join(folder, rel_path)), rel_path
|
||||
assert os.path.isfile(os.path.join(folder, "SKILL.md"))
|
||||
|
||||
|
||||
def test_workspace_seed_drops_path_traversal_keys(client):
|
||||
"""Keys that escape the workspace folder via `..` are silently
|
||||
skipped (continue branch in seed_workspace)."""
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
workspace_id = "ws-traversal"
|
||||
resp = client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={
|
||||
"workspace_id": workspace_id,
|
||||
"files": {
|
||||
"ok.txt": "kept",
|
||||
"../escape.html": "should-not-write",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id)
|
||||
assert os.path.isfile(os.path.join(folder, "ok.txt"))
|
||||
parent = os.path.dirname(os.path.normpath(folder))
|
||||
assert not os.path.exists(os.path.join(parent, "escape.html"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workspace_read_returns_files_and_meta(client):
|
||||
workspace_id = "ws-read"
|
||||
client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={
|
||||
"workspace_id": workspace_id,
|
||||
"files": {"index.html": "<html/>"},
|
||||
"meta": {"name": "Read me"},
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/outputs/workspace/{workspace_id}")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["files"]["index.html"] == "<html/>"
|
||||
assert body["meta"] == {"name": "Read me"}
|
||||
assert body["path"].endswith(workspace_id)
|
||||
|
||||
|
||||
def test_workspace_read_returns_none_meta_for_bad_meta_json(client):
|
||||
"""Garbage meta.json triggers the JSONDecodeError swallow branch
|
||||
in `read_workspace`, returning meta=None."""
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
workspace_id = "ws-bad-meta"
|
||||
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with open(os.path.join(folder, "meta.json"), "w") as f:
|
||||
f.write("{not valid json")
|
||||
|
||||
resp = client.get(f"/api/outputs/workspace/{workspace_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["meta"] is None
|
||||
|
||||
|
||||
def test_workspace_read_missing_returns_404(client):
|
||||
resp = client.get("/api/outputs/workspace/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace file write / delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workspace_write_and_delete_file(client):
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
workspace_id = "ws-write"
|
||||
client.post("/api/outputs/workspace/seed", json={"workspace_id": workspace_id})
|
||||
|
||||
write = client.put(
|
||||
f"/api/outputs/workspace/{workspace_id}/file/sub/dir/app.css",
|
||||
json={"content": "body { color: red; }"},
|
||||
)
|
||||
assert write.status_code == 200
|
||||
assert write.json() == {"ok": True}
|
||||
|
||||
full = os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id, "sub", "dir", "app.css")
|
||||
assert os.path.isfile(full)
|
||||
|
||||
delete = client.delete(f"/api/outputs/workspace/{workspace_id}/file/sub/dir/app.css")
|
||||
assert delete.status_code == 200
|
||||
assert not os.path.exists(full)
|
||||
# Empty parent dirs collapse up to the workspace root.
|
||||
assert not os.path.exists(os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id, "sub"))
|
||||
|
||||
|
||||
def test_workspace_write_traversal_rejected(client):
|
||||
workspace_id = "ws-write-trav"
|
||||
client.post("/api/outputs/workspace/seed", json={"workspace_id": workspace_id})
|
||||
|
||||
resp = client.put(
|
||||
f"/api/outputs/workspace/{workspace_id}/file/..%2Fescape.html",
|
||||
json={"content": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_workspace_write_missing_workspace_404(client):
|
||||
resp = client.put(
|
||||
"/api/outputs/workspace/missing/file/foo.txt",
|
||||
json={"content": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_workspace_delete_traversal_rejected(client):
|
||||
workspace_id = "ws-del-trav"
|
||||
client.post("/api/outputs/workspace/seed", json={"workspace_id": workspace_id})
|
||||
|
||||
resp = client.delete(f"/api/outputs/workspace/{workspace_id}/file/..%2Fescape.html")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_workspace_delete_missing_workspace_404(client):
|
||||
resp = client.delete("/api/outputs/workspace/missing/file/foo.txt")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_workspace_delete_missing_file_is_idempotent(client):
|
||||
"""DELETE on an existing workspace but missing file still returns
|
||||
{"ok": True} (no-op branch)."""
|
||||
workspace_id = "ws-del-idem"
|
||||
client.post("/api/outputs/workspace/seed", json={"workspace_id": workspace_id})
|
||||
|
||||
resp = client.delete(f"/api/outputs/workspace/{workspace_id}/file/nope.txt")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serve endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workspace_serve_non_html_is_raw(client):
|
||||
workspace_id = "ws-serve-css"
|
||||
client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={
|
||||
"workspace_id": workspace_id,
|
||||
"files": {"app.css": "body { color: red; }"},
|
||||
},
|
||||
)
|
||||
resp = client.get(f"/api/outputs/workspace/{workspace_id}/serve/app.css")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == "body { color: red; }"
|
||||
assert resp.headers["content-type"].startswith("text/css")
|
||||
|
||||
|
||||
def test_workspace_serve_index_html_injects_default_globals(client, auth_token):
|
||||
workspace_id = "ws-serve-html"
|
||||
html = (
|
||||
'<html><head><title>x</title>'
|
||||
'<link href="styles.css">'
|
||||
'<script src="https://cdn.example/lib.js"></script>'
|
||||
"</head><body></body></html>"
|
||||
)
|
||||
client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={"workspace_id": workspace_id, "files": {"index.html": html}},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/outputs/workspace/{workspace_id}/serve/index.html")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "window.OUTPUT_INPUT = {}" in body
|
||||
assert "window.OUTPUT_BACKEND_RESULT = null" in body
|
||||
# Relative <link> got the token; absolute <script src=https://...> did not.
|
||||
assert f'href="styles.css?token={auth_token}"' in body
|
||||
assert 'src="https://cdn.example/lib.js"' in body
|
||||
|
||||
|
||||
def test_workspace_serve_index_html_decodes_d_param(client):
|
||||
workspace_id = "ws-serve-d"
|
||||
html = "<html><head></head><body></body></html>"
|
||||
client.post(
|
||||
"/api/outputs/workspace/seed",
|
||||
json={"workspace_id": workspace_id, "files": {"index.html": html}},
|
||||
)
|
||||
|
||||
payload = {"i": {"k": 1}, "r": {"v": 2}}
|
||||
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
|
||||
|
||||
resp = client.get(
|
||||
f"/api/outputs/workspace/{workspace_id}/serve/index.html",
|
||||
params={"_d": encoded},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert 'window.OUTPUT_INPUT = {"k": 1}' in body
|
||||
assert 'window.OUTPUT_BACKEND_RESULT = {"v": 2}' in body
|
||||
|
||||
|
||||
def test_workspace_serve_traversal_rejected(client):
|
||||
workspace_id = "ws-serve-trav"
|
||||
client.post("/api/outputs/workspace/seed", json={"workspace_id": workspace_id})
|
||||
|
||||
resp = client.get(f"/api/outputs/workspace/{workspace_id}/serve/..%2Fescape.html")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_workspace_serve_missing_file_returns_404(client):
|
||||
workspace_id = "ws-serve-404"
|
||||
client.post("/api/outputs/workspace/seed", json={"workspace_id": workspace_id})
|
||||
|
||||
resp = client.get(f"/api/outputs/workspace/{workspace_id}/serve/nope.txt")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_output_serve_index_html_injects_globals(client, auth_token):
|
||||
html = (
|
||||
'<html><head><link href="extra.css"></head><body></body></html>'
|
||||
)
|
||||
output = _create_output(client, files={"index.html": html, "extra.css": "x{}"})
|
||||
|
||||
resp = client.get(f"/api/outputs/{output['id']}/serve/index.html")
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "window.OUTPUT_INPUT" in body
|
||||
assert f'href="extra.css?token={auth_token}"' in body
|
||||
|
||||
|
||||
def test_output_serve_non_html_raw(client):
|
||||
output = _create_output(
|
||||
client,
|
||||
files={"index.html": "<html/>", "data.json": '{"k":1}'},
|
||||
)
|
||||
resp = client.get(f"/api/outputs/{output['id']}/serve/data.json")
|
||||
assert resp.status_code == 200
|
||||
assert resp.text == '{"k":1}'
|
||||
|
||||
|
||||
def test_output_serve_missing_file_returns_404(client):
|
||||
output = _create_output(client)
|
||||
resp = client.get(f"/api/outputs/{output['id']}/serve/nope.html")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_output_serve_unknown_output_returns_404(client):
|
||||
resp = client.get("/api/outputs/does-not-exist/serve/index.html")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /execute
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_execute_schema_invalid_input(client):
|
||||
output = _create_output(client)
|
||||
resp = client.post(
|
||||
"/api/outputs/execute",
|
||||
json={"output_id": output["id"], "input_data": {"x": "not-an-int"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["error"] is not None
|
||||
assert "Schema validation failed" in body["error"]
|
||||
assert body["backend_result"] is None
|
||||
|
||||
|
||||
def test_execute_runs_backend_code(client):
|
||||
output = _create_output(
|
||||
client,
|
||||
files={
|
||||
"index.html": "<html/>",
|
||||
"backend.py": "result['double'] = input_data['x'] * 2",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/outputs/execute",
|
||||
json={"output_id": output["id"], "input_data": {"x": 21}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["backend_result"] == {"double": 42}
|
||||
assert body["error"] is None
|
||||
|
||||
|
||||
def test_execute_captures_stdout(client):
|
||||
output = _create_output(
|
||||
client,
|
||||
files={
|
||||
"index.html": "<html/>",
|
||||
"backend.py": "print('hi'); result['ok'] = True",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/outputs/execute",
|
||||
json={"output_id": output["id"], "input_data": {"x": 1}},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "hi" in (body["stdout"] or "")
|
||||
assert body["backend_result"] == {"ok": True}
|
||||
|
||||
|
||||
def test_execute_backend_raise_populates_error(client):
|
||||
output = _create_output(
|
||||
client,
|
||||
files={
|
||||
"index.html": "<html/>",
|
||||
"backend.py": "raise RuntimeError('boom')",
|
||||
},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/outputs/execute",
|
||||
json={"output_id": output["id"], "input_data": {"x": 1}},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["error"] is not None
|
||||
assert body["backend_result"] is None
|
||||
|
||||
|
||||
def test_execute_no_backend_code_returns_none_result(client):
|
||||
output = _create_output(client, files={"index.html": "<html/>"})
|
||||
resp = client.post(
|
||||
"/api/outputs/execute",
|
||||
json={"output_id": output["id"], "input_data": {"x": 1}},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["backend_result"] is None
|
||||
assert body["error"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /vibe-code (Anthropic mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vibe_code_happy_path(client, monkeypatch):
|
||||
response_json = json.dumps({
|
||||
"frontend_code": "<html>new</html>",
|
||||
"backend_code": "result = {'k': 1}",
|
||||
"input_schema": {"type": "object"},
|
||||
"name": "Generated",
|
||||
"description": "by AI",
|
||||
"message": "All set.",
|
||||
})
|
||||
_patch_anthropic(monkeypatch, response_json)
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/vibe-code",
|
||||
json={"prompt": "make me a thing", "current_frontend_code": "<old/>"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["frontend_code"] == "<html>new</html>"
|
||||
assert body["backend_code"] == "result = {'k': 1}"
|
||||
assert body["name"] == "Generated"
|
||||
assert body["message"] == "All set."
|
||||
|
||||
|
||||
def test_vibe_code_strips_markdown_fences(client, monkeypatch):
|
||||
fenced = "```json\n" + json.dumps({
|
||||
"frontend_code": "<html>fenced</html>",
|
||||
"message": "ok",
|
||||
}) + "\n```"
|
||||
_patch_anthropic(monkeypatch, fenced)
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/vibe-code",
|
||||
json={"prompt": "hi"},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["frontend_code"] == "<html>fenced</html>"
|
||||
|
||||
|
||||
def test_vibe_code_json_decode_error_keeps_user_code(client, monkeypatch):
|
||||
_patch_anthropic(monkeypatch, "not json at all")
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/vibe-code",
|
||||
json={
|
||||
"prompt": "x",
|
||||
"current_frontend_code": "<keep/>",
|
||||
"current_backend_code": "result={}",
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "couldn't parse" in body["message"]
|
||||
assert body["frontend_code"] == "<keep/>"
|
||||
assert body["backend_code"] == "result={}"
|
||||
|
||||
|
||||
def test_vibe_code_resolver_raises_value_error_returns_graceful(client, monkeypatch):
|
||||
"""resolve_aux_model raises ValueError when no model is connected.
|
||||
The route catches and returns a graceful error response without
|
||||
touching the Anthropic client."""
|
||||
from backend.apps.agents.providers import registry
|
||||
|
||||
async def _raise(_settings, preferred_tier="haiku"):
|
||||
raise ValueError("no aux model")
|
||||
|
||||
monkeypatch.setattr(registry, "resolve_aux_model", _raise)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/vibe-code",
|
||||
json={"prompt": "x", "current_frontend_code": "<keep/>"},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "no aux model" in body["message"]
|
||||
assert body["frontend_code"] == "<keep/>"
|
||||
|
||||
|
||||
def test_vibe_code_anthropic_import_error(client, monkeypatch):
|
||||
"""Forcing `import anthropic` to fail returns the install hint without
|
||||
touching the model registry at all."""
|
||||
monkeypatch.setitem(sys.modules, "anthropic", None)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/vibe-code",
|
||||
json={"prompt": "x", "current_frontend_code": "<keep/>"},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "anthropic SDK not installed" in body["message"]
|
||||
assert body["frontend_code"] == "<keep/>"
|
||||
|
||||
|
||||
def test_vibe_code_anthropic_call_raises_returns_graceful(client, monkeypatch):
|
||||
"""Generic exception from messages.create is caught and surfaced as
|
||||
`message: "Error: ..."` while preserving the user's existing code."""
|
||||
from backend.apps.outputs import outputs as outputs_mod
|
||||
|
||||
failing_client = MagicMock()
|
||||
failing_client.messages.create = AsyncMock(side_effect=RuntimeError("api down"))
|
||||
monkeypatch.setattr(outputs_mod, "_get_anthropic_client", lambda: failing_client)
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/vibe-code",
|
||||
json={"prompt": "x", "current_frontend_code": "<keep/>"},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "api down" in body["message"]
|
||||
assert body["frontend_code"] == "<keep/>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auto-run (Anthropic mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auto_run_happy_path_with_backend(client, monkeypatch):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q"],
|
||||
}
|
||||
_patch_anthropic(monkeypatch, json.dumps({"q": "hello"}))
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={
|
||||
"prompt": "give me data",
|
||||
"input_schema": schema,
|
||||
"backend_code": "print('side'); result['echoed'] = input_data['q']",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["input_data"] == {"q": "hello"}
|
||||
assert body["backend_result"] == {"echoed": "hello"}
|
||||
assert "side" in (body["stdout"] or "")
|
||||
assert body["error"] is None
|
||||
|
||||
|
||||
def test_auto_run_schema_validation_failure(client, monkeypatch):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "integer"}},
|
||||
"required": ["q"],
|
||||
}
|
||||
_patch_anthropic(monkeypatch, json.dumps({"q": "string-not-int"}))
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": schema},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["input_data"] == {"q": "string-not-int"}
|
||||
assert body["backend_result"] is None
|
||||
assert "Schema validation failed" in body["error"]
|
||||
|
||||
|
||||
def test_auto_run_json_decode_error(client, monkeypatch):
|
||||
_patch_anthropic(monkeypatch, "not json at all")
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": {"type": "object"}},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["error"] == "Failed to parse generated data as JSON"
|
||||
assert body["input_data"] is None
|
||||
|
||||
|
||||
def test_auto_run_resolver_value_error(client, monkeypatch):
|
||||
from backend.apps.agents.providers import registry
|
||||
|
||||
async def _raise(_settings, preferred_tier="haiku"):
|
||||
raise ValueError("no aux model")
|
||||
|
||||
monkeypatch.setattr(registry, "resolve_aux_model", _raise)
|
||||
# Ensure builtin lookup misses so route falls into resolve_aux_model.
|
||||
monkeypatch.setattr(registry, "_find_builtin_model", lambda _name: None)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": {"type": "object"}, "model": "unknown"},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "no aux model" in body["error"]
|
||||
assert body["input_data"] is None
|
||||
|
||||
|
||||
def test_auto_run_known_model_uses_resolve_for_sdk(client, monkeypatch):
|
||||
"""When body.model is a known builtin, the route calls
|
||||
`resolve_model_id_for_sdk` instead of `resolve_aux_model`. Patch
|
||||
both: only the sdk resolver should be hit."""
|
||||
from backend.apps.agents.providers import registry
|
||||
|
||||
monkeypatch.setattr(registry, "_find_builtin_model", lambda _name: {"value": "sonnet"})
|
||||
|
||||
resolved_calls = []
|
||||
|
||||
def _fake_resolve(short_name, _settings):
|
||||
resolved_calls.append(short_name)
|
||||
return "claude-sonnet-real"
|
||||
|
||||
monkeypatch.setattr(registry, "resolve_model_id_for_sdk", _fake_resolve)
|
||||
|
||||
aux_calls = []
|
||||
|
||||
async def _aux(_settings, preferred_tier="haiku"):
|
||||
aux_calls.append(preferred_tier)
|
||||
return ("should-not-be-used", None)
|
||||
|
||||
monkeypatch.setattr(registry, "resolve_aux_model", _aux)
|
||||
|
||||
mock_client = _patch_anthropic(monkeypatch, json.dumps({"x": 1}))
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": {"type": "object"}, "model": "sonnet"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resolved_calls == ["sonnet"]
|
||||
assert aux_calls == []
|
||||
# And the model id flowed into the Anthropic client call.
|
||||
call_kwargs = mock_client.messages.create.await_args.kwargs
|
||||
assert call_kwargs["model"] == "claude-sonnet-real"
|
||||
|
||||
|
||||
def test_auto_run_anthropic_import_error(client, monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "anthropic", None)
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": {"type": "object"}},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "anthropic SDK not installed" in body["error"]
|
||||
assert body["input_data"] is None
|
||||
|
||||
|
||||
def test_auto_run_strips_markdown_fences(client, monkeypatch):
|
||||
"""Markdown-fenced JSON from the model is stripped before parsing
|
||||
(covers the `if raw.startswith('```')` branch in auto_run_output)."""
|
||||
schema = {"type": "object", "properties": {"q": {"type": "string"}}}
|
||||
fenced = "```json\n" + json.dumps({"q": "ok"}) + "\n```"
|
||||
_patch_anthropic(monkeypatch, fenced)
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": schema},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["input_data"] == {"q": "ok"}
|
||||
assert body["error"] is None
|
||||
|
||||
|
||||
def test_auto_run_backend_code_raise_populates_error(client, monkeypatch):
|
||||
"""If `execute_backend_code` raises, the route catches the exception
|
||||
and stuffs it into `error` while keeping the validated input_data."""
|
||||
schema = {"type": "object", "properties": {"q": {"type": "string"}}}
|
||||
_patch_anthropic(monkeypatch, json.dumps({"q": "ok"}))
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={
|
||||
"prompt": "x",
|
||||
"input_schema": schema,
|
||||
"backend_code": "raise RuntimeError('exec boom')",
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["input_data"] == {"q": "ok"}
|
||||
assert body["backend_result"] is None
|
||||
assert "exec boom" in (body["error"] or "")
|
||||
|
||||
|
||||
def test_auto_run_anthropic_call_raises(client, monkeypatch):
|
||||
"""Generic exception from messages.create lands in `error`."""
|
||||
from backend.apps.outputs import outputs as outputs_mod
|
||||
|
||||
failing_client = MagicMock()
|
||||
failing_client.messages.create = AsyncMock(side_effect=RuntimeError("api down"))
|
||||
monkeypatch.setattr(outputs_mod, "_get_anthropic_client", lambda: failing_client)
|
||||
_patch_aux_model(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run",
|
||||
json={"prompt": "x", "input_schema": {"type": "object"}},
|
||||
)
|
||||
body = resp.json()
|
||||
assert "api down" in body["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auto-run-agent (stub_agent_loop + AgentConfig spy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auto_run_agent_assembles_config_and_forwards_prompt(
|
||||
client, stub_agent_loop, monkeypatch,
|
||||
):
|
||||
"""Use a spy around `launch_agent` so we can assert the AgentConfig
|
||||
that the route built (including the merged forced_tools list,
|
||||
which `launch_agent` itself ignores in favor of the mode roster)."""
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
|
||||
output = _create_output(client, name="MyView")
|
||||
|
||||
captured: list = []
|
||||
real_launch = am_mod.agent_manager.launch_agent
|
||||
|
||||
async def _spy(config):
|
||||
captured.append(config)
|
||||
return await real_launch(config)
|
||||
|
||||
monkeypatch.setattr(am_mod.agent_manager, "launch_agent", _spy)
|
||||
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run-agent",
|
||||
json={
|
||||
"prompt": "go gather data",
|
||||
"output_id": output["id"],
|
||||
"model": "sonnet",
|
||||
"forced_tools": ["customMcp__fetchEmail"],
|
||||
"input_schema": {"type": "object", "properties": {"k": {"type": "integer"}}},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
session_id = resp.json()["session_id"]
|
||||
|
||||
assert len(captured) == 1
|
||||
cfg = captured[0]
|
||||
assert cfg.name == f"AutoRun: {output['name']}"
|
||||
assert cfg.mode == "agent"
|
||||
assert cfg.model == "sonnet"
|
||||
assert output["id"] in (cfg.system_prompt or "")
|
||||
assert '"k"' in (cfg.system_prompt or "")
|
||||
# forced_tools not in FULL_TOOLS get appended onto allowed_tools.
|
||||
assert "customMcp__fetchEmail" in cfg.allowed_tools
|
||||
|
||||
session = am_mod.agent_manager.sessions[session_id]
|
||||
assert session.name == f"AutoRun: {output['name']}"
|
||||
assert any(m.role == "user" and m.content == "go gather data" for m in session.messages)
|
||||
|
||||
|
||||
def test_auto_run_agent_uses_output_schema_when_request_omits_one(
|
||||
client, stub_agent_loop, monkeypatch,
|
||||
):
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
|
||||
output = _create_output(
|
||||
client,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"slot": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
|
||||
captured: list = []
|
||||
real_launch = am_mod.agent_manager.launch_agent
|
||||
|
||||
async def _spy(config):
|
||||
captured.append(config)
|
||||
return await real_launch(config)
|
||||
|
||||
monkeypatch.setattr(am_mod.agent_manager, "launch_agent", _spy)
|
||||
|
||||
# Body omits `input_schema` (defaults to {}); route should fall back
|
||||
# to `output.input_schema`.
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run-agent",
|
||||
json={"prompt": "go", "output_id": output["id"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
cfg = captured[0]
|
||||
assert '"slot"' in (cfg.system_prompt or "")
|
||||
|
||||
|
||||
def test_auto_run_agent_unknown_output_returns_404(client, stub_agent_loop):
|
||||
resp = client.post(
|
||||
"/api/outputs/auto-run-agent",
|
||||
json={"prompt": "x", "output_id": "missing"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_cleanup_auto_run_agent_deletes_session(client, stub_agent_loop):
|
||||
output = _create_output(client)
|
||||
launched = client.post(
|
||||
"/api/outputs/auto-run-agent",
|
||||
json={"prompt": "x", "output_id": output["id"]},
|
||||
)
|
||||
session_id = launched.json()["session_id"]
|
||||
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
assert session_id in am_mod.agent_manager.sessions
|
||||
|
||||
resp = client.delete(f"/api/outputs/auto-run-agent/{session_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
assert session_id not in am_mod.agent_manager.sessions
|
||||
|
||||
|
||||
def test_cleanup_auto_run_agent_swallows_delete_errors(client, monkeypatch):
|
||||
"""If `delete_session` raises, the route still returns ok:True
|
||||
after logging a warning. Covers the try/except branch."""
|
||||
from backend.apps.agents import agent_manager as am_mod
|
||||
|
||||
async def _raise(_session_id):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(am_mod.agent_manager, "delete_session", _raise)
|
||||
|
||||
resp = client.delete("/api/outputs/auto-run-agent/some-id")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth control (mirrors test_api_agents.test_protected_route_requires_auth)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_protected_route_requires_auth(app, tmp_data_dirs):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(app) as tc:
|
||||
resp = tc.get("/api/outputs/list")
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Unit tests for the outputs subapp helpers, models, and executor.
|
||||
|
||||
These exercise pure logic and pydantic models without booting FastAPI.
|
||||
The integration surface (routes) lives in `test_api_outputs.py`.
|
||||
|
||||
Covers:
|
||||
- outputs.py helpers: _resolve_model, _validate_against_schema,
|
||||
_build_data_injection, _inject_data_into_html,
|
||||
_inject_token_into_relative_urls (every branch in
|
||||
_ABSOLUTE_URL_PREFIXES + token-already-present + fragment),
|
||||
_decode_data_param, _walk_directory.
|
||||
- On-disk store helpers: _save / _load / load_output / _load_all.
|
||||
- Models: legacy `frontend_code` / `backend_code` / `schema_json`
|
||||
migration into `files`, plus the property accessors.
|
||||
- executor.execute_backend_code: happy path, stdout capture,
|
||||
syntax + runtime errors, timeout (TIMEOUT_SECONDS monkeypatched),
|
||||
non-JSON output JSONDecodeError branch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.apps.outputs import outputs as outputs_mod
|
||||
from backend.apps.outputs.outputs import (
|
||||
_ABSOLUTE_URL_PREFIXES,
|
||||
_build_data_injection,
|
||||
_decode_data_param,
|
||||
_inject_data_into_html,
|
||||
_inject_token_into_relative_urls,
|
||||
_load,
|
||||
_load_all,
|
||||
_resolve_model,
|
||||
_save,
|
||||
_validate_against_schema,
|
||||
_walk_directory,
|
||||
load_output,
|
||||
MODEL_MAP,
|
||||
)
|
||||
from backend.apps.outputs.models import (
|
||||
AutoRunConfig,
|
||||
Output,
|
||||
OutputCreate,
|
||||
OutputUpdate,
|
||||
WorkspaceSeedRequest,
|
||||
)
|
||||
from backend.apps.outputs.executor import (
|
||||
BackendExecResult,
|
||||
execute_backend_code,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_model_known_short_name():
|
||||
assert _resolve_model("sonnet") == MODEL_MAP["sonnet"]
|
||||
assert _resolve_model("opus") == MODEL_MAP["opus"]
|
||||
assert _resolve_model("haiku") == MODEL_MAP["haiku"]
|
||||
|
||||
|
||||
def test_resolve_model_unknown_passthrough():
|
||||
assert _resolve_model("claude-3-5-haiku") == "claude-3-5-haiku"
|
||||
assert _resolve_model("") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_against_schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_against_schema_valid_returns_none():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "integer"}},
|
||||
"required": ["x"],
|
||||
}
|
||||
assert _validate_against_schema({"x": 1}, schema) is None
|
||||
|
||||
|
||||
def test_validate_against_schema_nested_path_in_error():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "object", "properties": {"b": {"type": "integer"}}}
|
||||
},
|
||||
}
|
||||
err = _validate_against_schema({"a": {"b": "not-int"}}, schema)
|
||||
assert err is not None
|
||||
assert "a -> b" in err
|
||||
assert "Schema validation failed" in err
|
||||
|
||||
|
||||
def test_validate_against_schema_root_level_error():
|
||||
"""When absolute_path is empty (root-level type mismatch), the
|
||||
formatter substitutes '(root)'."""
|
||||
schema = {"type": "object"}
|
||||
err = _validate_against_schema(["not-an-object"], schema)
|
||||
assert err is not None
|
||||
assert "(root)" in err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_data_injection / _inject_data_into_html
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_data_injection_includes_globals_and_listener():
|
||||
out = _build_data_injection('{"a":1}', "null")
|
||||
assert "window.OUTPUT_INPUT = " + '{"a":1}' in out
|
||||
assert "window.OUTPUT_BACKEND_RESULT = null" in out
|
||||
assert "addEventListener('message'" in out
|
||||
assert "OUTPUT_DATA" in out
|
||||
|
||||
|
||||
def test_inject_data_into_html_before_head_close():
|
||||
html = "<html><head><title>x</title></head><body></body></html>"
|
||||
out = _inject_data_into_html(html, '{"k":1}', "null")
|
||||
head_idx = out.index("</head>")
|
||||
assert "window.OUTPUT_INPUT" in out[:head_idx]
|
||||
|
||||
|
||||
def test_inject_data_into_html_falls_back_to_body():
|
||||
html = "<html><body><p>x</p></body></html>"
|
||||
out = _inject_data_into_html(html, "{}", "null")
|
||||
body_idx = out.index("<body")
|
||||
assert "window.OUTPUT_INPUT" in out[:body_idx]
|
||||
|
||||
|
||||
def test_inject_data_into_html_falls_back_to_prepend():
|
||||
html = "<p>plain</p>"
|
||||
out = _inject_data_into_html(html, "{}", "null")
|
||||
assert out.startswith("<script>")
|
||||
assert out.endswith(html)
|
||||
|
||||
|
||||
def test_inject_data_into_html_default_args():
|
||||
"""Default JSON values are valid base64-decoded payloads."""
|
||||
out = _inject_data_into_html("<html></html>")
|
||||
assert "window.OUTPUT_INPUT = {}" in out
|
||||
assert "window.OUTPUT_BACKEND_RESULT = null" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _inject_token_into_relative_urls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inject_token_relative_href_and_src():
|
||||
html = '<link href="styles.css"><script src="app.js"></script>'
|
||||
out = _inject_token_into_relative_urls(html, "tok123")
|
||||
assert 'href="styles.css?token=tok123"' in out
|
||||
assert 'src="app.js?token=tok123"' in out
|
||||
|
||||
|
||||
def test_inject_token_appends_with_amp_when_query_present():
|
||||
html = '<script src="app.js?v=1"></script>'
|
||||
out = _inject_token_into_relative_urls(html, "tok")
|
||||
assert 'src="app.js?v=1&token=tok"' in out
|
||||
|
||||
|
||||
def test_inject_token_preserves_fragment():
|
||||
html = '<link href="page.html?v=1#sec">'
|
||||
out = _inject_token_into_relative_urls(html, "tok")
|
||||
assert 'href="page.html?v=1&token=tok#sec"' in out
|
||||
|
||||
|
||||
def test_inject_token_preserves_fragment_no_query():
|
||||
html = '<link href="page.html#sec">'
|
||||
out = _inject_token_into_relative_urls(html, "tok")
|
||||
assert 'href="page.html?token=tok#sec"' in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", _ABSOLUTE_URL_PREFIXES)
|
||||
def test_inject_token_skips_absolute_urls(prefix):
|
||||
"""Every prefix in _ABSOLUTE_URL_PREFIXES must be left untouched."""
|
||||
url = f"{prefix}foo"
|
||||
html = f'<script src="{url}"></script>'
|
||||
out = _inject_token_into_relative_urls(html, "tok")
|
||||
assert f'src="{url}"' in out
|
||||
assert "token=tok" not in out
|
||||
|
||||
|
||||
def test_inject_token_skips_urls_with_existing_token():
|
||||
html = '<link href="styles.css?token=existing">'
|
||||
out = _inject_token_into_relative_urls(html, "newtok")
|
||||
assert 'href="styles.css?token=existing"' in out
|
||||
assert "newtok" not in out
|
||||
|
||||
|
||||
def test_inject_token_noop_when_token_empty():
|
||||
html = '<link href="styles.css">'
|
||||
assert _inject_token_into_relative_urls(html, "") == html
|
||||
|
||||
|
||||
def test_inject_token_handles_single_quotes():
|
||||
html = "<link href='styles.css'>"
|
||||
out = _inject_token_into_relative_urls(html, "tok")
|
||||
assert "styles.css?token=tok" in out
|
||||
|
||||
|
||||
def test_inject_token_requires_whitespace_before_attr():
|
||||
"""The regex matches `\\shref=...`, so attr-like substrings without
|
||||
leading whitespace are NOT touched (defensive: no false positives in
|
||||
user-supplied JSON / inline scripts)."""
|
||||
html = 'data-href="x.css"'
|
||||
out = _inject_token_into_relative_urls(html, "tok")
|
||||
assert out == html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _decode_data_param
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_decode_data_param_round_trip():
|
||||
payload = {"i": {"k": 1}, "r": {"v": 2}}
|
||||
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
|
||||
input_json, result_json = _decode_data_param(encoded)
|
||||
assert json.loads(input_json) == {"k": 1}
|
||||
assert json.loads(result_json) == {"v": 2}
|
||||
|
||||
|
||||
def test_decode_data_param_missing_keys_default():
|
||||
encoded = base64.b64encode(b"{}").decode()
|
||||
input_json, result_json = _decode_data_param(encoded)
|
||||
assert input_json == "{}"
|
||||
assert result_json == "null"
|
||||
|
||||
|
||||
def test_decode_data_param_malformed_returns_defaults():
|
||||
assert _decode_data_param("not-base64!") == ("{}", "null")
|
||||
assert _decode_data_param("") == ("{}", "null")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _walk_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_walk_directory_nonexistent_returns_empty(tmp_path):
|
||||
assert _walk_directory(str(tmp_path / "nope")) == {}
|
||||
|
||||
|
||||
def test_walk_directory_returns_relative_paths(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("A")
|
||||
nested = tmp_path / "sub" / "deep"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "b.txt").write_text("B")
|
||||
|
||||
result = _walk_directory(str(tmp_path))
|
||||
assert result["a.txt"] == "A"
|
||||
assert result[os.path.join("sub", "deep", "b.txt")] == "B"
|
||||
|
||||
|
||||
def test_walk_directory_skips_unreadable(tmp_path):
|
||||
"""Binary files that fail UTF-8 decode are silently skipped — the
|
||||
`except Exception: pass` swallow path."""
|
||||
(tmp_path / "ok.txt").write_text("hello")
|
||||
(tmp_path / "binary.dat").write_bytes(bytes([0xFF, 0xFE, 0x00, 0x80]))
|
||||
|
||||
result = _walk_directory(str(tmp_path))
|
||||
assert result["ok.txt"] == "hello"
|
||||
assert "binary.dat" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_all / _save / _load / load_output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_load_round_trip(tmp_data_dirs):
|
||||
out = Output(name="round-trip", description="d", icon="x")
|
||||
_save(out)
|
||||
loaded = _load(out.id)
|
||||
assert loaded.name == "round-trip"
|
||||
assert loaded.description == "d"
|
||||
assert loaded.id == out.id
|
||||
|
||||
|
||||
def test_load_missing_raises_404(tmp_data_dirs):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_load("does-not-exist")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_load_output_returns_none_for_missing(tmp_data_dirs):
|
||||
assert load_output("does-not-exist") is None
|
||||
|
||||
|
||||
def test_load_output_returns_resolved(tmp_data_dirs):
|
||||
out = Output(name="x")
|
||||
_save(out)
|
||||
fetched = load_output(out.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "x"
|
||||
|
||||
|
||||
def test_load_all_picks_up_saved(tmp_data_dirs):
|
||||
a = Output(name="a")
|
||||
b = Output(name="b")
|
||||
_save(a)
|
||||
_save(b)
|
||||
names = sorted(o.name for o in _load_all())
|
||||
assert names == ["a", "b"]
|
||||
|
||||
|
||||
def test_load_all_empty_when_dir_missing(monkeypatch, tmp_path):
|
||||
"""If DATA_DIR doesn't exist, _load_all returns []."""
|
||||
monkeypatch.setattr(outputs_mod, "DATA_DIR", str(tmp_path / "nope"))
|
||||
assert _load_all() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models — legacy field migration + properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_output_migrates_frontend_and_backend_code():
|
||||
out = Output(
|
||||
name="legacy",
|
||||
frontend_code="<html>x</html>",
|
||||
backend_code="result = {}",
|
||||
)
|
||||
assert out.files == {
|
||||
"index.html": "<html>x</html>",
|
||||
"backend.py": "result = {}",
|
||||
}
|
||||
assert out.frontend_code == "<html>x</html>"
|
||||
assert out.backend_code == "result = {}"
|
||||
|
||||
|
||||
def test_output_already_has_files_drops_legacy_fields():
|
||||
out = Output(
|
||||
name="ok",
|
||||
files={"index.html": "<p>kept</p>"},
|
||||
frontend_code="<should-be-dropped/>",
|
||||
backend_code="dropped",
|
||||
)
|
||||
assert out.files == {"index.html": "<p>kept</p>"}
|
||||
|
||||
|
||||
def test_output_frontend_backend_properties_default_to_empty():
|
||||
out = Output(name="empty")
|
||||
assert out.frontend_code == ""
|
||||
assert out.backend_code is None
|
||||
|
||||
|
||||
def test_output_create_migrates_legacy_fields():
|
||||
create = OutputCreate(
|
||||
name="x",
|
||||
frontend_code="<html/>",
|
||||
backend_code="result = {}",
|
||||
)
|
||||
assert create.files["index.html"] == "<html/>"
|
||||
assert create.files["backend.py"] == "result = {}"
|
||||
|
||||
|
||||
def test_output_update_partial_excludes_none():
|
||||
upd = OutputUpdate(name="renamed")
|
||||
dumped = upd.model_dump(exclude_none=True)
|
||||
assert dumped == {"name": "renamed"}
|
||||
|
||||
|
||||
def test_output_update_migrates_legacy_fields():
|
||||
upd = OutputUpdate(frontend_code="<a/>")
|
||||
dumped = upd.model_dump(exclude_none=True)
|
||||
assert dumped["files"] == {"index.html": "<a/>"}
|
||||
|
||||
|
||||
def test_workspace_seed_migrates_schema_json_field():
|
||||
seed = WorkspaceSeedRequest(
|
||||
workspace_id="ws-1",
|
||||
frontend_code="<html/>",
|
||||
backend_code="result = {}",
|
||||
schema_json='{"type":"object"}',
|
||||
)
|
||||
assert seed.files is not None
|
||||
assert seed.files["index.html"] == "<html/>"
|
||||
assert seed.files["backend.py"] == "result = {}"
|
||||
assert seed.files["schema.json"] == '{"type":"object"}'
|
||||
|
||||
|
||||
def test_workspace_seed_files_already_set_drops_legacy():
|
||||
seed = WorkspaceSeedRequest(
|
||||
workspace_id="ws-2",
|
||||
files={"index.html": "<kept/>"},
|
||||
frontend_code="<dropped/>",
|
||||
)
|
||||
assert seed.files == {"index.html": "<kept/>"}
|
||||
|
||||
|
||||
def test_auto_run_config_defaults():
|
||||
cfg = AutoRunConfig()
|
||||
assert cfg.enabled is False
|
||||
assert cfg.mode == "agent"
|
||||
assert cfg.model == "sonnet"
|
||||
assert cfg.context_paths == []
|
||||
assert cfg.forced_tools == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# executor.execute_backend_code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_execute_backend_happy_path():
|
||||
code = "result['x'] = input_data['y'] + 1"
|
||||
res = await execute_backend_code(code, {"y": 41})
|
||||
assert isinstance(res, BackendExecResult)
|
||||
assert res.result == {"x": 42}
|
||||
assert res.stdout == ""
|
||||
|
||||
|
||||
async def test_execute_backend_captures_stdout():
|
||||
code = "print('hello world'); result['ok'] = True"
|
||||
res = await execute_backend_code(code, {})
|
||||
assert res.result == {"ok": True}
|
||||
assert "hello world" in res.stdout
|
||||
|
||||
|
||||
async def test_execute_backend_syntax_error_raises():
|
||||
"""SyntaxError during compile bubbles up as RuntimeError with the
|
||||
nonzero exit code."""
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
await execute_backend_code("def : bad", {})
|
||||
assert "Backend code error" in str(exc.value)
|
||||
|
||||
|
||||
async def test_execute_backend_runtime_error_raises():
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
await execute_backend_code("raise ValueError('boom')", {})
|
||||
msg = str(exc.value)
|
||||
assert "Backend code error" in msg
|
||||
assert "ValueError" in msg or "boom" in msg
|
||||
|
||||
|
||||
async def test_execute_backend_timeout(monkeypatch):
|
||||
from backend.apps.outputs import executor as exec_mod
|
||||
|
||||
monkeypatch.setattr(exec_mod, "TIMEOUT_SECONDS", 0.1)
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
await execute_backend_code("import time; time.sleep(5)", {})
|
||||
assert "timed out" in str(exc.value)
|
||||
|
||||
|
||||
async def test_execute_backend_non_json_output():
|
||||
"""Corrupt the stdout JSON by writing extra bytes BEFORE the
|
||||
postamble's json.dump runs. Subprocess exits 0 but stdout no
|
||||
longer parses → JSONDecodeError → RuntimeError 'did not produce
|
||||
valid JSON'."""
|
||||
code = (
|
||||
"_orig_stdout.write('not json prefix ')\n"
|
||||
"_orig_stdout.flush()\n"
|
||||
)
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
await execute_backend_code(code, {})
|
||||
assert "did not produce valid JSON" in str(exc.value)
|
||||
Reference in New Issue
Block a user