When a Desktop-driven update fails at the Electron rebuild (updater exit 6, 'Code and dependencies updated, but the Desktop app REBUILD FAILED'), the only artifacts holding the root cause are ~/.hermes/logs/update.log (full stdout/stderr mirror of hermes update, written by _UpdateOutputStream) and ~/.hermes/logs/desktop-update-handoff.log (hand-off stages including the 'desktop --force-build --build-only' retry). Both the updater's failure messages and the Desktop error box direct users to 'hermes debug share', but the share captured neither file: LOG_FILES and the debug.py capture chain covered only the classic five logs, so update-failure issues (e.g. #100874) arrived with the diagnostics that matter most missing. Additionally, 'hermes logs list' (a directory scan) already listed both files while LOG_FILES had no readable key for them, so 'hermes logs update' failed with 'Unknown log' against a file the CLI itself displays. Wire both files through the whole chain: - hermes_cli/logs.py: add 'update' and 'handoff' LOG_FILES keys (fixes list-vs-read inconsistency; tail/follow/filters come free). - hermes_cli/debug.py: _capture_default_log_snapshots, collect_debug_report (512 KB snapshot cap and the 100-line tail budget both apply; a hand-built snapshot dict without the new keys degrades to the old five-section report instead of KeyError), collect_share_bundle full logs, the paste-upload label list in build_debug_share, and the --local print list. The --nous bundle and privacy notice inherit the new keys; force-redaction applies via _capture_log_snapshot as for every other log. - hermes_cli/subcommands/logs.py + website docs: name the new logs. Tests (tests/hermes_cli/test_debug_share_update_logs.py): 12 new tests — snapshot keys present with real content, missing files degrade to the honest note without raising, report sections appear, bundle keys omitted when absent, upload/--local label lists include the new logs, LOG_FILES keys resolve, tail_log end-to-end. Sabotage pass: 10 of 12 fail without the fix.
213 lines
7.9 KiB
Python
213 lines
7.9 KiB
Python
"""Regression tests: debug share captures update.log + desktop-update-handoff.log.
|
|
|
|
A Desktop-driven update that fails at the Electron rebuild (updater exit 6)
|
|
stores its root cause in exactly two files: ``logs/update.log`` (full
|
|
stdout/stderr mirror of ``hermes update``) and
|
|
``logs/desktop-update-handoff.log`` (hand-off stages incl. the rebuild
|
|
retry). The updater's failure message and the Desktop error box both point
|
|
users at ``hermes debug share``, yet the share captured neither file, so
|
|
update-failure reports arrived with zero root-cause signal (#100874).
|
|
|
|
These tests fail against the pre-fix capture set (five logs only).
|
|
"""
|
|
|
|
import pytest
|
|
|
|
# Lines the writers really produce (main.py _UpdateOutputStream mirror and
|
|
# scripts/desktop-update/windows.ps1 Write-HandoffLog / posix.sh log()).
|
|
UPDATE_LOG_BODY = (
|
|
"=== hermes update started 2026-09-02T03:21:09 ===\n"
|
|
"→ Fetching updates...\n"
|
|
"→ Building desktop packaged app...\n"
|
|
"✗ Desktop GUI build failed\n"
|
|
)
|
|
HANDOFF_LOG_BODY = (
|
|
"2026-09-02T03:21:08 desktop rebuild exit code: 1\n"
|
|
"2026-09-02T03:21:08 rebuild!| npm ERR! code ELIFECYCLE\n"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def home_with_update_logs(tmp_path, monkeypatch):
|
|
"""Isolated HERMES_HOME whose logs directory holds the update pair."""
|
|
home = tmp_path / "hermes_test_home"
|
|
logs = home / "logs"
|
|
logs.mkdir(parents=True)
|
|
(logs / "agent.log").write_text(
|
|
"2026-09-02 03:21:00 INFO agent.conversation_loop: API call #1\n"
|
|
)
|
|
(logs / "update.log").write_text(UPDATE_LOG_BODY)
|
|
(logs / "desktop-update-handoff.log").write_text(HANDOFF_LOG_BODY)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
return home
|
|
|
|
|
|
@pytest.fixture
|
|
def home_without_update_logs(tmp_path, monkeypatch):
|
|
"""Isolated HERMES_HOME with only the classic five logs present."""
|
|
home = tmp_path / "hermes_test_home"
|
|
logs = home / "logs"
|
|
logs.mkdir(parents=True)
|
|
for name in ("agent.log", "errors.log", "gateway.log", "gui.log", "desktop.log"):
|
|
(logs / name).write_text("2026-09-02 03:21:00 INFO x: one line\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
return home
|
|
|
|
|
|
class TestCaptureDefaultLogSnapshots:
|
|
"""The snapshot collector must include the update/hand-off pair."""
|
|
|
|
def test_snapshots_include_update_and_handoff_keys(self, home_with_update_logs):
|
|
from hermes_cli.debug import _capture_default_log_snapshots
|
|
|
|
snaps = _capture_default_log_snapshots(100)
|
|
|
|
assert "update" in snaps
|
|
assert "handoff" in snaps
|
|
assert "Desktop GUI build failed" in snaps["update"].tail_text
|
|
assert "ELIFECYCLE" in snaps["handoff"].tail_text
|
|
|
|
def test_missing_files_report_absence_without_raising(
|
|
self, home_without_update_logs
|
|
):
|
|
"""A fresh install has no update logs — capture degrades, not crashes."""
|
|
from hermes_cli.debug import _capture_default_log_snapshots
|
|
|
|
snaps = _capture_default_log_snapshots(100)
|
|
|
|
# Keys exist; the snapshots describe the missing files honestly.
|
|
assert "update" in snaps
|
|
assert "handoff" in snaps
|
|
assert "not found" in snaps["update"].tail_text
|
|
assert "not found" in snaps["handoff"].tail_text
|
|
|
|
|
|
class TestCollectDebugReport:
|
|
"""The summary report (what users paste) shows the update tails."""
|
|
|
|
def test_report_contains_update_and_handoff_sections(self, home_with_update_logs):
|
|
from hermes_cli.debug import collect_debug_report
|
|
|
|
report = collect_debug_report(log_lines=50, dump_text="dump")
|
|
|
|
assert "--- update.log" in report
|
|
assert "Desktop GUI build failed" in report
|
|
assert "--- desktop-update-handoff.log" in report
|
|
assert "ELIFECYCLE" in report
|
|
|
|
def test_report_omits_update_sections_when_logs_absent(
|
|
self, home_without_update_logs
|
|
):
|
|
from hermes_cli.debug import collect_debug_report
|
|
|
|
report = collect_debug_report(log_lines=50, dump_text="dump")
|
|
|
|
# Honest absence: the section header appears with a missing-note
|
|
# (snapshot present), never a KeyError. The classic five remain.
|
|
assert "--- agent.log" in report
|
|
assert "--- update.log" in report
|
|
assert "--- desktop-update-handoff.log" in report
|
|
|
|
def test_report_tolerates_a_hand_built_snapshots_dict(self, home_with_update_logs):
|
|
"""Gateway /debug and older callers pass dicts without the new keys.
|
|
|
|
They must keep working (no KeyError) — degraded to the old
|
|
five-section report rather than crashing.
|
|
"""
|
|
from hermes_cli.debug import LogSnapshot, collect_debug_report
|
|
|
|
legacy_keys = ("agent", "errors", "gateway", "gui", "desktop")
|
|
legacy = {k: LogSnapshot(path=None, tail_text="", full_text="") for k in legacy_keys}
|
|
|
|
report = collect_debug_report(
|
|
log_lines=50, dump_text="dump", log_snapshots=legacy
|
|
)
|
|
|
|
assert "--- agent.log" in report
|
|
assert "--- update.log" not in report
|
|
|
|
|
|
class TestCollectShareBundle:
|
|
"""The upload bundle (paste.rs + --nous) carries the full update logs."""
|
|
|
|
def test_bundle_includes_update_logs_when_present(self, home_with_update_logs):
|
|
from hermes_cli.debug import collect_share_bundle
|
|
|
|
bundle = collect_share_bundle(log_lines=50, redact=False)
|
|
|
|
assert "update.log" in bundle
|
|
assert "Desktop GUI build failed" in bundle["update.log"]
|
|
assert "--- full update.log ---" in bundle["update.log"]
|
|
assert "desktop-update-handoff.log" in bundle
|
|
assert "ELIFECYCLE" in bundle["desktop-update-handoff.log"]
|
|
|
|
def test_bundle_omits_update_keys_when_files_absent(
|
|
self, home_without_update_logs
|
|
):
|
|
from hermes_cli.debug import collect_share_bundle
|
|
|
|
bundle = collect_share_bundle(log_lines=50, redact=False)
|
|
|
|
assert "update.log" not in bundle
|
|
assert "desktop-update-handoff.log" not in bundle
|
|
# The classic five keep uploading.
|
|
assert "agent.log" in bundle
|
|
|
|
|
|
class TestFullLogUploadLabels:
|
|
"""build_debug_share uploads every bundle log — including the new pair."""
|
|
|
|
def test_upload_label_list_includes_update_logs(self):
|
|
"""The paste-upload loop must not silently skip the new labels.
|
|
|
|
Asserts the label tuple (source-derived behavior contract: the
|
|
bundle keys are only reachable for upload if listed here).
|
|
"""
|
|
import inspect
|
|
|
|
from hermes_cli import debug
|
|
|
|
source = inspect.getsource(debug.build_debug_share)
|
|
assert '"update.log"' in source
|
|
assert '"desktop-update-handoff.log"' in source
|
|
|
|
def test_local_print_label_list_includes_update_logs(self):
|
|
"""run_debug_share --local prints bundle logs by the same contract."""
|
|
import inspect
|
|
|
|
from hermes_cli import debug
|
|
|
|
source = inspect.getsource(debug.run_debug_share)
|
|
assert '"FULL update.log"' in source
|
|
assert '"FULL desktop-update-handoff.log"' in source
|
|
|
|
|
|
class TestLogsRegistry:
|
|
"""LOG_FILES gains readable keys for files `hermes logs list` already shows."""
|
|
|
|
def test_update_and_handoff_keys_resolve_real_filenames(self):
|
|
from hermes_cli.logs import LOG_FILES
|
|
|
|
assert LOG_FILES.get("update") == "update.log"
|
|
assert LOG_FILES.get("handoff") == "desktop-update-handoff.log"
|
|
|
|
def test_tail_log_reads_update_log_from_hermes_home(self, home_with_update_logs):
|
|
"""End-to-end: `hermes logs update` resolves and prints the file."""
|
|
from hermes_cli import logs as logs_mod
|
|
|
|
logs_mod.tail_log("update", num_lines=10)
|
|
|
|
def test_tail_log_names_the_new_logs_when_unknown_requested(
|
|
self, home_with_update_logs, capsys
|
|
):
|
|
from hermes_cli import logs as logs_mod
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
logs_mod.tail_log("no-such-log")
|
|
|
|
assert excinfo.value.code == 1
|
|
# The available-log hint teaches the new keys.
|
|
hint = capsys.readouterr().out
|
|
assert "update" in hint
|
|
assert "handoff" in hint
|