fix(webui): render local CLI image artifacts
This commit is contained in:
@@ -480,6 +480,38 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
||||
assert second["stream_id"] == "sid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch, tmp_path) -> None:
|
||||
bus = MagicMock()
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||
media = tmp_path / "media"
|
||||
|
||||
def fake_media_dir(channel: str | None = None):
|
||||
path = media / channel if channel else media
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
workspace_path=workspace,
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send_delta("chat-1", "
|
||||
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
|
||||
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
|
||||
|
||||
assert mock_ws.send.await_count == 3
|
||||
final = json.loads(mock_ws.send.call_args_list[2][0][0])
|
||||
assert final["event"] == "stream_end"
|
||||
assert final["text"].startswith("
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@@ -44,6 +44,7 @@ def _ch(
|
||||
bus: Any,
|
||||
*,
|
||||
session_manager: SessionManager | None = None,
|
||||
workspace_path: Path | None = None,
|
||||
port: int,
|
||||
) -> WebSocketChannel:
|
||||
return WebSocketChannel(
|
||||
@@ -57,6 +58,7 @@ def _ch(
|
||||
},
|
||||
bus,
|
||||
session_manager=session_manager,
|
||||
workspace_path=workspace_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +69,15 @@ def bus() -> MagicMock:
|
||||
return b
|
||||
|
||||
|
||||
def _fake_media_dir(root: Path):
|
||||
def inner(channel: str | None = None) -> Path:
|
||||
path = root / channel if channel else root
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
async def _http_get(
|
||||
url: str, headers: dict[str, str] | None = None
|
||||
) -> httpx.Response:
|
||||
@@ -123,6 +134,45 @@ def test_sign_media_path_round_trips_via_hmac(
|
||||
assert _b64url_decode(payload).decode() == "a.png"
|
||||
|
||||
|
||||
def test_local_markdown_image_is_staged_and_rewritten(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "demo_arch.png").write_bytes(_PNG_BYTES)
|
||||
media = tmp_path / "media"
|
||||
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||
|
||||
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||
rewritten = channel._rewrite_local_markdown_images(
|
||||
"The result:\n"
|
||||
)
|
||||
|
||||
assert ".iterdir())
|
||||
assert len(staged) == 1
|
||||
assert staged[0].read_bytes() == _PNG_BYTES
|
||||
|
||||
|
||||
def test_local_markdown_image_rejects_workspace_escape(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside.png"
|
||||
outside.write_bytes(_PNG_BYTES)
|
||||
media = tmp_path / "media"
|
||||
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||
text = ""
|
||||
|
||||
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||
assert channel._rewrite_local_markdown_images(text) == text
|
||||
|
||||
assert not (media / "websocket").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/media/<sig>/<payload>: the serving handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -372,6 +372,33 @@ def test_run_installed_cli_uses_argv_without_shell(
|
||||
assert "['--json', 'project', 'list']" in result
|
||||
|
||||
|
||||
def test_run_reports_created_artifacts(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli_apps.service.shutil.which",
|
||||
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
||||
)
|
||||
|
||||
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
cwd = Path(str(kwargs["cwd"]))
|
||||
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
|
||||
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
|
||||
|
||||
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
|
||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
||||
|
||||
result = manager.run("gimp", ["render"])
|
||||
|
||||
assert "Artifacts created or updated:" in result
|
||||
assert "diagram.png (previewable image" in result
|
||||
assert "" in result
|
||||
|
||||
|
||||
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
|
||||
manager = _manager(tmp_path)
|
||||
_seed_catalog(manager)
|
||||
|
||||
@@ -42,6 +42,19 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_replay_augments_assistant_text() -> None:
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
[
|
||||
{"event": "user", "chat_id": "t-img", "text": "draw"},
|
||||
{"event": "delta", "chat_id": "t-img", "text": ""},
|
||||
{"event": "stream_end", "chat_id": "t-img"},
|
||||
],
|
||||
augment_assistant_text=lambda text: text.replace("diagram.png", "/api/media/sig/payload"),
|
||||
)
|
||||
|
||||
assert msgs[1]["content"] == ""
|
||||
|
||||
|
||||
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-file"
|
||||
|
||||
Reference in New Issue
Block a user