Use the exact target root and profile for branch, release, and cache decisions. Keep the desktop as a transport and handoff adapter. Remove the competing TypeScript checker and switch the banner, dashboard, and updater count consumers. Preserve fork origins, unknown counts, publication checks, old-probe recovery, and official SSH branch healing through public HTTPS. Keep the historical unstamped-root policy unchanged.
130 lines
5.8 KiB
Python
130 lines
5.8 KiB
Python
"""Drive the real CLI with a FIFO update-cache result under a Linux PTY.
|
|
|
|
Run from the checkout with the project Python. No provider requests are sent.
|
|
The FIFO releases the real cached update result only after the prompt renders.
|
|
"""
|
|
import argparse
|
|
import errno
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pty
|
|
import select
|
|
import signal
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import termios
|
|
import time
|
|
import fcntl
|
|
|
|
|
|
def run_case(root, output, name, behind, early=False, cancel=False):
|
|
with tempfile.TemporaryDirectory(prefix="hermes_test_notice_") as home:
|
|
hh = Path(home) / ".hermes"
|
|
hh.mkdir()
|
|
(hh / "config.yaml").write_text(
|
|
"model:\n default: test-model\n provider: custom\n"
|
|
" base_url: http://127.0.0.1:9/v1\n"
|
|
"display:\n interface: cli\n skip_banner: false\n"
|
|
"memory:\n provider: ''\n", encoding="utf-8")
|
|
cache = hh / "notice-result.json"
|
|
# This harness tests display timing, not source-check cache policy.
|
|
payload = json.dumps({"behind": behind}).encode()
|
|
if early:
|
|
cache.write_bytes(payload)
|
|
else:
|
|
os.mkfifo(cache)
|
|
env = {"PATH": os.environ["PATH"], "HOME": home, "HERMES_HOME": str(hh),
|
|
"PYTHONPATH": str(root), "PYTHONUNBUFFERED": "1",
|
|
"TERM": "xterm-256color", "LANG": "C.UTF-8",
|
|
"OPENAI_API_KEY": "local-not-used", "PROMPT_TOOLKIT_NO_CPR": "1"}
|
|
master, slave = pty.openpty()
|
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
|
|
bootstrap = ("import json; from pathlib import Path; import hermes_cli.main as m; "
|
|
"import hermes_cli.banner as b; from hermes_cli import source_check; "
|
|
f"source_check.check_for_updates = lambda **kw: json.loads(Path({str(cache)!r}).read_text()); "
|
|
"print('LOADED', m.__file__, b.__file__, flush=True); m.main()")
|
|
proc = subprocess.Popen([sys.executable, "-c", bootstrap, "chat"], cwd=root,
|
|
env=env, stdin=slave, stdout=slave, stderr=slave,
|
|
start_new_session=True)
|
|
os.close(slave)
|
|
data = bytearray()
|
|
|
|
def pump_until(predicate, timeout=30):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if predicate(bytes(data)):
|
|
return True
|
|
if select.select([master], [], [], 0.1)[0]:
|
|
try:
|
|
chunk = os.read(master, 65536)
|
|
except OSError as exc:
|
|
if exc.errno == errno.EIO:
|
|
return predicate(bytes(data))
|
|
raise
|
|
if not chunk:
|
|
return predicate(bytes(data))
|
|
data.extend(chunk)
|
|
return predicate(bytes(data))
|
|
|
|
try:
|
|
ready = pump_until(lambda b: b"\x1b[?2004h" in b)
|
|
assert ready, f"{name}: prompt did not render: {data[-1500:]!r}"
|
|
offset = len(data)
|
|
if not early and not cancel:
|
|
fd = os.open(cache, os.O_WRONLY | os.O_NONBLOCK)
|
|
os.write(fd, payload)
|
|
os.close(fd)
|
|
pump_until(lambda b: b"to update" in b[offset:] or b"update available" in b[offset:], 3)
|
|
# prompt_toolkit prints above the prompt via run_in_terminal (input detached,
|
|
# cooked mode); type only after the prompt is redrawn, like a user would.
|
|
redraw = len(data)
|
|
pump_until(lambda b: b"\x1b[?2004h" in b[redraw:], 3)
|
|
elif cancel:
|
|
os.write(master, b"\x03")
|
|
os.write(master, b"/quit\r")
|
|
exited = pump_until(lambda b: proc.poll() is not None, 60)
|
|
proc.wait(timeout=30)
|
|
text = bytes(data).decode(errors="replace")
|
|
(output / f"{name}.pty").write_bytes(data)
|
|
result = {"case": name, "ready": ready, "exited": exited,
|
|
"returncode": proc.returncode, "garbled": "?[1;33m" in text,
|
|
"notice": "commits behind" in text or "update available" in text,
|
|
"loaded_worktree": str(root / "hermes_cli/banner.py") in text,
|
|
"raw_path": str(output / f"{name}.pty")}
|
|
assert result["loaded_worktree"] and proc.returncode == 0, result
|
|
return result
|
|
finally:
|
|
(output / f"{name}.pty").write_bytes(data)
|
|
if proc.poll() is None:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
proc.wait(timeout=30)
|
|
os.close(master)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--root", required=True)
|
|
parser.add_argument("--expect", choices=("clean", "garbled"), required=True)
|
|
args = parser.parse_args()
|
|
root = Path(args.root).resolve()
|
|
args.output.mkdir(parents=True, exist_ok=True)
|
|
cases = [("late-count", 3, False, False), ("late-current", 0, False, False),
|
|
("late-unknown-count", -1, False, False), ("early-count", 3, True, False),
|
|
("cancel-pending", 3, False, True)]
|
|
results = [run_case(root, args.output, *case) for case in cases]
|
|
(args.output / "results.json").write_text(json.dumps(results, indent=2) + "\n")
|
|
print(json.dumps(results, indent=2))
|
|
for row in results:
|
|
expected_notice = row["case"] not in ("late-current", "cancel-pending")
|
|
assert row["notice"] == expected_notice, row
|
|
expected_garble = args.expect == "garbled" and row["case"].startswith("late-") and expected_notice
|
|
assert row["garbled"] == expected_garble, row
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|