mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[haik]: restructure backend test layout: move 37 test files from backend/tests/ into backend/tests/test_cases/, add backend/tests/run.sh one-command launcher that auto-provisions both runner and test venvs with stamp-based caching, update runner config.json to point test_paths at test_cases/ and fix venv_python path, revise runner README with run.sh usage and clearer setup instructions, and gitignore the .runner-venv directory
This commit is contained in:
@@ -40,6 +40,8 @@ backend/uv-bin/
|
||||
backend/apps/outputs/webapp_template_cache/
|
||||
# Backend Python venv (created by run.ps1 / backend/run.sh)
|
||||
backend/.venv/
|
||||
# Test runner UI venv (created by backend/tests/run.sh)
|
||||
backend/tests/.runner-venv/
|
||||
.account-factory
|
||||
openswarm-cloud
|
||||
.openswarm-cloud
|
||||
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-command launcher for the interactive test runner.
|
||||
#
|
||||
# bash backend/tests/run.sh # discover -> picker -> run
|
||||
# bash backend/tests/run.sh -k ingest # forward any flags/paths to the runner
|
||||
#
|
||||
# Two-venv design (see tests/runner/README.md):
|
||||
# - runner venv (.runner-venv): UI libs only (typer/rich/textual), never pytest
|
||||
# - test venv (backend/.venv): pytest + project deps; config.json -> venv_python
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # .../backend/tests
|
||||
BACKEND_DIR="$(dirname "$HERE")" # .../backend
|
||||
|
||||
TEST_VENV="$BACKEND_DIR/.venv"
|
||||
RUNNER_VENV="$HERE/.runner-venv"
|
||||
|
||||
# Prefer 3.13 (matches the production/runtime interpreter) but fall back to python3.
|
||||
PY="${PYTHON:-}"
|
||||
if [[ -z "$PY" ]]; then
|
||||
if command -v python3.13 >/dev/null 2>&1; then PY=python3.13; else PY=python3; fi
|
||||
fi
|
||||
|
||||
# 1) Test venv: project + pytest deps. This is what config.json -> venv_python
|
||||
# ("./.venv/bin/python", resolved against repo_root=backend) points at.
|
||||
# Installs are skipped when the stamp is newer than both requirements files,
|
||||
# so repeat runs are fast and need no network.
|
||||
TEST_STAMP="$TEST_VENV/.run-sh-deps.stamp"
|
||||
if [[ ! -x "$TEST_VENV/bin/python" ]]; then
|
||||
echo "Creating test venv at $TEST_VENV ..."
|
||||
"$PY" -m venv "$TEST_VENV"
|
||||
fi
|
||||
if [[ ! -f "$TEST_STAMP" \
|
||||
|| "$BACKEND_DIR/requirements.txt" -nt "$TEST_STAMP" \
|
||||
|| "$BACKEND_DIR/requirements-dev.txt" -nt "$TEST_STAMP" ]]; then
|
||||
echo "Installing test dependencies ..."
|
||||
"$TEST_VENV/bin/pip" install -q \
|
||||
-r "$BACKEND_DIR/requirements.txt" \
|
||||
-r "$BACKEND_DIR/requirements-dev.txt"
|
||||
touch "$TEST_STAMP"
|
||||
fi
|
||||
|
||||
# 2) Runner venv: the UI libraries only (never pytest). These mirror the
|
||||
# dependencies declared in tests/runner/pyproject.toml; the package itself is
|
||||
# imported straight from the source tree (cwd=backend below), so it does not
|
||||
# need to be pip-installed. A stamp keeps repeat runs install-free.
|
||||
RUNNER_DEPS=(typer rich textual)
|
||||
RUNNER_STAMP="$RUNNER_VENV/.run-sh-deps.stamp"
|
||||
if [[ ! -x "$RUNNER_VENV/bin/python" ]]; then
|
||||
echo "Creating runner venv at $RUNNER_VENV ..."
|
||||
"$PY" -m venv "$RUNNER_VENV"
|
||||
fi
|
||||
if [[ ! -f "$RUNNER_STAMP" ]]; then
|
||||
echo "Installing runner UI dependencies ..."
|
||||
"$RUNNER_VENV/bin/pip" install -q "${RUNNER_DEPS[@]}"
|
||||
touch "$RUNNER_STAMP"
|
||||
fi
|
||||
|
||||
# 3) Launch the parent from backend/ so `from tests.runner...` resolves, and
|
||||
# forward any args/flags straight through to the Typer CLI.
|
||||
cd "$BACKEND_DIR"
|
||||
exec "$RUNNER_VENV/bin/python" -m tests.runner "$@"
|
||||
@@ -31,17 +31,32 @@ parent (rich) <—— JSON events over a pipe FD —— worker (pytest, test v
|
||||
|
||||
## Setup
|
||||
|
||||
1. Runner venv (the UI):
|
||||
The easiest path is the one-command launcher, which provisions both venvs and
|
||||
runs the picker:
|
||||
|
||||
```
|
||||
bash backend/tests/run.sh # discover -> picker -> run
|
||||
bash backend/tests/run.sh -k ingest # any flags/paths forward to the runner
|
||||
```
|
||||
|
||||
To wire it up by hand instead:
|
||||
|
||||
1. Runner venv (the UI) — install the libs declared in
|
||||
[`pyproject.toml`](./pyproject.toml). The package is imported from the source
|
||||
tree (parent runs with `cwd=backend`), so only its deps need installing:
|
||||
|
||||
```
|
||||
python -m venv .runner-venv
|
||||
.runner-venv/bin/pip install -e tests/runner
|
||||
python -m venv backend/tests/.runner-venv
|
||||
backend/tests/.runner-venv/bin/pip install typer rich textual
|
||||
```
|
||||
|
||||
2. Test venv (where tests actually run) — your project's existing venv with
|
||||
`pytest`, `pytest-asyncio`, and `coverage` installed.
|
||||
`pytest`, `pytest-asyncio`, and `coverage` installed (`backend/.venv` via
|
||||
`backend/requirements-dev.txt`).
|
||||
|
||||
3. Point the runner at the test venv in [`config.json`](./config.json).
|
||||
`venv_python` is resolved relative to `repo_root` (which is `backend/`), so
|
||||
the value is `.venv/bin/python`, not `backend/.venv/bin/python`.
|
||||
|
||||
## config.json
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repo_root": "../..",
|
||||
"test_paths": ["tests/unit", "tests/api"],
|
||||
"venv_python": "backend/.venv/bin/python",
|
||||
"test_paths": ["tests/test_cases"],
|
||||
"venv_python": ".venv/bin/python",
|
||||
"coverage_source": ["backend"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: test-runner
|
||||
Version: 0.1.0
|
||||
Summary: Interactive subprocess test runner with a live Rich dashboard.
|
||||
Requires-Python: >=3.9
|
||||
Requires-Dist: typer
|
||||
Requires-Dist: rich
|
||||
Requires-Dist: textual
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
typer
|
||||
rich
|
||||
textual
|
||||
@@ -0,0 +1 @@
|
||||
tests
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Service-sync compatibility tests.
|
||||
|
||||
Verifies the legacy compatibility helpers on backend/apps/service/client.py
|
||||
(record, submit_event, submit_session_close, etc.) still produce the right
|
||||
opaque payload through the unified sync() entry point. Forward-looking
|
||||
contract tests live in test_service.py; this file covers the legacy shim
|
||||
surface so it can be deprecated cleanly later.
|
||||
|
||||
Run with:
|
||||
cd backend && python -m pytest tests/test_service_legacy.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Sandbox the data dir before any module import touches settings on disk.
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
os.environ.setdefault("OPENSWARM_DATA_DIR", tmpdir)
|
||||
|
||||
# Captured syncs from this test run.
|
||||
captured_syncs: list[dict] = []
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def resetcaptured_syncs():
|
||||
captured_syncs.clear()
|
||||
yield
|
||||
captured_syncs.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def install_sync_sink():
|
||||
"""Install a service-sync sink and decode the opaque payload back into
|
||||
a structured shape for assertions. The sink translates the new shape
|
||||
{client_state, d, t} into a legacy-compatible {kind, distinct_id, props}
|
||||
bag so existing tests can keep their assertions terse."""
|
||||
import backend.apps.service.client as svc_client
|
||||
|
||||
def sink_fn(label: str, body: dict):
|
||||
cs = body.get("client_state") or {}
|
||||
payload = body.get("d") or body.get("payload") or {}
|
||||
|
||||
# Infer a synthetic kind from payload shape, same dispatch logic
|
||||
# as the cloud uses in production.
|
||||
if "status" in payload and "messages" in payload:
|
||||
status = payload.get("status", "unknown")
|
||||
kind = f"session.{status}" if status != "unknown" else "session.completed"
|
||||
props = dict(payload)
|
||||
elif "identity" in payload:
|
||||
kind = "state.update"
|
||||
props = dict(payload)
|
||||
elif "diagnostic" in payload:
|
||||
kind = "diagnostic.fired"
|
||||
props = dict(payload)
|
||||
elif "s" in payload and "a" in payload:
|
||||
kind = f"{payload['s']}.{payload['a']}"
|
||||
props = dict(payload.get("p") or {})
|
||||
elif "surface" in payload:
|
||||
surface = payload.get("surface", "")
|
||||
action = payload.get("action", "fired")
|
||||
kind = f"{surface}.{action}"
|
||||
props = dict(payload.get("props") or {})
|
||||
else:
|
||||
kind = "state.update"
|
||||
props = dict(payload)
|
||||
|
||||
if payload.get("session_id"):
|
||||
props["session_id"] = payload["session_id"]
|
||||
if payload.get("dashboard_id"):
|
||||
props["dashboard_id"] = payload["dashboard_id"]
|
||||
props.setdefault("os", cs.get("os", ""))
|
||||
props.setdefault("platform", cs.get("os", ""))
|
||||
|
||||
captured_syncs.append({
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"distinct_id": cs.get("install_id", ""),
|
||||
"properties": props,
|
||||
})
|
||||
|
||||
old_sink = svc_client.P_TEST_SINK # p-private-ignore: P_TEST_SINK
|
||||
old_iid = svc_client.P_INSTALL_ID # p-private-ignore: P_INSTALL_ID
|
||||
svc_client.set_test_sink(sink_fn)
|
||||
svc_client.P_INSTALL_ID = "test-install-id"
|
||||
yield
|
||||
svc_client.set_test_sink(old_sink)
|
||||
svc_client.P_INSTALL_ID = old_iid
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_settings(tmp_path):
|
||||
"""Sandbox settings so tests don't read or write the real config."""
|
||||
settings_file = tmp_path / "settings.json"
|
||||
settings_file.write_text(json.dumps({
|
||||
"service_diagnostics_mode": "standard",
|
||||
"installation_id": "test-install-id",
|
||||
}))
|
||||
|
||||
import backend.apps.settings.store as settings_mod
|
||||
old_file = settings_mod.SETTINGS_FILE
|
||||
settings_mod.SETTINGS_FILE = str(settings_file)
|
||||
yield
|
||||
settings_mod.SETTINGS_FILE = old_file
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_sessions_dir(tmp_path):
|
||||
"""Use temp dir for session persistence."""
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
|
||||
import backend.config.paths as paths_mod
|
||||
old_dir = paths_mod.SESSIONS_DIR
|
||||
paths_mod.SESSIONS_DIR = str(sessions_dir)
|
||||
yield str(sessions_dir)
|
||||
paths_mod.SESSIONS_DIR = old_dir
|
||||
|
||||
|
||||
def syncs(kind: str | None = None) -> list[dict]:
|
||||
"""Return captured syncs, optionally filtered by inferred kind."""
|
||||
if kind:
|
||||
return [s for s in captured_syncs if s["kind"] == kind]
|
||||
return list(captured_syncs)
|
||||
|
||||
|
||||
def last_sync(kind: str) -> dict:
|
||||
"""Return the last captured sync of a given inferred kind."""
|
||||
matching = syncs(kind)
|
||||
assert matching, f"No {kind} syncs captured. Got: {[s['kind'] for s in captured_syncs]}"
|
||||
return matching[-1]
|
||||
|
||||
|
||||
# Import application modules (after fixtures are wired).
|
||||
from backend.apps.service.client import record
|
||||
from backend.apps.agents.core.models import AgentConfig, Message
|
||||
from backend.apps.agents.agent_manager import AgentManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
"""Fresh AgentManager per test."""
|
||||
return AgentManager()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. record(), legacy shim correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecordBasics:
|
||||
def test_record_sends_payload(self):
|
||||
record("test.report", {"key": "value"})
|
||||
s = last_sync("test.report")
|
||||
assert s["properties"]["key"] == "value"
|
||||
assert s["distinct_id"] == "test-install-id"
|
||||
|
||||
def test_record_adds_os_and_platform(self):
|
||||
record("test.report", {})
|
||||
s = last_sync("test.report")
|
||||
assert "os" in s["properties"]
|
||||
assert "platform" in s["properties"]
|
||||
|
||||
def test_record_includes_session_id(self):
|
||||
record("test.report", {}, session_id="sess123")
|
||||
s = last_sync("test.report")
|
||||
assert s["properties"]["session_id"] == "sess123"
|
||||
|
||||
def test_record_includes_dashboard_id(self):
|
||||
record("test.report", {}, dashboard_id="dash456")
|
||||
s = last_sync("test.report")
|
||||
assert s["properties"]["dashboard_id"] == "dash456"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Multi-message session, close fires exactly once
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiMessageSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_completes_only_on_close(self, manager):
|
||||
"""Verify a completed-session sync does NOT fire mid-loop. It should
|
||||
only fire on close_session() or persist_all_sessions()."""
|
||||
config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent")
|
||||
session = await manager.launch_agent(config)
|
||||
|
||||
for i in range(3):
|
||||
session.messages.append(Message(role="user", content=f"msg {i}"))
|
||||
session.messages.append(Message(role="assistant", content=f"reply {i}"))
|
||||
|
||||
completed = syncs("session.completed")
|
||||
assert len(completed) == 0, f"session-completed fired {len(completed)} times before close"
|
||||
|
||||
session.status = "completed"
|
||||
await manager.close_session(session.id)
|
||||
|
||||
completed = syncs("session.completed")
|
||||
assert len(completed) == 1, f"expected 1 completed sync, got {len(completed)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Token + cost capture on close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTokenTracking:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tokens_and_cost_in_session_close(self, manager):
|
||||
config = AgentConfig(name="Token Test", model="opus", mode="agent")
|
||||
session = await manager.launch_agent(config)
|
||||
|
||||
session.tokens = {"input": 50000, "output": 15000}
|
||||
session.cost_usd = 0.25
|
||||
session.status = "completed"
|
||||
|
||||
await manager.close_session(session.id)
|
||||
|
||||
s = last_sync("session.completed")
|
||||
assert s["properties"]["tokens"]["input"] == 50000
|
||||
assert s["properties"]["tokens"]["output"] == 15000
|
||||
assert s["properties"]["cost_usd"] == 0.25
|
||||
Reference in New Issue
Block a user