mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 10:52:15 +00:00
- Rewrite .github/workflows/desktop.yml: 2-job pipeline (validate + build-and-release) with rolling desktop-latest pre-release on push to main and stable desktop-v* releases - Add UpdateChecker component: checks for updates on startup + every 30 min, background download with progress bar, one-click relaunch - Configure Tauri updater: endpoints pointing to desktop-latest release, pubkey placeholder - Add tauri-plugin-process for relaunch support (Cargo.toml, lib.rs, package.json) - Add macOS Entitlements.plist for notarization (network + file access, no sandbox) - Add scripts/bump-desktop-version.sh for atomic version bumps across 3 config files - Add desktop/README.md with dev setup, auto-update architecture, signing docs - Update .gitignore for desktop/node_modules, dist, target - Configure macOS minimumSystemVersion, Windows timestampUrl - Include all Phase 14-21 work: agent hardening, RBAC, taint tracking, workflows, skills, knowledge graph, sessions, A2A, MCP templates, WASM sandbox, TUI dashboard, production tools, CLI expansion, API expansion, learning productionization, Tauri desktop app, and 10 new channels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
7.8 KiB
Python
216 lines
7.8 KiB
Python
"""Tests for the WebSocket streaming endpoint."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
fastapi = pytest.importorskip("fastapi")
|
||
from fastapi import FastAPI # noqa: E402
|
||
from starlette.testclient import TestClient # noqa: E402
|
||
|
||
from openjarvis.server.api_routes import include_all_routes # noqa: E402
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_app(engine=None):
|
||
"""Create a minimal FastAPI app with mock engine wired up."""
|
||
app = FastAPI()
|
||
if engine is None:
|
||
engine = _make_streaming_engine()
|
||
app.state.engine = engine
|
||
app.state.model = "test-model"
|
||
include_all_routes(app)
|
||
return app
|
||
|
||
|
||
def _make_streaming_engine(tokens=None):
|
||
"""Return a mock engine whose ``stream()`` yields tokens."""
|
||
if tokens is None:
|
||
tokens = ["Hello", " ", "world"]
|
||
engine = MagicMock()
|
||
engine.engine_id = "mock"
|
||
|
||
async def mock_stream(messages, *, model="test-model", **kwargs):
|
||
for tok in tokens:
|
||
yield tok
|
||
|
||
engine.stream = mock_stream
|
||
engine.generate.return_value = {
|
||
"content": "Hello world",
|
||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||
"model": "test-model",
|
||
"finish_reason": "stop",
|
||
}
|
||
return engine
|
||
|
||
|
||
def _make_generate_only_engine(content="Hello world"):
|
||
"""Return a mock engine that only has ``generate()`` (no ``stream()``)."""
|
||
engine = MagicMock(spec=["generate", "engine_id"])
|
||
engine.engine_id = "mock-nostream"
|
||
engine.generate.return_value = {
|
||
"content": content,
|
||
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
|
||
"model": "test-model",
|
||
"finish_reason": "stop",
|
||
}
|
||
return engine
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestWebSocketStreaming:
|
||
"""Tests for WS /v1/chat/stream endpoint."""
|
||
|
||
def test_basic_streaming_exchange(self):
|
||
"""A valid message should produce chunk messages followed by a done."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
chunks = []
|
||
done = None
|
||
# Read all responses until we get 'done'
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "chunk":
|
||
chunks.append(data["content"])
|
||
elif data["type"] == "done":
|
||
done = data
|
||
break
|
||
else:
|
||
break
|
||
assert len(chunks) == 3
|
||
assert chunks == ["Hello", " ", "world"]
|
||
assert done is not None
|
||
assert done["content"] == "Hello world"
|
||
|
||
def test_missing_message_field(self):
|
||
"""Sending JSON without a 'message' field should return an error."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"text": "Hi"}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Missing" in data["detail"]
|
||
|
||
def test_invalid_json(self):
|
||
"""Sending non-JSON text should return an error."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text("not json at all")
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Invalid JSON" in data["detail"]
|
||
|
||
def test_empty_message_field(self):
|
||
"""An empty string for 'message' should return an error."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": ""}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Missing" in data["detail"]
|
||
|
||
def test_generate_fallback_when_no_stream(self):
|
||
"""When the engine has no stream(), generate() result is sent as one chunk."""
|
||
engine = _make_generate_only_engine("Fallback response")
|
||
app = _make_app(engine=engine)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
chunks = []
|
||
done = None
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "chunk":
|
||
chunks.append(data["content"])
|
||
elif data["type"] == "done":
|
||
done = data
|
||
break
|
||
else:
|
||
break
|
||
assert len(chunks) == 1
|
||
assert chunks[0] == "Fallback response"
|
||
assert done is not None
|
||
assert done["content"] == "Fallback response"
|
||
|
||
def test_custom_model_in_request(self):
|
||
"""The model field from the request should be forwarded to the engine."""
|
||
tokens = ["OK"]
|
||
engine = _make_streaming_engine(tokens=tokens)
|
||
app = _make_app(engine=engine)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi", "model": "custom-model"}))
|
||
# Consume until done
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "done":
|
||
break
|
||
# The mock stream function was called — we can't easily inspect
|
||
# async-generator call args, but the exchange completed without error
|
||
assert data["content"] == "OK"
|
||
|
||
def test_engine_error_returns_error_message(self):
|
||
"""If the engine raises, the endpoint should send an error frame."""
|
||
engine = MagicMock()
|
||
|
||
async def bad_stream(messages, *, model="test-model", **kwargs):
|
||
raise RuntimeError("Engine exploded")
|
||
# Make it look like an async generator to the endpoint
|
||
yield # pragma: no cover – unreachable, but needed for async gen syntax
|
||
|
||
engine.stream = bad_stream
|
||
app = _make_app(engine=engine)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "boom"}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "Engine exploded" in data["detail"]
|
||
|
||
def test_multiple_messages_on_same_connection(self):
|
||
"""The WebSocket should support multiple request/response cycles."""
|
||
app = _make_app()
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
for _ in range(3):
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
# Drain until done
|
||
while True:
|
||
data = ws.receive_json()
|
||
if data["type"] == "done":
|
||
assert data["content"] == "Hello world"
|
||
break
|
||
|
||
def test_no_engine_configured(self):
|
||
"""If app.state has no engine, an error should be returned."""
|
||
app = FastAPI()
|
||
app.state.model = "test-model"
|
||
# Intentionally do NOT set app.state.engine
|
||
include_all_routes(app)
|
||
client = TestClient(app)
|
||
with client.websocket_connect("/v1/chat/stream") as ws:
|
||
ws.send_text(json.dumps({"message": "Hi"}))
|
||
data = ws.receive_json()
|
||
assert data["type"] == "error"
|
||
assert "engine" in data["detail"].lower()
|
||
|
||
|
||
__all__ = [
|
||
"TestWebSocketStreaming",
|
||
]
|