mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
Three foot-guns in the canonical test runner, each of which cost real debugging time by making an unverified run look verified. 1. Zero collection across the whole run reported success-shaped output. Per-file rc=5 is rewritten to rc=0 so a platform-gated file (every test skipped on this OS) doesn't fail the suite — correct, but it also meant a run where NOTHING was collected anywhere printed "0 tests passed, 0 failed (100% complete)" and, with no failures recorded, could exit 0. Now the run-level guard counts every collected outcome (passed/failed/skipped/errors/xfailed/xpassed): an all-skipped file still passes, but zero-collected-anywhere prints an explicit "✗ NO TESTS RAN — this is NOT a pass" block naming the likely causes and returns 1. 2. A venv without pytest was selected merely for existing. The probe accepted any directory with bin/activate, so in a checkout/worktree without a local .venv it picked the RELEASE venv (~/.hermes/hermes-agent/venv, no pytest). Every file then died with "No module named pytest" and the run reported 0 tests. Candidates are now import-checked for pytest — the same guard the HERMES_PYTHON fallback already applied — and a skipped candidate is named on stderr. 3. Pytest node ids were silently discarded. This runner is file-granular, so `tests/foo.py::TestBar::test_baz` isn't an existing path: discovery dropped it and the run ended "No test files to run" while the selector looked accepted. Node ids are now translated to the FILE plus an inferred `-k` on the leaf name (parametrized ids reduced to the function name), with a note explaining the translation. An explicit caller `-k` wins over the inferred one. Tests: 4 behavior contracts in tests/test_run_tests_parallel.py. Verified by sabotage — reverting the runner fails 3 of the 4 (the fourth pins the pre-existing all-skipped tolerance so fix 1 can't regress it).
124 lines
5.9 KiB
Bash
Executable file
124 lines
5.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Canonical test runner for hermes-agent. Run this instead of calling
|
|
# `pytest` directly to guarantee your local run matches CI behavior.
|
|
#
|
|
# What this script enforces:
|
|
# * Per-file isolation via scripts/run_tests_parallel.py — each test
|
|
# file runs in its own freshly-spawned `python -m pytest <file>`
|
|
# subprocess. No xdist, no shared workers, no module-level leakage
|
|
# between files.
|
|
# * TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0 (deterministic)
|
|
# * Env vars blanked (conftest.py also does this, but this
|
|
# is belt-and-suspenders for anyone running pytest outside our
|
|
# conftest path — e.g. on a single file)
|
|
# * Proper venv activation (probes .venv, venv, then ~/.hermes/...)
|
|
#
|
|
# Usage:
|
|
# scripts/run_tests.sh # full suite
|
|
# scripts/run_tests.sh -j 4 # cap parallelism
|
|
# scripts/run_tests.sh tests/agent/ # discover only here
|
|
# scripts/run_tests.sh tests/agent/ tests/acp/ # multiple roots
|
|
# scripts/run_tests.sh tests/foo.py # single file
|
|
# scripts/run_tests.sh tests/foo.py -q # path + bare pytest flag
|
|
# scripts/run_tests.sh tests/foo.py -v --tb=long # bare flags "just work"
|
|
# scripts/run_tests.sh -k 'pattern' # value flags pass through too
|
|
# scripts/run_tests.sh tests/foo.py -- --tb=long # explicit '--' still works
|
|
#
|
|
# Bare pytest flags (anything starting with '-' that isn't one of this
|
|
# runner's own options: -j/--jobs, --paths, --slice, --file-timeout, etc.)
|
|
# are forwarded to each per-file pytest invocation automatically — no '--'
|
|
# separator required. The explicit '--' form still works and stacks with
|
|
# bare flags. Positional path arguments override the default discovery
|
|
# root (tests/).
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Locate repo root ────────────────────────────────────────────────────────
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
|
# ── Locate python ───────────────────────────────────────────────────────────
|
|
# Probe local venvs first; fall back to the Nix devShell's editable venv
|
|
# (HERMES_PYTHON is exported by the devShell hook and ships [dev] extras:
|
|
# pytest, pytest-asyncio, pytest-timeout, ruff, ty).
|
|
#
|
|
# A candidate must have pytest INSTALLED, not merely exist. The release venv
|
|
# at ~/.hermes/hermes-agent/venv has bin/activate but no pytest, so an
|
|
# existence-only probe selected it in checkouts/worktrees without a local
|
|
# .venv — every file then died with "No module named pytest" and the run
|
|
# reported "0 tests passed" (which reads green at a glance even though the
|
|
# exit code is 1). Skip such a venv and keep probing instead.
|
|
VENV=""
|
|
SKIPPED_VENVS=""
|
|
for candidate in "$REPO_ROOT/.venv" "$REPO_ROOT/venv" "$HOME/.hermes/hermes-agent/venv"; do
|
|
if [ -f "$candidate/bin/activate" ]; then
|
|
if "$candidate/bin/python" -c 'import pytest' 2>/dev/null; then
|
|
VENV="$candidate"
|
|
break
|
|
fi
|
|
SKIPPED_VENVS="$SKIPPED_VENVS $candidate"
|
|
fi
|
|
done
|
|
|
|
if [ -n "$SKIPPED_VENVS" ]; then
|
|
for skipped in $SKIPPED_VENVS; do
|
|
echo "▶ skipping venv without pytest: $skipped" >&2
|
|
done
|
|
fi
|
|
|
|
if [ -n "$VENV" ]; then
|
|
PYTHON="$VENV/bin/python"
|
|
elif [ -n "${HERMES_PYTHON:-}" ] && [ -x "$HERMES_PYTHON" ] \
|
|
&& "$HERMES_PYTHON" -c 'import pytest' 2>/dev/null; then
|
|
# Guard with an import check: HERMES_PYTHON may point at the RELEASE
|
|
# venv (no pytest) when inherited from a wrapped `hermes` binary rather
|
|
# than the devShell hook.
|
|
PYTHON="$HERMES_PYTHON"
|
|
echo "▶ no local venv — using Nix dev venv via HERMES_PYTHON: $PYTHON"
|
|
else
|
|
echo "error: no virtualenv with pytest found in $REPO_ROOT/.venv or $REPO_ROOT/venv," >&2
|
|
echo " and HERMES_PYTHON is not a python with pytest (enter the Nix devShell or create a venv)" >&2
|
|
if [ -n "$SKIPPED_VENVS" ]; then
|
|
echo " (skipped for missing pytest:$SKIPPED_VENVS — install dev extras there, or create $REPO_ROOT/.venv)" >&2
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
|
|
# ── Live-gateway plugin (computed before we drop env) ───────────────────────
|
|
EXTRA_PYTHONPATH=""
|
|
EXTRA_PYTEST_PLUGINS=""
|
|
if [ -f "$HOME/.hermes/pytest_live_guard.py" ]; then
|
|
EXTRA_PYTHONPATH="$HOME/.hermes"
|
|
EXTRA_PYTEST_PLUGINS="pytest_live_guard"
|
|
fi
|
|
|
|
|
|
# ── Run in hermetic env ──────────────────────────────────────────────────────
|
|
# env -i: start with empty environment, opt-in only what we need.
|
|
# No credential var can leak — you'd have to explicitly add it here.
|
|
echo "▶ running per-file parallel test suite via run_tests_parallel.py"
|
|
echo " (TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0; clean env)"
|
|
|
|
cd "$REPO_ROOT"
|
|
|
|
# ── Pre-compile .pyc bytecode cache ─────────────────────────────────────────
|
|
# Each test file runs in its own subprocess via run_tests_parallel.py.
|
|
# Pre-building the bytecode cache once here (instead of each subprocess
|
|
# compiling on first import) avoids redundant work across ~2000 processes.
|
|
# Uses git to list tracked .py files (skips venv, node_modules, etc).
|
|
echo "▶ pre-compiling bytecode cache"
|
|
"$PYTHON" -m compileall -q -j 0 -- $(git ls-files '*.py') >/dev/null 2>&1 || true
|
|
|
|
echo "▶ launching test runner"
|
|
exec env -i \
|
|
PATH="$PATH" \
|
|
HOME="$HOME" \
|
|
TZ=UTC \
|
|
LANG=C.UTF-8 \
|
|
LC_ALL=C.UTF-8 \
|
|
PYTHONHASHSEED=0 \
|
|
${HERMES_RUN_SLOW_PET_TESTS:+HERMES_RUN_SLOW_PET_TESTS="$HERMES_RUN_SLOW_PET_TESTS"} \
|
|
${EXTRA_PYTHONPATH:+PYTHONPATH="$EXTRA_PYTHONPATH"} \
|
|
${EXTRA_PYTEST_PLUGINS:+PYTEST_PLUGINS="$EXTRA_PYTEST_PLUGINS"} \
|
|
"$PYTHON" "$SCRIPT_DIR/run_tests_parallel.py" "$@"
|