From 66783244ed430838c05cd5d4e613225fd9d64878 Mon Sep 17 00:00:00 2001 From: haikdc Date: Sun, 14 Jun 2026 02:31:36 -0700 Subject: [PATCH] [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 --- .gitignore | 2 + backend/tests/run.sh | 62 +++++ backend/tests/runner/README.md | 23 +- backend/tests/runner/config.json | 4 +- .../runner/test_runner.egg-info/PKG-INFO | 8 + .../runner/test_runner.egg-info/SOURCES.txt | 0 .../test_runner.egg-info/dependency_links.txt | 1 + .../runner/test_runner.egg-info/requires.txt | 3 + .../runner/test_runner.egg-info/top_level.txt | 1 + .../{ => test_cases}/test_auth_router.py | 0 .../test_browser_agent_loop.py | 0 .../test_browser_batch_replay.py | 0 .../test_browser_command_timeout.py | 0 .../test_browser_fast_path.py | 0 .../{ => test_cases}/test_browser_hints.py | 0 .../test_browser_hotpath_waste.py | 0 .../test_browser_memory_endpoints.py | 0 .../test_browser_meta_playbook.py | 0 .../{ => test_cases}/test_browser_metrics.py | 0 .../test_browser_orchestrator_routing.py | 0 .../{ => test_cases}/test_browser_playbook.py | 0 .../{ => test_cases}/test_browser_save.py | 0 .../test_browser_screenshot_prune.py | 0 .../test_browser_self_audit.py | 0 .../{ => test_cases}/test_browser_skills.py | 0 .../test_browser_stagnation.py | 0 .../test_browser_validator.py | 0 .../{ => test_cases}/test_browser_wait.py | 0 .../test_clean_short_label.py | 0 .../test_disconnect_resilience.py | 0 .../{ => test_cases}/test_disk_caches.py | 0 .../{ => test_cases}/test_disk_resilience.py | 0 .../tests/{ => test_cases}/test_free_trial.py | 0 .../{ => test_cases}/test_ghost_detector.py | 0 .../test_outputs_runtime_cleanup.py | 0 .../{ => test_cases}/test_phase1_stress.py | 0 .../tests/{ => test_cases}/test_service.py | 0 .../test_settings_migration.py | 0 .../test_settings_server_owned.py | 0 .../test_skill_metrics_and_analyzer.py | 0 .../{ => test_cases}/test_v2_invariants.py | 0 .../{ => test_cases}/test_v2_label_logic.py | 0 .../test_web_search_cascade.py | 0 .../{ => test_cases}/test_web_search_ddg.py | 0 .../test_web_search_reliability.py | 0 backend/tests/test_service_legacy.py | 221 ------------------ 46 files changed, 98 insertions(+), 227 deletions(-) create mode 100755 backend/tests/run.sh create mode 100644 backend/tests/runner/test_runner.egg-info/PKG-INFO create mode 100644 backend/tests/runner/test_runner.egg-info/SOURCES.txt create mode 100644 backend/tests/runner/test_runner.egg-info/dependency_links.txt create mode 100644 backend/tests/runner/test_runner.egg-info/requires.txt create mode 100644 backend/tests/runner/test_runner.egg-info/top_level.txt rename backend/tests/{ => test_cases}/test_auth_router.py (100%) rename backend/tests/{ => test_cases}/test_browser_agent_loop.py (100%) rename backend/tests/{ => test_cases}/test_browser_batch_replay.py (100%) rename backend/tests/{ => test_cases}/test_browser_command_timeout.py (100%) rename backend/tests/{ => test_cases}/test_browser_fast_path.py (100%) rename backend/tests/{ => test_cases}/test_browser_hints.py (100%) rename backend/tests/{ => test_cases}/test_browser_hotpath_waste.py (100%) rename backend/tests/{ => test_cases}/test_browser_memory_endpoints.py (100%) rename backend/tests/{ => test_cases}/test_browser_meta_playbook.py (100%) rename backend/tests/{ => test_cases}/test_browser_metrics.py (100%) rename backend/tests/{ => test_cases}/test_browser_orchestrator_routing.py (100%) rename backend/tests/{ => test_cases}/test_browser_playbook.py (100%) rename backend/tests/{ => test_cases}/test_browser_save.py (100%) rename backend/tests/{ => test_cases}/test_browser_screenshot_prune.py (100%) rename backend/tests/{ => test_cases}/test_browser_self_audit.py (100%) rename backend/tests/{ => test_cases}/test_browser_skills.py (100%) rename backend/tests/{ => test_cases}/test_browser_stagnation.py (100%) rename backend/tests/{ => test_cases}/test_browser_validator.py (100%) rename backend/tests/{ => test_cases}/test_browser_wait.py (100%) rename backend/tests/{ => test_cases}/test_clean_short_label.py (100%) rename backend/tests/{ => test_cases}/test_disconnect_resilience.py (100%) rename backend/tests/{ => test_cases}/test_disk_caches.py (100%) rename backend/tests/{ => test_cases}/test_disk_resilience.py (100%) rename backend/tests/{ => test_cases}/test_free_trial.py (100%) rename backend/tests/{ => test_cases}/test_ghost_detector.py (100%) rename backend/tests/{ => test_cases}/test_outputs_runtime_cleanup.py (100%) rename backend/tests/{ => test_cases}/test_phase1_stress.py (100%) rename backend/tests/{ => test_cases}/test_service.py (100%) rename backend/tests/{ => test_cases}/test_settings_migration.py (100%) rename backend/tests/{ => test_cases}/test_settings_server_owned.py (100%) rename backend/tests/{ => test_cases}/test_skill_metrics_and_analyzer.py (100%) rename backend/tests/{ => test_cases}/test_v2_invariants.py (100%) rename backend/tests/{ => test_cases}/test_v2_label_logic.py (100%) rename backend/tests/{ => test_cases}/test_web_search_cascade.py (100%) rename backend/tests/{ => test_cases}/test_web_search_ddg.py (100%) rename backend/tests/{ => test_cases}/test_web_search_reliability.py (100%) delete mode 100644 backend/tests/test_service_legacy.py diff --git a/.gitignore b/.gitignore index 11ccb59c..f30b149e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/tests/run.sh b/backend/tests/run.sh new file mode 100755 index 00000000..6a1f6433 --- /dev/null +++ b/backend/tests/run.sh @@ -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 "$@" diff --git a/backend/tests/runner/README.md b/backend/tests/runner/README.md index 6fc54c72..aadef6d4 100644 --- a/backend/tests/runner/README.md +++ b/backend/tests/runner/README.md @@ -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 diff --git a/backend/tests/runner/config.json b/backend/tests/runner/config.json index bb6b01b3..296a3b3d 100644 --- a/backend/tests/runner/config.json +++ b/backend/tests/runner/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"] } diff --git a/backend/tests/runner/test_runner.egg-info/PKG-INFO b/backend/tests/runner/test_runner.egg-info/PKG-INFO new file mode 100644 index 00000000..697d156d --- /dev/null +++ b/backend/tests/runner/test_runner.egg-info/PKG-INFO @@ -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 diff --git a/backend/tests/runner/test_runner.egg-info/SOURCES.txt b/backend/tests/runner/test_runner.egg-info/SOURCES.txt new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/runner/test_runner.egg-info/dependency_links.txt b/backend/tests/runner/test_runner.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tests/runner/test_runner.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/backend/tests/runner/test_runner.egg-info/requires.txt b/backend/tests/runner/test_runner.egg-info/requires.txt new file mode 100644 index 00000000..67e31013 --- /dev/null +++ b/backend/tests/runner/test_runner.egg-info/requires.txt @@ -0,0 +1,3 @@ +typer +rich +textual diff --git a/backend/tests/runner/test_runner.egg-info/top_level.txt b/backend/tests/runner/test_runner.egg-info/top_level.txt new file mode 100644 index 00000000..2b29f276 --- /dev/null +++ b/backend/tests/runner/test_runner.egg-info/top_level.txt @@ -0,0 +1 @@ +tests diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_cases/test_auth_router.py similarity index 100% rename from backend/tests/test_auth_router.py rename to backend/tests/test_cases/test_auth_router.py diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_cases/test_browser_agent_loop.py similarity index 100% rename from backend/tests/test_browser_agent_loop.py rename to backend/tests/test_cases/test_browser_agent_loop.py diff --git a/backend/tests/test_browser_batch_replay.py b/backend/tests/test_cases/test_browser_batch_replay.py similarity index 100% rename from backend/tests/test_browser_batch_replay.py rename to backend/tests/test_cases/test_browser_batch_replay.py diff --git a/backend/tests/test_browser_command_timeout.py b/backend/tests/test_cases/test_browser_command_timeout.py similarity index 100% rename from backend/tests/test_browser_command_timeout.py rename to backend/tests/test_cases/test_browser_command_timeout.py diff --git a/backend/tests/test_browser_fast_path.py b/backend/tests/test_cases/test_browser_fast_path.py similarity index 100% rename from backend/tests/test_browser_fast_path.py rename to backend/tests/test_cases/test_browser_fast_path.py diff --git a/backend/tests/test_browser_hints.py b/backend/tests/test_cases/test_browser_hints.py similarity index 100% rename from backend/tests/test_browser_hints.py rename to backend/tests/test_cases/test_browser_hints.py diff --git a/backend/tests/test_browser_hotpath_waste.py b/backend/tests/test_cases/test_browser_hotpath_waste.py similarity index 100% rename from backend/tests/test_browser_hotpath_waste.py rename to backend/tests/test_cases/test_browser_hotpath_waste.py diff --git a/backend/tests/test_browser_memory_endpoints.py b/backend/tests/test_cases/test_browser_memory_endpoints.py similarity index 100% rename from backend/tests/test_browser_memory_endpoints.py rename to backend/tests/test_cases/test_browser_memory_endpoints.py diff --git a/backend/tests/test_browser_meta_playbook.py b/backend/tests/test_cases/test_browser_meta_playbook.py similarity index 100% rename from backend/tests/test_browser_meta_playbook.py rename to backend/tests/test_cases/test_browser_meta_playbook.py diff --git a/backend/tests/test_browser_metrics.py b/backend/tests/test_cases/test_browser_metrics.py similarity index 100% rename from backend/tests/test_browser_metrics.py rename to backend/tests/test_cases/test_browser_metrics.py diff --git a/backend/tests/test_browser_orchestrator_routing.py b/backend/tests/test_cases/test_browser_orchestrator_routing.py similarity index 100% rename from backend/tests/test_browser_orchestrator_routing.py rename to backend/tests/test_cases/test_browser_orchestrator_routing.py diff --git a/backend/tests/test_browser_playbook.py b/backend/tests/test_cases/test_browser_playbook.py similarity index 100% rename from backend/tests/test_browser_playbook.py rename to backend/tests/test_cases/test_browser_playbook.py diff --git a/backend/tests/test_browser_save.py b/backend/tests/test_cases/test_browser_save.py similarity index 100% rename from backend/tests/test_browser_save.py rename to backend/tests/test_cases/test_browser_save.py diff --git a/backend/tests/test_browser_screenshot_prune.py b/backend/tests/test_cases/test_browser_screenshot_prune.py similarity index 100% rename from backend/tests/test_browser_screenshot_prune.py rename to backend/tests/test_cases/test_browser_screenshot_prune.py diff --git a/backend/tests/test_browser_self_audit.py b/backend/tests/test_cases/test_browser_self_audit.py similarity index 100% rename from backend/tests/test_browser_self_audit.py rename to backend/tests/test_cases/test_browser_self_audit.py diff --git a/backend/tests/test_browser_skills.py b/backend/tests/test_cases/test_browser_skills.py similarity index 100% rename from backend/tests/test_browser_skills.py rename to backend/tests/test_cases/test_browser_skills.py diff --git a/backend/tests/test_browser_stagnation.py b/backend/tests/test_cases/test_browser_stagnation.py similarity index 100% rename from backend/tests/test_browser_stagnation.py rename to backend/tests/test_cases/test_browser_stagnation.py diff --git a/backend/tests/test_browser_validator.py b/backend/tests/test_cases/test_browser_validator.py similarity index 100% rename from backend/tests/test_browser_validator.py rename to backend/tests/test_cases/test_browser_validator.py diff --git a/backend/tests/test_browser_wait.py b/backend/tests/test_cases/test_browser_wait.py similarity index 100% rename from backend/tests/test_browser_wait.py rename to backend/tests/test_cases/test_browser_wait.py diff --git a/backend/tests/test_clean_short_label.py b/backend/tests/test_cases/test_clean_short_label.py similarity index 100% rename from backend/tests/test_clean_short_label.py rename to backend/tests/test_cases/test_clean_short_label.py diff --git a/backend/tests/test_disconnect_resilience.py b/backend/tests/test_cases/test_disconnect_resilience.py similarity index 100% rename from backend/tests/test_disconnect_resilience.py rename to backend/tests/test_cases/test_disconnect_resilience.py diff --git a/backend/tests/test_disk_caches.py b/backend/tests/test_cases/test_disk_caches.py similarity index 100% rename from backend/tests/test_disk_caches.py rename to backend/tests/test_cases/test_disk_caches.py diff --git a/backend/tests/test_disk_resilience.py b/backend/tests/test_cases/test_disk_resilience.py similarity index 100% rename from backend/tests/test_disk_resilience.py rename to backend/tests/test_cases/test_disk_resilience.py diff --git a/backend/tests/test_free_trial.py b/backend/tests/test_cases/test_free_trial.py similarity index 100% rename from backend/tests/test_free_trial.py rename to backend/tests/test_cases/test_free_trial.py diff --git a/backend/tests/test_ghost_detector.py b/backend/tests/test_cases/test_ghost_detector.py similarity index 100% rename from backend/tests/test_ghost_detector.py rename to backend/tests/test_cases/test_ghost_detector.py diff --git a/backend/tests/test_outputs_runtime_cleanup.py b/backend/tests/test_cases/test_outputs_runtime_cleanup.py similarity index 100% rename from backend/tests/test_outputs_runtime_cleanup.py rename to backend/tests/test_cases/test_outputs_runtime_cleanup.py diff --git a/backend/tests/test_phase1_stress.py b/backend/tests/test_cases/test_phase1_stress.py similarity index 100% rename from backend/tests/test_phase1_stress.py rename to backend/tests/test_cases/test_phase1_stress.py diff --git a/backend/tests/test_service.py b/backend/tests/test_cases/test_service.py similarity index 100% rename from backend/tests/test_service.py rename to backend/tests/test_cases/test_service.py diff --git a/backend/tests/test_settings_migration.py b/backend/tests/test_cases/test_settings_migration.py similarity index 100% rename from backend/tests/test_settings_migration.py rename to backend/tests/test_cases/test_settings_migration.py diff --git a/backend/tests/test_settings_server_owned.py b/backend/tests/test_cases/test_settings_server_owned.py similarity index 100% rename from backend/tests/test_settings_server_owned.py rename to backend/tests/test_cases/test_settings_server_owned.py diff --git a/backend/tests/test_skill_metrics_and_analyzer.py b/backend/tests/test_cases/test_skill_metrics_and_analyzer.py similarity index 100% rename from backend/tests/test_skill_metrics_and_analyzer.py rename to backend/tests/test_cases/test_skill_metrics_and_analyzer.py diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_cases/test_v2_invariants.py similarity index 100% rename from backend/tests/test_v2_invariants.py rename to backend/tests/test_cases/test_v2_invariants.py diff --git a/backend/tests/test_v2_label_logic.py b/backend/tests/test_cases/test_v2_label_logic.py similarity index 100% rename from backend/tests/test_v2_label_logic.py rename to backend/tests/test_cases/test_v2_label_logic.py diff --git a/backend/tests/test_web_search_cascade.py b/backend/tests/test_cases/test_web_search_cascade.py similarity index 100% rename from backend/tests/test_web_search_cascade.py rename to backend/tests/test_cases/test_web_search_cascade.py diff --git a/backend/tests/test_web_search_ddg.py b/backend/tests/test_cases/test_web_search_ddg.py similarity index 100% rename from backend/tests/test_web_search_ddg.py rename to backend/tests/test_cases/test_web_search_ddg.py diff --git a/backend/tests/test_web_search_reliability.py b/backend/tests/test_cases/test_web_search_reliability.py similarity index 100% rename from backend/tests/test_web_search_reliability.py rename to backend/tests/test_cases/test_web_search_reliability.py diff --git a/backend/tests/test_service_legacy.py b/backend/tests/test_service_legacy.py deleted file mode 100644 index e5f7be22..00000000 --- a/backend/tests/test_service_legacy.py +++ /dev/null @@ -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