Files
roboco/tests/unit/gateway/test_request_render_verb.py
T

486 lines
16 KiB
Python
Raw Normal View History

2026-07-16 19:49:26 +02:00
"""ContentActions.request_render — render a video composition to preview
frames so an agent verifies the RENDERED artifact, not just its source.
Guard matrix (flag off / no active task / non-video source / renderer
unconfigured / missing composition_id / bad composition_id-orientation-
frame_count / missing composition dir on disk / role gate), the dev and QA
success paths (frame extraction + marker payload shape), and a
VideoRendererError surfacing as a retryable rejection.
"""
from __future__ import annotations
import io
import tarfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
from roboco.services.video_renderer_client import VideoRendererError
def _make_actions(
*,
task_obj: MagicMock | None,
role: str = "developer",
team: str | None = "ux_ui",
workspace: MagicMock | None = None,
) -> tuple[ContentActions, MagicMock]:
task = AsyncMock()
task.get_active_task_for_agent.return_value = task_obj
task.session = MagicMock()
task.session.flush = AsyncMock()
task.heartbeat = AsyncMock()
agent = MagicMock()
agent.role = role
agent.team = team
task.agent_for = AsyncMock(return_value=agent)
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=workspace or MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps), task
def _task(
*,
project_id: object | None = uuid4(),
source: str = markers.VIDEO_TASK_SOURCE,
branch_name: str | None = "feature/ux_ui/ABCD1234",
draft: dict[str, object] | None = None,
) -> MagicMock:
t = MagicMock()
t.id = uuid4()
t.project_id = project_id
t.status = "in_progress"
t.source = source
t.branch_name = branch_name
t.orchestration_markers = {"video_draft": draft} if draft else None
return t
def _stub_project(
monkeypatch: pytest.MonkeyPatch, *, slug: str = "demo-project"
) -> MagicMock:
project = MagicMock(slug=slug, git_url="https://example.invalid/demo.git")
project_service = MagicMock()
project_service.get = AsyncMock(return_value=project)
monkeypatch.setattr(
"roboco.services.project.get_project_service", lambda _s: project_service
)
return project
def _stub_renderer(
monkeypatch: pytest.MonkeyPatch,
*,
frames_tar_gz: bytes = b"",
duration: float = 2.5,
error: Exception | None = None,
) -> MagicMock:
renderer = MagicMock()
if error is not None:
renderer.render_frames = AsyncMock(side_effect=error)
else:
renderer.render_frames = AsyncMock(return_value=(frames_tar_gz, duration))
monkeypatch.setattr(
"roboco.services.video_renderer_client.get_video_renderer",
lambda: renderer,
)
return renderer
def _make_frames_tar(names: list[str]) -> bytes:
"""An in-memory tar.gz of a few frame files, matching what render_frames
returns on success — the render loop extracts this straight to disk."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for name in names:
data = f"fake-png-bytes-{name}".encode()
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
def _arm(
monkeypatch: pytest.MonkeyPatch, *, renderer_url: str = "http://sidecar:3001"
) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
monkeypatch.setattr(settings, "video_renderer_base_url", renderer_url)
# --------------------------------------------------------------------------- #
# guard matrix
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_flag_off_refuses_before_task_lookup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", False)
actions, task_svc = _make_actions(task_obj=None)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
task_svc.get_active_task_for_agent.assert_not_awaited()
@pytest.mark.asyncio
async def test_no_active_task_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
actions, _task_svc = _make_actions(task_obj=None)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_non_video_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
t = _task(source="chore")
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "video-authoring" in (env.message or "") + (env.remediate or "")
@pytest.mark.asyncio
async def test_renderer_unconfigured_refused(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "video_engine_enabled", True)
monkeypatch.setattr(settings, "video_renderer_base_url", "")
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "ROBOCO_VIDEO_RENDERER_BASE_URL" in (env.remediate or "")
@pytest.mark.asyncio
async def test_missing_composition_id_is_incomplete_input(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
t = _task(draft=None)
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "incomplete_input"
assert "composition_id" in (env.missing or [])
@pytest.mark.asyncio
async def test_bad_composition_id_regex_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(agent_id=uuid4(), composition_id="bad id!")
assert env.error == "invalid_state"
assert "not renderable" in (env.message or "")
@pytest.mark.asyncio
async def test_bad_orientation_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
_arm(monkeypatch)
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
env = await actions.request_render(
agent_id=uuid4(), composition_id="release-v1", orientation="landscape"
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_frame_count_out_of_range_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
t = _task()
actions, _task_svc = _make_actions(task_obj=t)
too_low = await actions.request_render(
agent_id=uuid4(), composition_id="release-v1", frame_count=0
)
too_high = await actions.request_render(
agent_id=uuid4(), composition_id="release-v1", frame_count=33
)
assert too_low.error == "invalid_state"
assert too_high.error == "invalid_state"
@pytest.mark.asyncio
async def test_missing_composition_dir_on_disk_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch)
clone_root = tmp_path / "clone"
clone_root.mkdir(parents=True) # no motion/ tree at all
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "x"
t = _task(draft={"composition_id": "release-v1"})
actions, _task_svc = _make_actions(task_obj=t, workspace=workspace)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "motion/compositions/release-v1" in (env.message or "")
@pytest.mark.asyncio
async def test_other_role_not_authorized(monkeypatch: pytest.MonkeyPatch) -> None:
_arm(monkeypatch)
_stub_project(monkeypatch)
t = _task(draft={"composition_id": "release-v1"})
actions, _task_svc = _make_actions(task_obj=t, role="documenter", team="ux_ui")
env = await actions.request_render(agent_id=uuid4())
assert env.error == "not_authorized"
# --------------------------------------------------------------------------- #
# dev happy path — own working tree
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_dev_happy_path_extracts_frames_and_stamps_marker(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
t = _task(draft={"composition_id": "release-v1"})
actions, task_svc = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
expected_frame_count = 2
expected_duration = 3.2
default_frame_count = 8
tar_bytes = _make_frames_tar(["frame-0.png", "frame-1.png"])
_stub_renderer(monkeypatch, frames_tar_gz=tar_bytes, duration=expected_duration)
env = await actions.request_render(agent_id=uuid4())
assert env.error is None
assert env.status == t.status
assert env.task_id == str(t.id)
assert env.evidence is not None
frames = env.evidence["frames"]
assert len(frames) == expected_frame_count
for p in frames:
assert Path(p).is_file()
assert env.evidence["duration_seconds"] == expected_duration
assert env.evidence["source"] == "workspace"
assert env.evidence["dirty"] is False
assert env.evidence["rendered_by"]
assert "note" in env.evidence
assert "frames[]" in (env.next or "")
payload = markers.get_render_preview(t)
assert payload is not None
assert payload["composition_id"] == "release-v1"
assert payload["orientation"] == "vertical"
assert payload["frame_count"] == default_frame_count
assert payload["duration_seconds"] == expected_duration
assert payload["source"] == "workspace"
assert payload["dirty"] is False
assert payload["frames"] == frames
assert "at" in payload
task_svc.session.flush.assert_awaited()
task_svc.heartbeat.assert_awaited_once()
@pytest.mark.asyncio
async def test_explicit_composition_id_backfills_video_draft(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A dev who never called propose_video must still leave
video_draft.composition_id stamped — the post-completion render loop
keys on it and skips silently when absent (proven live)."""
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
t = _task(draft=None)
actions, _ = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
_stub_renderer(
monkeypatch, frames_tar_gz=_make_frames_tar(["frame-0.png"]), duration=1.0
)
env = await actions.request_render(agent_id=uuid4(), composition_id="release-v1")
assert env.error is None
draft = markers.get_video_draft(t)
assert draft is not None
assert draft["composition_id"] == "release-v1"
@pytest.mark.asyncio
async def test_existing_video_draft_composition_id_not_overwritten(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
for comp in ("release-v1", "release-v2"):
(clone_root / "motion" / "compositions" / comp).mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "deadbeef"
t = _task(draft={"composition_id": "release-v1", "occasion": "r1"})
actions, _ = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
_stub_renderer(
monkeypatch, frames_tar_gz=_make_frames_tar(["frame-0.png"]), duration=1.0
)
env = await actions.request_render(agent_id=uuid4(), composition_id="release-v2")
assert env.error is None
draft = markers.get_video_draft(t)
assert draft is not None
assert draft["composition_id"] == "release-v1"
assert draft["occasion"] == "r1"
# --------------------------------------------------------------------------- #
# QA happy path — read-only branch export, never a working tree
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_qa_happy_path_uses_branch_export(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
scratch = tmp_path / "scratch"
(scratch / "motion" / "compositions" / "release-v1").mkdir(parents=True)
read_clone = tmp_path / "readclone"
read_clone.mkdir()
workspace = MagicMock()
workspace.export_branch_motion = AsyncMock(return_value=scratch)
workspace.ensure_read_clone = AsyncMock(return_value=read_clone)
t = _task(
draft={"composition_id": "release-v1"},
branch_name="feature/ux_ui/ABCD1234",
)
actions, task_svc = _make_actions(
task_obj=t, role="qa", team=None, workspace=workspace
)
tar_bytes = _make_frames_tar(["frame-0.png"])
_stub_renderer(monkeypatch, frames_tar_gz=tar_bytes, duration=1.0)
env = await actions.request_render(agent_id=uuid4())
assert env.error is None
assert env.evidence is not None
assert env.evidence["source"] == "branch"
assert env.evidence["dirty"] is False
assert len(env.evidence["frames"]) == 1
workspace.export_branch_motion.assert_awaited_once()
called_project, called_branch = workspace.export_branch_motion.call_args.args
assert called_project.slug == "demo-project"
assert called_branch == "feature/ux_ui/ABCD1234"
payload = markers.get_render_preview(t)
assert payload is not None
assert payload["source"] == "branch"
task_svc.heartbeat.assert_awaited_once()
@pytest.mark.asyncio
async def test_qa_without_branch_name_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_arm(monkeypatch)
_stub_project(monkeypatch)
t = _task(draft={"composition_id": "release-v1"}, branch_name=None)
actions, _task_svc = _make_actions(task_obj=t, role="qa", team=None)
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# renderer failure
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_video_renderer_error_is_retryable_invalid_state(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_arm(monkeypatch)
monkeypatch.setattr(settings, "workspaces_root", str(tmp_path / "workspaces"))
_stub_project(monkeypatch, slug="demo-project")
clone_root = tmp_path / "clone"
(clone_root / "motion" / "compositions" / "release-v1").mkdir(parents=True)
workspace = MagicMock()
workspace.get_clone_root_path.return_value = clone_root
workspace.get_worktree_path.return_value = clone_root / ".worktrees" / "x"
t = _task(draft={"composition_id": "release-v1"})
actions, _task_svc = _make_actions(
task_obj=t, role="developer", team="ux_ui", workspace=workspace
)
_stub_renderer(monkeypatch, error=VideoRendererError("sidecar unreachable"))
env = await actions.request_render(agent_id=uuid4())
assert env.error == "invalid_state"
assert "retry" in (env.remediate or "").lower()
assert markers.get_render_preview(t) is None