[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:
haikdc
2026-06-14 02:31:36 -07:00
parent 4652bb05b4
commit 66783244ed
46 changed files with 98 additions and 227 deletions
+2
View File
@@ -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
+62
View File
@@ -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 "$@"
+19 -4
View File
@@ -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
+2 -2
View File
@@ -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,3 @@
typer
rich
textual
@@ -0,0 +1 @@
tests
-221
View File
@@ -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