fix(tests): auto-derive unique port+state-dir per worktree (fixes parallel pytest)

This commit is contained in:
Hermes Agent
2026-04-14 19:04:48 +00:00
parent 924c833878
commit c3251ea97d
43 changed files with 130 additions and 60 deletions

42
tests/_pytest_port.py Normal file
View File

@@ -0,0 +1,42 @@
"""
Shared test server constants for use in individual test files.
Instead of hardcoding ``BASE = "http://127.0.0.1:8788"`` in every test file,
import from here so the port and state dir are always consistent with
what conftest.py computed for this worktree.
Usage::
from tests._pytest_port import BASE
conftest.py publishes ``HERMES_WEBUI_TEST_PORT`` and
``HERMES_WEBUI_TEST_STATE_DIR`` to ``os.environ`` at module level
(before any test file is imported), so this module always reads the
correct values. The auto-derivation fallback matches conftest's logic
exactly, so standalone imports also work correctly.
"""
import hashlib
import os
import pathlib
def _auto_test_port(repo_root: pathlib.Path) -> int:
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root: pathlib.Path) -> str:
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
_TESTS_DIR = pathlib.Path(__file__).parent.resolve()
_REPO_ROOT = _TESTS_DIR.parent.resolve()
_HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME',
str(pathlib.Path.home() / '.hermes')))
TEST_PORT = int(os.environ.get('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(_REPO_ROOT))))
BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.environ.get(
'HERMES_WEBUI_TEST_STATE_DIR',
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
))

View File

@@ -31,14 +31,37 @@ HOME = pathlib.Path.home()
HERMES_HOME = pathlib.Path(os.getenv('HERMES_HOME', str(HOME / '.hermes')))
# ── Test server config ────────────────────────────────────────────────────
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT', '8788'))
# Port and state dir auto-derive from the repo path when no env var is set,
# giving every worktree its own isolated port (8800-8899) and state directory.
# Override with HERMES_WEBUI_TEST_PORT / HERMES_WEBUI_TEST_STATE_DIR to pin.
def _auto_test_port(repo_root) -> int:
"""Map repo path to a unique port in 20000-29999 (10k range = near-zero collisions).
Far from system port ranges and Linux ephemeral ports (32768+).
Override with HERMES_WEBUI_TEST_PORT to use a specific port."""
import hashlib
h = int(hashlib.md5(str(repo_root).encode()).hexdigest(), 16)
return 20000 + (h % 10000)
def _auto_state_dir_name(repo_root) -> str:
import hashlib
h = hashlib.md5(str(repo_root).encode()).hexdigest()[:8]
return f"webui-test-{h}"
TEST_PORT = int(os.getenv('HERMES_WEBUI_TEST_PORT',
str(_auto_test_port(REPO_ROOT))))
TEST_BASE = f"http://127.0.0.1:{TEST_PORT}"
TEST_STATE_DIR = pathlib.Path(os.getenv(
'HERMES_WEBUI_TEST_STATE_DIR',
str(HERMES_HOME / 'webui-mvp-test')
str(HERMES_HOME / _auto_state_dir_name(REPO_ROOT))
))
TEST_WORKSPACE = TEST_STATE_DIR / 'test-workspace'
# Publish at module level so _pytest_port.py (imported at collection time)
# and any test file using os.environ sees the right values immediately.
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
# ── Server script: always relative to repo root ───────────────────────────
SERVER_SCRIPT = REPO_ROOT / 'server.py'
if not SERVER_SCRIPT.exists():
@@ -245,7 +268,10 @@ def test_server():
# as the server. Other test files (test_auth_sessions.py) may override
# HERMES_WEBUI_STATE_DIR for their own purposes, but HERMES_WEBUI_TEST_STATE_DIR
# is reserved for this mapping and is never overridden by individual test files.
os.environ.setdefault('HERMES_WEBUI_TEST_STATE_DIR', str(TEST_STATE_DIR))
# Export both port and state-dir as env vars so individual test files
# can read them without importing conftest (avoids circular imports).
os.environ.setdefault('HERMES_WEBUI_TEST_PORT', str(TEST_PORT))
# os.environ already set at module level above; no-op here.
env = os.environ.copy()
env.update({

View File

@@ -41,7 +41,7 @@ pytestmark = pytest.mark.skipif(
reason="tools.approval not available in this environment"
)
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -18,7 +18,7 @@ import urllib.error
import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
@@ -49,11 +49,9 @@ def _get_test_state_dir():
set (e.g. when running this file standalone), fall back to the conftest
formula: HERMES_HOME/webui-mvp-test.
"""
explicit = os.getenv('HERMES_WEBUI_TEST_STATE_DIR')
if explicit:
return pathlib.Path(explicit)
hermes_home = pathlib.Path(os.getenv('HERMES_HOME', str(pathlib.Path.home() / '.hermes')))
return hermes_home / 'webui-mvp-test' # matches conftest.py TEST_STATE_DIR formula
# Use _pytest_port which applies the same auto-derivation as conftest.py
from tests._pytest_port import TEST_STATE_DIR as _ptsd
return _ptsd
def _get_state_db_path():

View File

@@ -34,7 +34,7 @@ STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text()
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def _get(path):
@@ -261,7 +261,7 @@ class TestBubbleLayoutI18N(unittest.TestCase):
)
# ── Integration tests (require live server on port 8788) ─────────────────
# ── Integration tests (require live server on test server port) ─────────────────
class TestBubbleLayoutSettingsAPI(unittest.TestCase):
@@ -272,7 +272,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
try:
d, status = _get("/api/settings")
except OSError:
self.skipTest("Server not running on port 8788")
self.skipTest("Server not running on test server port")
self.assertEqual(status, 200)
self.assertIn(
"bubble_layout",
@@ -289,7 +289,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
try:
_, status = _post("/api/settings", {"bubble_layout": True})
except OSError:
self.skipTest("Server not running on port 8788")
self.skipTest("Server not running on test server port")
self.assertEqual(status, 200)
d, _ = _get("/api/settings")
self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST")
@@ -302,7 +302,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
_post("/api/settings", {"bubble_layout": True})
_post("/api/settings", {"bubble_layout": False})
except OSError:
self.skipTest("Server not running on port 8788")
self.skipTest("Server not running on test server port")
d, _ = _get("/api/settings")
self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST")
@@ -311,7 +311,7 @@ class TestBubbleLayoutSettingsAPI(unittest.TestCase):
try:
_post("/api/settings", {"bubble_layout": "1"})
except OSError:
self.skipTest("Server not running on port 8788")
self.skipTest("Server not running on test server port")
d, _ = _get("/api/settings")
self.assertIsInstance(
d["bubble_layout"],

View File

@@ -3,7 +3,7 @@ import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -12,6 +12,7 @@ Covers:
from __future__ import annotations
import json
import os
import pathlib
import urllib.error
import urllib.request
@@ -187,7 +188,7 @@ class TestApplyOnboardingSetupGuard:
# Integration tests — require the live test server on port 8788
# ---------------------------------------------------------------------------
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def _http_get(path):
@@ -213,7 +214,7 @@ def _server_hermes_home() -> pathlib.Path:
env_path = data.get("system", {}).get("env_path", "")
if env_path:
return pathlib.Path(env_path).parent
return pathlib.Path.home() / ".hermes" / "webui-mvp-test"
return pathlib.Path(os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(pathlib.Path.home() / ".hermes" / "webui-mvp-test")))
def _server_reachable() -> bool:

View File

@@ -13,7 +13,7 @@ import urllib.request
import pytest
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
# Check if pyyaml is available — onboarding setup tests need it on the server
try:

View File

@@ -24,7 +24,7 @@ import urllib.request
import pytest
REPO = pathlib.Path(__file__).parent.parent
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
# ---------------------------------------------------------------------------
# Unit tests — directly test the IP-resolution + guard logic in routes.py
@@ -128,14 +128,14 @@ class TestOnboardingIPLogic:
# ---------------------------------------------------------------------------
# Integration tests — hit the live test server at port 8788
# Integration tests — hit the live test server at test server port
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestOnboardingSetupEndpoint:
"""
Integration tests for /api/onboarding/setup.
These require the test server running on port 8788.
These require the test server running on test server port.
"""
def _post(self, path: str, data: dict, headers: dict | None = None) -> tuple[int, dict]:
@@ -157,7 +157,7 @@ class TestOnboardingSetupEndpoint:
Requests from 127.0.0.1 (which is what the test server sees) should
pass the IP check. We confirm no 403 is returned.
"""
# The test server runs on 127.0.0.1:8788 so client_address[0] is 127.0.0.1.
# The test server runs on 127.0.0.1:{TEST_PORT} so client_address[0] is 127.0.0.1.
# A valid setup payload with a mock provider should not be rejected for IP reasons.
# We patch apply_onboarding_setup to avoid actually writing any config.
import unittest.mock

View File

@@ -16,7 +16,7 @@ import re
import urllib.request
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def _read(rel_path: str) -> str:

View File

@@ -5,6 +5,7 @@ These tests exist specifically to prevent those bugs from silently returning.
Each test is tagged with the sprint/commit where the bug was found and fixed.
"""
import json
import os
import pathlib
import time
import urllib.error
@@ -12,7 +13,7 @@ import urllib.request
import urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
@@ -104,7 +105,7 @@ def test_session_with_tool_calls_in_json_loads_ok(cleanup_test_sessions):
sid = make_session(cleanup_test_sessions)
# Manually inject tool_calls into the session's JSON file
sessions_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test" / "sessions"
sessions_dir = pathlib.Path(os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(pathlib.Path.home() / ".hermes" / "webui-mvp-test"))) / "sessions"
session_file = sessions_dir / f"{sid}.json"
if session_file.exists():
d = json.loads(session_file.read_text())

View File

@@ -33,7 +33,7 @@ def _server_is_up(port: int = 8788) -> bool:
# The skipif is evaluated lazily via the fixture, not at collection time.
_needs_server = pytest.mark.usefixtures("test_server")
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
# Sample credentials that should be masked in every API response
_FAKE_GITHUB_PAT = "ghp_TestFakeCredential1234567890ab"

View File

@@ -11,7 +11,7 @@ import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
_needs_server = pytest.mark.usefixtures("test_server")
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
_FULL_SECRET = "sk-" + ("B" * 24)

View File

@@ -1,7 +1,7 @@
"""
Sprint 1 test suite for the Hermes Web UI.
Tests use the ISOLATED test server running on http://127.0.0.1:8788.
Tests use the ISOLATED test server. Port is auto-derived per worktree (see conftest.py).
Production server (port 8787) and your real conversations are never touched.
Start the server before running:
<repo>/start.sh
@@ -27,7 +27,7 @@ import pathlib
# Allow importing server modules directly for unit tests
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
from tests._pytest_port import BASE
# ──────────────────────────────────────────────

View File

@@ -4,7 +4,7 @@ Sprint 10 Tests: server.py split, cancel endpoint, cron history, tool card polis
import json, pathlib, urllib.error, urllib.request, urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -4,7 +4,7 @@ Sprint 11 Tests: multi-provider model support, streaming smoothness, routes extr
import json, pathlib, urllib.error, urllib.request, urllib.parse
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -3,7 +3,7 @@ Sprint 12 Tests: settings panel, session pinning, session import, SSE reconnect.
"""
import json, pathlib, urllib.error, urllib.request, urllib.parse
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 13 Tests: cron recent endpoint, session duplicate, background alerts.
"""
import json, pathlib, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 14 Tests: file rename, folder create, session archive, session tags, merm
"""
import json, os, pathlib, shutil, tempfile, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 15 Tests: session projects (CRUD, move, backward compat).
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -7,7 +7,7 @@ import pathlib
import re
import urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
REPO_ROOT = pathlib.Path(__file__).parent.parent

View File

@@ -3,7 +3,7 @@ Sprint 17 Tests: send_key setting, commands.js static file, workspace subdir lis
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -3,7 +3,7 @@ Sprint 19 Tests: auth/login, security headers, request size limit.
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path, headers=None):

View File

@@ -1,7 +1,7 @@
"""Sprint 2 tests: image preview, file types, markdown. Uses cleanup_test_sessions fixture."""
import io, json, uuid, urllib.request, urllib.error, pathlib
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -10,7 +10,7 @@ import urllib.request
import json
import pathlib
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get_text(path):

View File

@@ -5,7 +5,7 @@ icon-only circle design.
import re
import urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get_text(path):

View File

@@ -4,7 +4,7 @@ subagent card names, skill picker in cron, skill linked files.
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -4,7 +4,7 @@ custom theme names accepted.
"""
import json, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -7,7 +7,7 @@ import json
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -14,7 +14,7 @@ import urllib.request
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -27,7 +27,7 @@ import urllib.request
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_STATE_DIR
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path, headers=None):

View File

@@ -1,7 +1,7 @@
"""Sprint 3 tests: cron API, skills API, memory API, input validation."""
import json, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -19,7 +19,7 @@ import urllib.parse
import pytest
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):

View File

@@ -68,7 +68,7 @@ class TestWriteEndpointToConfig:
# ── 6-7: API integration tests ────────────────────────────────────────────────
_TEST_BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE as _TEST_BASE
def _post(path, body=None):

View File

@@ -1,6 +1,7 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
import subprocess
import os
from api.startup import auto_install_agent_deps
class TestAutoInstallAgentDeps:

View File

@@ -22,7 +22,7 @@ import unittest.mock
import pytest
REPO = pathlib.Path(__file__).parent.parent
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
# ── Helpers ──────────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
"""Sprint 4 tests: relocation, session rename, search, file ops, validation."""
import json, pathlib, uuid, urllib.request, urllib.error
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -14,7 +14,7 @@ import urllib.request
import os
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
REPO = pathlib.Path(__file__).parent.parent
# Use HERMES_WEBUI_TEST_STATE_DIR if available (set by conftest for the test process),
# falling back to the conventional webui-mvp-test path.

View File

@@ -1,7 +1,8 @@
"""Sprint 5 tests: workspace CRUD, file save, session index, JS serving."""
import json, pathlib, uuid, urllib.request, urllib.error
import os
BASE = "http://127.0.0.1:8788" # test server (isolated from production)
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
@@ -132,7 +133,7 @@ def test_file_save_path_traversal_blocked(cleanup_test_sessions):
def test_session_index_created_after_save(cleanup_test_sessions):
# Index is created in the TEST state dir, not the production dir
test_state_dir = pathlib.Path.home() / ".hermes" / "webui-mvp-test"
test_state_dir = pathlib.Path(os.environ.get("HERMES_WEBUI_TEST_STATE_DIR", str(pathlib.Path.home() / ".hermes" / "webui-mvp-test")))
index_path = test_state_dir / "sessions" / "_index.json"
make_session_tracked(cleanup_test_sessions)
# Index may not exist yet if cleanup already wiped it -- just check the endpoint works

View File

@@ -2,7 +2,7 @@
import json, uuid, pathlib, urllib.request, urllib.error
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
BASE = "http://127.0.0.1:8788" # isolated test server
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -3,7 +3,7 @@ Sprint 7 Tests: Cron CRUD, Skill CRUD, Memory Write, Session Content Search, Hea
"""
import json, pathlib, urllib.error, urllib.parse, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -3,7 +3,7 @@ Sprint 8 Tests: Edit/regenerate, clear conversation, truncate, reconnect banner
"""
import json, pathlib, urllib.error, urllib.parse, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:

View File

@@ -4,7 +4,7 @@ Run: python -m pytest tests/test_sprint9.py -v
"""
import json, pathlib, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
from tests._pytest_port import BASE
def get_text(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r: