Files
hermes-agent/tests/scripts/desktop_update/test_desktop_update_shim_progress.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

200 lines
6.7 KiB
Python

"""The shim's /progress contract, exercised against the real posix hand-off.
`ui.html` is one page for three operating systems, so the stage-and-elapsed
line it renders is only as good as the weakest orchestrator behind it. These
drive the real `posix.sh` and the real `serve-ui.py` -- no mocks, no source
reading -- because the posix half is the one that had no coverage.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from urllib.request import urlopen
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
SHIM_DIR = REPO_ROOT / "scripts" / "desktop-update"
# posix.sh re-execs itself through these to detach from Electron's process
# group; without them the hand-off never reaches its own main flow.
requires_posix_handoff = pytest.mark.skipif(
not (os.path.exists("/bin/bash") and os.path.exists("/usr/bin/python3")),
reason="posix.sh detaches through /bin/bash and /usr/bin/python3",
)
# ── serve-ui.py: what the page actually receives ───────────────────────────
@pytest.fixture
def progress(tmp_path):
"""The real loopback server, over a status file the test drives."""
status = tmp_path / "hermes-update-status"
proc = subprocess.Popen(
[
sys.executable,
str(SHIM_DIR / "serve-ui.py"),
str(SHIM_DIR / "ui.html"),
str(status),
str(time.time()),
],
stdout=subprocess.PIPE,
text=True,
)
try:
port = int(proc.stdout.readline().strip())
class Progress:
def publish(self, state: str, message: str) -> None:
status.write_text(json.dumps({"status": state, "message": message}))
def corrupt(self) -> None:
status.write_text("}not json{")
def poll(self) -> dict:
with urlopen(f"http://127.0.0.1:{port}/progress", timeout=5) as r:
return json.loads(r.read())
yield Progress()
finally:
proc.kill()
proc.wait(timeout=5)
def test_elapsed_advances_between_publishes(progress):
"""The clock is stamped per request, not frozen at the last publish.
Stages are minutes apart during a real update, so a value written into
the status file would sit still through exactly the long waits this line
exists to disprove.
"""
progress.publish("running", "Updating code and dependencies")
first = progress.poll()
time.sleep(1.2)
second = progress.poll()
assert first["status"] == "running"
assert second["message"] == first["message"] == "Updating code and dependencies"
assert second["elapsed_seconds"] > first["elapsed_seconds"]
def test_every_state_carries_a_clock(progress):
"""Including the stage-less running state posix publishes at window open."""
for state, message in [
("running", ""),
("running", "Installing the new app"),
("done", ""),
("manual", "Reopen Hermes to finish."),
("error", "Update failed."),
]:
progress.publish(state, message)
served = progress.poll()
assert served["status"] == state
assert served["message"] == message
assert served["elapsed_seconds"] >= 0
def test_unreadable_status_still_serves_a_running_state(progress):
"""A window frozen on its last frame is the bug being fixed here."""
progress.corrupt()
served = progress.poll()
assert served["status"] == "running"
assert served["elapsed_seconds"] >= 0
# ── posix.sh: the stages it publishes at its own gates ─────────────────────
# Stands in for `hermes update`, and reports the stage that was on screen
# while it ran -- the update child is the only thing that can observe the
# window's state at the exact moment of the longest wait in the hand-off.
FAKE_HERMES = """#!/bin/bash
# The hand-off probes `update --help` for --keep-stash support before the
# real update call; answer it without consuming a counted call so the
# exits.N mapping below still refers to actual update attempts.
case "$*" in *--help*) echo "--keep-stash"; exit 0 ;; esac
n="$(cat "$HERMES_TEST_CALLS" 2>/dev/null || echo 0)"; n=$((n + 1))
printf '%s' "$n" > "$HERMES_TEST_CALLS"
for f in "$TMPDIR"/hermes-update-status.[0-9]*; do
case "$f" in *.tmp) continue ;; esac
cp "$f" "$HERMES_TEST_CAPTURE.$n" 2>/dev/null
done
exit "$(cat "$HERMES_TEST_EXITS.$n" 2>/dev/null || echo 0)"
"""
def _run_handoff(tmp_path, exits: dict[int, int]) -> list[dict]:
"""Run the real hand-off end to end; return the stage seen at each call."""
install_root = tmp_path / "hermes-agent"
(install_root / "venv" / "bin").mkdir(parents=True)
hermes = install_root / "venv" / "bin" / "hermes"
hermes.write_text(FAKE_HERMES)
hermes.chmod(0o755)
capture = tmp_path / "seen"
calls = tmp_path / "calls"
for call, code in exits.items():
(tmp_path / f"exits.{call}").write_text(str(code))
env = {
**os.environ,
"TMPDIR": str(tmp_path),
"HERMES_TEST_CAPTURE": str(capture),
"HERMES_TEST_CALLS": str(calls),
"HERMES_TEST_EXITS": str(tmp_path / "exits"),
}
# The hand-off daemonizes and the launcher exits immediately; the result
# file is the orchestrator's own completion signal.
subprocess.run(
[
"/bin/bash",
str(SHIM_DIR / "posix.sh"),
"--install-root",
str(install_root),
"--no-ui",
],
env=env,
timeout=60,
check=True,
)
result = tmp_path / ".hermes-update-result.json"
deadline = time.monotonic() + 45
while time.monotonic() < deadline and not result.exists():
time.sleep(0.1)
assert result.exists(), "hand-off never wrote its result file"
seen = sorted(tmp_path.glob("seen.*"), key=lambda p: int(p.suffix[1:]))
return [json.loads(p.read_text()) for p in seen]
@requires_posix_handoff
def test_update_gate_publishes_its_stage_before_running(tmp_path):
"""`hermes update` is the longest wait in the hand-off and the one the
Discord report sat through; the window must name it while it happens."""
stages = _run_handoff(tmp_path, {1: 0})
assert len(stages) == 1
assert stages[0]["status"] == "running"
assert stages[0]["message"] == "Updating code and dependencies"
@requires_posix_handoff
def test_retry_gate_publishes_a_distinct_stage(tmp_path):
"""A failed first attempt doubles the wait -- the second pass must not
look like the first one hanging."""
stages = _run_handoff(tmp_path, {1: 1, 2: 0})
assert [s["message"] for s in stages] == [
"Updating code and dependencies",
"Retrying update",
]