The root file is project context and was 38.7k chars, past the context-file budget (20k floor, 30,720 on a 128k window), so every session lost its middle: routing table, testing rules, code-shape rules. It now keeps the two invariants, a condensed rubric (every rule kept), environment, structure, one-line rules that apply everywhere and the routing table (11.8k). Moved verbatim to where they load: Testing -> new tests/AGENTS.md; dependency pinning and PM ownership -> new pm/AGENTS.md; machine facts -> new hermes_platform/AGENTS.md; session-scoped tool availability -> tools/AGENTS.md; the full rubric with its real-close examples -> developer-guide/contributing.md. Pointers in lint, checker messages, docs and test comments name the new homes.
8.9 KiB
tests/ — how Hermes tests are written and run
Applies on top of the root AGENTS.md. Read it before adding or changing any test.
Running tests
ALWAYS use scripts/run_tests.sh, never bare pytest. It enforces CI parity: credential
vars unset, TZ=UTC, LANG=C.UTF-8, HERMES_HOME → temp dir, and per-file subprocess
isolation via scripts/run_tests_parallel.py (no xdist; workers scale with CPU count) so
module-level dicts/ContextVars cannot leak between files. Direct pytest on a big machine
with API keys set has caused repeated "works locally, fails in CI" incidents (and the reverse).
Prepare a test interpreter with the checkout's bootstrapped Python:
python -m pm.build_env --source . --out .venv --group dev --group test
This is a fresh build, not an in-place sync. If the disposable output exists,
stop its processes and intentionally remove it before regeneration. The runner
clears PYTHONPATH, so PM shell activation alone does not supply pytest. For a
fresh output outside the checkout, set HERMES_PYTHON to its interpreter.
scripts/run_tests.sh # full suite
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py -k test_x # runner is file-granular; -k narrows
scripts/run_tests.sh -v --tb=long # pytest flags pass through
- Flake policy: a failing FILE is retried once in a fresh subprocess (
--file-retries;HERMES_TEST_FILE_RETRIES=0disables); a worker killed by signal or the file timeout is never retried (relaunching a runaway doubles the damage). Pass-on-retry is green but printed under⚠ FLAKYwith both outputs — a bug to fix, not noise. Timing tests must not assume a quiet runner: wall-clock bounds ≥ 2s, event-based sync, noassert not _wait_until(...)races. - Placement mirrors the source tree. A test lives in
tests/<top-level source dir>/(tests/hermes_cli/,tests/agent/,tests/hermes_state/,tests/gateway/relay/, ...); installer/updater script tests undertests/scripts/{install,desktop_update}/. Only tests of root-level modules (batch_runner,utils,hermes_constants, packaging) sit directly intests/. No issue numbers in filenames — cite the issue in the module docstring (test_89315_x.py→test_x.py, "Regression for #89315"). - Placement (CI lanes):
scripts/ci/classify_changes.pypicks jobs by changed files. A Python test asserting aboutpackage.json,package-lock.json,tsconfig.json, or.ts/.tsx/.js/ .mjs/.cjssources will not run on a JS-only PR (green on PR, red onmainwhere the classifier fails open). Such tests belong in the vitest suite, nottests/*.py. - Tests must not write to
~/.hermes/. The autouse_isolate_hermes_homefixture intests/conftest.pyredirectsHERMES_HOME; never hardcode~/.hermes/in tests. Profile tests also mockPath.home()so_get_profiles_root()/_get_default_hermes_home()stay in the temp dir (pattern:tests/hermes_cli/test_profiles.py):Tests that@pytest.fixture def profile_env(tmp_path, monkeypatch): home = tmp_path / ".hermes"; home.mkdir() monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(home)) return homepatch.object(Path, "home", ...)must ALSO setHERMES_HOME— code reads the env var, notPath.home()/.hermes.
Don't fake the host OS
Behaviour that genuinely differs per host is tested ON that host with @pytest.mark.platforms("linux")
/ platforms("macos") / platforms("windows"), never by patching sys.platform. Host-independent things stay
unmarked: pure functions that take the platform as data (hidden_windows_child_options(opts, is_windows=True)) and declaration/packaging invariants ("pyproject declares tzdata with a
sys_platform == 'win32' marker"). Setting a module-level IS_WINDOWS flag and calling
windows_detach_flags() IS a fake. The line: if the test needs the interpreter to believe it
is on another OS to pass, it belongs on that OS. A test that walks several platforms in
sequence is split — host-native arm on Linux, other arms as their own marked tests.
One marker per test, with any number of spec strings (any-of semantics) plus
optional arch filters. To gate on several OSes, pass several specs to ONE
marker — never stack several platforms() decorators on one test (the
conftest rejects that at collection):
@pytest.mark.platforms("linux", "macos") # ONE marker, two specs: runs on either
def test_posix_signal_path(): ...
Other single-marker forms (each is a complete marker on its own):
platforms("windows") (native Windows only), platforms("not macos")
(anywhere except macOS), platforms("windows", arch="arm64") (native Windows
on arm64), platforms("posix") (Linux or macOS).
Specs: linux, macos, windows, posix, any, and not <spec>.
The historic linux_only / macos_only / windows_only markers have been
fully replaced — platforms is the only host-gating marker in the tree.
Live Windows process-topology E2E: the wine2e lane. For claims about
real Windows process behavior that mocks cannot reproduce (venv-holder
scans, process-tree parentage, launcher/worker chains, detach semantics),
there is an on-demand workflow windows-venv-e2e.yml that runs
tests/hermes_cli/test_venv_holder_windows_live.py on a real
windows-latest runner — spawning actual processes and driving the real
detection code, no mocked psutil. It fires ONLY on pushes to wine2e/**
branches (inert on PRs and main; costs nothing on normal work). The proven
workflow: write probes that pin CORRECT behavior, push to a wine2e/
branch to reproduce the bugs live on unfixed code, build the fix, iterate
until the lane is green, then open the PR — the live receipt on the exact
head is the Windows proof reviewers ask for. Extend the live suite when
touching that subsystem; assert against the gateway ANCESTOR found by
argv, not the direct parent (the venv shim makes every spawn a
launcher/worker chain).
Use the marker, never a bare skipif. scripts/ci/list_os_marked_tests.py
decides which files an OS lane imports by resolving the quoted specs inside
platforms(...) ("posix" reaches the macOS lane, "not linux" reaches
both others), and the lane then selects with -m platforms while the
conftest's per-test host skips do the actual gating. A test gated with
@pytest.mark.skipif(sys.platform != "win32") therefore runs on no host at
all, silently — it is never imported by the lane that would run it, and the
full-suite lanes skip it. skipif(sys.platform == "win32") becomes
platforms("posix"); a non-host condition (os.geteuid() == 0) stays a
separate skipif beside the marker. A misspelt spec is a collection error,
not a skip. Don't stack a module-level pytestmark = platforms(...) on a file whose tests carry their own host marker — the
conftest hard-rejects tests carrying two platforms() markers (a test
skipped on every host, reported green everywhere).
Equally, don't pytest.skip() the non-host rows of a @parametrize over
platforms — split it into one marked test per OS, or only the host's row ever
executes.
Don't write change-detector tests
A change-detector fails whenever data expected to change is updated — model catalogs,
_config_version, enumeration counts, hardcoded model lists. It adds no coverage and taxes
every routine update. Don't: assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"],
assert DEFAULT_CONFIG["_config_version"] == 21, assert len(models) == 8. Do: assert "gemini" in _PROVIDER_MODELS and len(_PROVIDER_MODELS["gemini"]) >= 1 (plumbing works);
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] (migration reaches
latest); assert not (set(moonshot_models) & coding_plan_only_models) (no leak); every
catalog model has a context-length entry (relationship). If it reads like a snapshot, delete
it; if it reads like a contract between two pieces of data, keep it. Reviewers reject new
change-detectors; authors convert them before re-review.
Never read source code in tests
A test that reads a .py/.ts/.tsx file's text tests the shape of the source, not
behavior — banned outright. It passes when the implementation is subtly broken (regex matches
a mis-wired call site) and fails on correct refactors; it can't run against bundled/minified
artifacts; it blocks structural cleanup; it gives false confidence. Don't
fs.readFileSync('main.ts') + assert.match(source, /spawn\(...hiddenWindowsChildOptions/).
Do extract the logic into a pure/DI-testable function and call it:
export function hiddenWindowsChildOptions(options = {}, isWindows = process.platform === 'win32') {
if (!isWindows || 'windowsHide' in options) return options
return { ...options, windowsHide: true }
}
If the logic lives inline in a god-file and extraction feels disruptive, that is the signal to extract, not to regex around it.