test(e2e/windows): real C:\Users profiles, serialized gateway phases, Git-for-Windows machines
This commit is contained in:
12
.github/workflows/windows-install-update-e2e.yml
vendored
12
.github/workflows/windows-install-update-e2e.yml
vendored
@@ -50,9 +50,12 @@ jobs:
|
||||
-m "platforms and integration" -v --tb=short -rA
|
||||
env:
|
||||
HERMES_E2E_WINDOWS_INSTALL: '1'
|
||||
# Short machine roots: a real install lives at C:\Users\<name>\AppData\Local\hermes;
|
||||
# pytest's tmp dir would add ~100 characters before node_modules.
|
||||
# Machine work dirs (serve.git, transcripts) stay short under runner.temp. The fake
|
||||
# user profiles live in C:\Users itself, where a real one does: the checkout carries
|
||||
# 159-character paths, so a deeper profile hits MAX_PATH where no user would (the
|
||||
# runner is disposable, so writing C:\Users is fine).
|
||||
HERMES_E2E_MACHINE_ROOT: ${{ runner.temp }}\wm
|
||||
HERMES_E2E_PROFILES_ROOT: C:\Users
|
||||
HERMES_E2E_ARTIFACTS: ${{ runner.temp }}\win-update-e2e
|
||||
# One journey per file, all in parallel (each is mostly network + subprocess wait).
|
||||
HERMES_TEST_WORKERS: '6'
|
||||
@@ -77,11 +80,14 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$names = 'python.exe', 'pythonw.exe', 'hermes.exe', 'node.exe'
|
||||
# Work dirs live under runner.temp\wm; profiles are C:\Users\*hermes-e2e-<id>.
|
||||
$root = "${{ runner.temp }}\wm".ToLower()
|
||||
$profileMark = 'hermes-e2e-'
|
||||
$deadline = (Get-Date).AddSeconds(20)
|
||||
do {
|
||||
$left = @(Get-CimInstance Win32_Process | Where-Object {
|
||||
($names -contains $_.Name.ToLower()) -and ("$($_.CommandLine) $($_.ExecutablePath)".ToLower().Contains($root))
|
||||
$blob = "$($_.CommandLine) $($_.ExecutablePath)".ToLower()
|
||||
($names -contains $_.Name.ToLower()) -and ($blob.Contains($root) -or $blob.Contains($profileMark))
|
||||
})
|
||||
if ($left.Count -eq 0) { 'no leftover processes from the E2E machines'; exit 0 }
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
@@ -145,8 +145,9 @@ _pf86="$(env | sed -n 's/^ProgramFiles(x86)=//p' | head -n1)"
|
||||
# * CI / GITHUB_ACTIONS tell suites they run on a disposable runner (e.g.
|
||||
# tests/e2e/core/upgrade runs the real updater unsandboxed only there).
|
||||
# * HERMES_E2E_WINDOWS_INSTALL opts tests/e2e/core/windows_update into running
|
||||
# the real install.ps1 (it writes HKCU PATH); HERMES_E2E_MACHINE_ROOT and
|
||||
# HERMES_E2E_ARTIFACTS place its fake machines and the transcripts CI uploads.
|
||||
# the real install.ps1 (it writes HKCU PATH); HERMES_E2E_MACHINE_ROOT,
|
||||
# HERMES_E2E_PROFILES_ROOT and HERMES_E2E_ARTIFACTS place its fake machines,
|
||||
# their user profiles and the transcripts CI uploads.
|
||||
#
|
||||
# These are test-infrastructure knobs, not credentials — same class as the
|
||||
# HERMES_RUN_SLOW_PET_TESTS / HERMES_E2E_BROWSER / HERMES_RUN_E2E opt-ins
|
||||
@@ -160,7 +161,7 @@ TEST_ENV=()
|
||||
for _test_var in HERMES_TEST_IMAGE HERMES_TEST_WORKERS HERMES_TEST_PATHS \
|
||||
HERMES_TEST_FILE_TIMEOUT HERMES_TEST_FILE_RETRIES HERMES_TEST_SLICE \
|
||||
SSL_CERT_FILE SSL_CERT_DIR HERMES_GATEWAY_LOCK_DIR HERMES_E2E_REQUIRE_TUI CI GITHUB_ACTIONS \
|
||||
HERMES_E2E_WINDOWS_INSTALL HERMES_E2E_MACHINE_ROOT HERMES_E2E_ARTIFACTS; do
|
||||
HERMES_E2E_WINDOWS_INSTALL HERMES_E2E_MACHINE_ROOT HERMES_E2E_PROFILES_ROOT HERMES_E2E_ARTIFACTS; do
|
||||
if [ -n "${!_test_var:-}" ]; then
|
||||
TEST_ENV+=("$_test_var=${!_test_var}")
|
||||
fi
|
||||
|
||||
@@ -20,11 +20,17 @@ Only external edges are replaced (tests/install/README.md, "The isolation trick"
|
||||
Tool and dependency downloads (uv, the managed Python, wheels, Node) use the network,
|
||||
exactly like the real installer.
|
||||
|
||||
Each machine is a fake user profile under pytest's tmp dir: ``USERPROFILE``/``HOME``/
|
||||
``LOCALAPPDATA``/``APPDATA`` point inside it and ``HERMES_HOME`` is NOT set, so the
|
||||
installer and every ``hermes`` command resolve the default ``%LOCALAPPDATA%\\hermes``
|
||||
the way a real user's do. The installer also prepends its bin dir to the user PATH in
|
||||
HKCU; the machine restores that value on teardown.
|
||||
Each machine is a fake user profile: ``USERPROFILE``/``HOME``/``LOCALAPPDATA``/``APPDATA``
|
||||
point inside it and ``HERMES_HOME`` is NOT set, so the installer and every ``hermes``
|
||||
command resolve the default ``%LOCALAPPDATA%\\hermes`` the way a real user's do. CI
|
||||
puts the profiles in ``C:\\Users`` itself (``HERMES_E2E_PROFILES_ROOT``): the checkout
|
||||
carries 159-character paths, so a profile any deeper than a real one would hit MAX_PATH
|
||||
where no user does. The installer also prepends its bin dir to the user PATH in HKCU;
|
||||
the machine restores that value on teardown.
|
||||
|
||||
Machines run in parallel, but ``hermes update`` on Windows pauses every gateway on the
|
||||
host, including other installs' (#124659). Updates and gateway lifetimes therefore take
|
||||
a job-wide lock (``gateway_phase``); installs, turns and everything else stay parallel.
|
||||
|
||||
The suite mutates HKCU and downloads a toolchain per machine, so it only runs where
|
||||
``HERMES_E2E_WINDOWS_INSTALL=1`` (the CI job sets it).
|
||||
@@ -32,11 +38,12 @@ The suite mutates HKCU and downloads a toolchain per machine, so it only runs wh
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
@@ -135,6 +142,10 @@ class Machine:
|
||||
root: Path
|
||||
profile_name: str
|
||||
base_url: str
|
||||
profiles_root: Path | None = None
|
||||
# Git for Windows on PATH (a typical developer box). False: a clean machine, where the
|
||||
# installer must stage its own pinned Git.
|
||||
system_git: bool = False
|
||||
path_prepend: list[str] = field(default_factory=list)
|
||||
head: str = ""
|
||||
next: str = ""
|
||||
@@ -142,12 +153,14 @@ class Machine:
|
||||
_hkcu: Any = None
|
||||
_seq: int = 0
|
||||
_spawned: list[subprocess.Popen] = field(default_factory=list)
|
||||
_lock_depth: int = 0
|
||||
timings: list[tuple[str, float]] = field(default_factory=list)
|
||||
|
||||
# -- layout ---------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def profile(self) -> Path:
|
||||
return self.root / "Users" / self.profile_name
|
||||
return (self.profiles_root or self.root / "Users") / self.profile_name
|
||||
|
||||
@property
|
||||
def local(self) -> Path:
|
||||
@@ -183,7 +196,8 @@ class Machine:
|
||||
roaming = self.profile / "AppData" / "Roaming"
|
||||
self.local.mkdir(parents=True, exist_ok=True)
|
||||
roaming.mkdir(parents=True, exist_ok=True)
|
||||
path = _strip_path(os.environ.get("PATH", ""), ("git.exe", "python.exe", "python3.exe", "uv.exe"))
|
||||
drop = ("python.exe", "python3.exe", "uv.exe") + (() if self.system_git else ("git.exe",))
|
||||
path = _strip_path(os.environ.get("PATH", ""), drop)
|
||||
env.update({
|
||||
"PATH": os.pathsep.join([*self.path_prepend, path]),
|
||||
"USERPROFILE": str(self.profile),
|
||||
@@ -253,11 +267,13 @@ class Machine:
|
||||
"""Run to completion with the full transcript on disk (survives a timeout)."""
|
||||
self._seq += 1
|
||||
log = self.logs / f"{self._seq:02d}-{label}.log"
|
||||
started = time.monotonic()
|
||||
with log.open("wb") as fh:
|
||||
proc = subprocess.Popen(argv, cwd=cwd or self.profile, env=self.env(env_extra),
|
||||
stdin=subprocess.DEVNULL, stdout=fh, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
code = proc.wait(timeout=timeout)
|
||||
self.timings.append((label, round(time.monotonic() - started, 1)))
|
||||
except subprocess.TimeoutExpired:
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=60)
|
||||
proc.wait(timeout=60)
|
||||
@@ -290,7 +306,46 @@ class Machine:
|
||||
return self._run_logged([str(self.hermes_exe), *args], name, timeout=timeout, env_extra=env_extra)
|
||||
|
||||
def update(self, *extra: str, label: str = "update") -> Run:
|
||||
return self.hermes("update", "--yes", *extra, label=label, timeout=UPDATE_TIMEOUT)
|
||||
with self.gateway_phase():
|
||||
return self.hermes("update", "--yes", *extra, label=label, timeout=UPDATE_TIMEOUT)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def gateway_phase(self):
|
||||
"""Job-wide mutex for updates and gateway lifetimes (reentrant within a machine).
|
||||
|
||||
``hermes update`` on Windows discovers gateways host-wide and stops ones it cannot map
|
||||
to its own profiles (#124659), so one machine's update would kill another machine's
|
||||
gateway. Real users rarely run two installs side by side; this suite always does."""
|
||||
if self._lock_depth:
|
||||
self._lock_depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._lock_depth -= 1
|
||||
return
|
||||
import msvcrt
|
||||
|
||||
lock_dir = Path(os.environ.get("HERMES_E2E_MACHINE_ROOT") or tempfile.gettempdir())
|
||||
lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
waited = time.monotonic()
|
||||
with (lock_dir / "gateway-phase.lock").open("a+b") as fh:
|
||||
while True:
|
||||
try:
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() - waited > 1800:
|
||||
raise RuntimeError("harness: gateway-phase lock not acquired within 30 min") from None
|
||||
time.sleep(0.5)
|
||||
self.timings.append(("(waited for gateway phase)", round(time.monotonic() - waited, 1)))
|
||||
self._lock_depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._lock_depth = 0
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
def installed_head(self) -> str:
|
||||
try:
|
||||
@@ -348,7 +403,7 @@ class Machine:
|
||||
"""Live processes of this machine: exe/cwd/argv under its root, or its HERMES_HOME."""
|
||||
import psutil
|
||||
|
||||
root = os.path.normcase(os.path.normpath(str(self.root)))
|
||||
roots = {os.path.normcase(os.path.normpath(str(p))) for p in (self.root, self.profile)}
|
||||
home = os.path.normcase(os.path.normpath(str(self.hermes_home)))
|
||||
me = os.getpid()
|
||||
owned = []
|
||||
@@ -361,14 +416,14 @@ class Machine:
|
||||
blob = os.path.normcase(" ".join([proc.exe() or "", proc.cwd() or "", *proc.cmdline()]))
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
if hh == home or root in blob:
|
||||
if hh == home or any(root in blob for root in roots):
|
||||
owned.append(proc)
|
||||
return owned
|
||||
|
||||
def evidence(self) -> str:
|
||||
"""Receipts, logs and the process table: what a failure message must carry."""
|
||||
parts = [f"machine root: {self.root}", f"HEAD={self.head} NEXT={self.next}",
|
||||
f"installed checkout: {self.installed_head()}"]
|
||||
parts = [f"machine root: {self.root}", f"profile: {self.profile}", f"HEAD={self.head} NEXT={self.next}",
|
||||
f"installed checkout: {self.installed_head()}", f"timings (s): {self.timings}"]
|
||||
receipt = self.hermes_home / "logs" / "update_receipts" / "latest.json"
|
||||
if receipt.is_file():
|
||||
parts.append(self._tail(receipt, 4000))
|
||||
@@ -385,11 +440,15 @@ class Machine:
|
||||
parts.append(f"process table unavailable: {exc}")
|
||||
return "\n".join(parts)
|
||||
|
||||
def teardown(self) -> None:
|
||||
def kill_owned(self) -> None:
|
||||
"""Hard-stop every live process of this machine (end of a gateway phase, teardown)."""
|
||||
kill_tree(self.owned_processes())
|
||||
for proc in self._spawned:
|
||||
if proc.poll() is None:
|
||||
subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=60)
|
||||
|
||||
def teardown(self) -> None:
|
||||
self.kill_owned()
|
||||
try:
|
||||
_restore_hkcu_path(self._hkcu)
|
||||
except OSError:
|
||||
@@ -404,17 +463,20 @@ class Machine:
|
||||
shutil.copytree(src, dest / sub, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("*.db", "*.db-*"))
|
||||
(dest / "evidence.txt").write_text(self.evidence(), encoding="utf-8", errors="replace")
|
||||
(dest / "timings.json").write_text(json.dumps(self.timings, indent=1), encoding="utf-8")
|
||||
|
||||
|
||||
def new_machine(tmp_root: Path, base_url: str, *, label: str, profile_name: str = "e2e",
|
||||
path_prepend: list[str] | None = None) -> Machine:
|
||||
"""A staged machine. ``HERMES_E2E_MACHINE_ROOT`` (CI: a short runner.temp dir) keeps the
|
||||
install tree near the depth of a real ``C:\\Users\\<name>\\AppData\\Local\\hermes``;
|
||||
pytest's own tmp dir is already ~100 characters deep on Windows, and node_modules does
|
||||
the rest of the way to MAX_PATH."""
|
||||
def new_machine(tmp_root: Path, base_url: str, *, label: str, person: str = "",
|
||||
system_git: bool = False) -> Machine:
|
||||
"""A staged machine. Its work dir (serve.git, transcripts) lives under
|
||||
``HERMES_E2E_MACHINE_ROOT`` (else ``tmp_root``); its user profile under
|
||||
``HERMES_E2E_PROFILES_ROOT`` (CI: ``C:\\Users``), named ``[<person> ]hermes-e2e-<id>``."""
|
||||
sfx = uuid.uuid4().hex[:4]
|
||||
base = Path(os.environ.get("HERMES_E2E_MACHINE_ROOT") or tmp_root)
|
||||
machine = Machine(root=base / f"{label}-{uuid.uuid4().hex[:4]}", profile_name=profile_name,
|
||||
base_url=base_url, path_prepend=list(path_prepend or []))
|
||||
profiles = os.environ.get("HERMES_E2E_PROFILES_ROOT")
|
||||
name = f"{person} hermes-e2e-{sfx}" if person else f"hermes-e2e-{sfx}"
|
||||
machine = Machine(root=base / f"{label}-{sfx}", profile_name=name, base_url=base_url,
|
||||
profiles_root=Path(profiles) if profiles else None, system_git=system_git)
|
||||
machine.stage()
|
||||
return machine
|
||||
|
||||
@@ -454,6 +516,14 @@ class Journey:
|
||||
raise RuntimeError(fail_with(self.machine, f"{name}: {message}", run))
|
||||
|
||||
|
||||
def failure_line(run: Run) -> str:
|
||||
"""The first line a ``hermes`` command printed as its failure (``✗ ...``), else ``""``."""
|
||||
for line in run.stdout.splitlines():
|
||||
if line.strip().startswith("✗"):
|
||||
return line.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def fail_with(machine: Machine, message: str, run: Run | None = None) -> str:
|
||||
"""Assertion text: the claim first (gates match on it), then the transcript and evidence."""
|
||||
tail = f"\n--- transcript rc={run.returncode} ---\n{run.stdout[-6000:]}" if run is not None else ""
|
||||
|
||||
@@ -19,6 +19,7 @@ from tests.e2e.core.windows_update._machine import (
|
||||
REQUIRES_OPT_IN,
|
||||
Journey,
|
||||
fail_with,
|
||||
failure_line,
|
||||
new_machine,
|
||||
one_shot_turn,
|
||||
source_completion_detour,
|
||||
@@ -29,10 +30,10 @@ pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
|
||||
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
|
||||
|
||||
KNOWN = {
|
||||
"first_launch": (
|
||||
r"^first agent launch after a pristine install detoured through source-update completion",
|
||||
"gated on #123314: a fresh Windows source install enters source-update completion on the "
|
||||
"first agent launch",
|
||||
"update": (
|
||||
r"^hermes update exited 1: ✗ Update failed: \[WinError 2\]",
|
||||
"gated on #124634: on a machine without system Git, the pinned Git that install.ps1 "
|
||||
"staged never reaches `hermes update`'s PATH",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -71,10 +72,9 @@ def test_install_lands_on_head_and_publishes_hermes(journey: Journey) -> None:
|
||||
def test_first_agent_launch_runs_the_turn(journey: Journey) -> None:
|
||||
m, turn = journey.machine, journey["first_turn"]
|
||||
detour = source_completion_detour(turn.run)
|
||||
with known_gate(KNOWN, "first_launch"):
|
||||
assert detour is None, fail_with(
|
||||
assert detour is None, fail_with(
|
||||
m, f"first agent launch after a pristine install detoured through source-update completion "
|
||||
f"(printed {detour!r})", turn.run)
|
||||
f"(printed {detour!r})", turn.run)
|
||||
assert turn.ok, fail_with(
|
||||
m, f"first agent launch did not complete a turn (reply printed={turn.reply_id in turn.run.stdout}, "
|
||||
f"prompt reached provider={turn.reached_wire})", turn.run)
|
||||
@@ -82,7 +82,8 @@ def test_first_agent_launch_runs_the_turn(journey: Journey) -> None:
|
||||
|
||||
def test_update_moves_checkout_to_next(journey: Journey) -> None:
|
||||
m, run = journey.machine, journey["update"]
|
||||
assert run.returncode == 0, fail_with(m, f"hermes update exited {run.returncode}", run)
|
||||
with known_gate(KNOWN, "update"):
|
||||
assert run.returncode == 0, fail_with(m, f"hermes update exited {run.returncode}: {failure_line(run)}", run)
|
||||
head = m.installed_head()
|
||||
assert head == m.next, fail_with(m, f"after hermes update the checkout is at {head}, expected NEXT {m.next}", run)
|
||||
assert (m.install_dir / NEXT_MARKER).is_file(), fail_with(m, "NEXT's marker file is missing from the checkout", run)
|
||||
|
||||
@@ -53,10 +53,18 @@ def _alive(pid: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _exits(pid: int, timeout: float) -> bool:
|
||||
try:
|
||||
wait_until(lambda: not _alive(pid), timeout, f"gateway pid {pid} to exit", interval=0.5)
|
||||
except AssertionError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def journey(tmp_path_factory):
|
||||
with FakeLLMServer() as srv:
|
||||
machine = new_machine(tmp_path_factory.mktemp("gw"), srv.base_url, label="gw")
|
||||
machine = new_machine(tmp_path_factory.mktemp("gw"), srv.base_url, label="gw", system_git=True)
|
||||
j = Journey(machine)
|
||||
try:
|
||||
install = j.step("install", machine.install)
|
||||
@@ -64,18 +72,22 @@ def journey(tmp_path_factory):
|
||||
j.step("install_ok", lambda: j.require("install", install.returncode == 0,
|
||||
f"install.ps1 exited {install.returncode}", install))
|
||||
if j.ok("install_ok"):
|
||||
j.step("spawn", machine.spawn_gateway)
|
||||
before = j.step("state_before", machine.wait_gateway_running)
|
||||
j.step("status_before", lambda: machine.hermes("gateway", "status", label="status-before"))
|
||||
machine.advance()
|
||||
j.step("update", machine.update)
|
||||
old_pid = int(before.get("pid") or 0) if isinstance(before, dict) else None
|
||||
j.step("state_after", lambda: machine.wait_gateway_running(not_pid=old_pid))
|
||||
j.step("status_after", lambda: machine.hermes("gateway", "status", label="status-after"))
|
||||
j.step("pidfile_after", lambda: (machine.hermes_home / "gateway.pid").exists())
|
||||
j.step("update_again", lambda: machine.update(label="update-again"))
|
||||
j.step("state_before_stop", machine.gateway_state)
|
||||
j.step("stop", lambda: machine.hermes("gateway", "stop", label="stop"))
|
||||
with machine.gateway_phase():
|
||||
j.step("spawn", machine.spawn_gateway)
|
||||
before = j.step("state_before", machine.wait_gateway_running)
|
||||
j.step("status_before", lambda: machine.hermes("gateway", "status", label="status-before"))
|
||||
machine.advance()
|
||||
j.step("update", machine.update)
|
||||
old_pid = int(before.get("pid") or 0) if isinstance(before, dict) else None
|
||||
j.step("state_after", lambda: machine.wait_gateway_running(not_pid=old_pid))
|
||||
j.step("status_after", lambda: machine.hermes("gateway", "status", label="status-after"))
|
||||
j.step("pidfile_after", lambda: (machine.hermes_home / "gateway.pid").exists())
|
||||
j.step("update_again", lambda: machine.update(label="update-again"))
|
||||
last = j.step("state_before_stop", machine.gateway_state)
|
||||
j.step("stop", lambda: machine.hermes("gateway", "stop", label="stop"))
|
||||
last_pid = int(last.get("pid") or 0) if isinstance(last, dict) else 0
|
||||
j.step("stopped", lambda: _exits(last_pid, 90))
|
||||
machine.kill_owned() # nothing of this machine outlives its gateway phase
|
||||
yield j
|
||||
finally:
|
||||
machine.teardown()
|
||||
@@ -100,8 +112,7 @@ def test_update_with_running_gateway_succeeds(journey: Journey) -> None:
|
||||
|
||||
def test_gateway_serves_next_after_update(journey: Journey) -> None:
|
||||
m, state = journey.machine, journey["state_after"]
|
||||
pid, sha = int(state.get("pid") or 0), str(state.get("code_sha") or "")
|
||||
assert _alive(pid), fail_with(m, f"the relaunched gateway pid {pid} is not alive")
|
||||
sha = str(state.get("code_sha") or "") # state_after only resolves for a live, running pid
|
||||
assert len(sha) >= 7 and m.next.startswith(sha), fail_with(
|
||||
m, f"the relaunched gateway serves code_sha={sha!r}, expected NEXT {m.next}")
|
||||
|
||||
@@ -130,12 +141,7 @@ def test_gateway_stop_after_update(journey: Journey) -> None:
|
||||
m, stop, state = journey.machine, journey["stop"], journey["state_before_stop"]
|
||||
pid = int(state.get("pid") or 0)
|
||||
assert pid, fail_with(m, f"no gateway recorded before stop: {state}")
|
||||
try:
|
||||
wait_until(lambda: not _alive(pid), 90, f"gateway pid {pid} to exit after stop", interval=0.5)
|
||||
stopped = True
|
||||
except AssertionError:
|
||||
stopped = False
|
||||
with known_gate(KNOWN, "stop"):
|
||||
assert stopped, fail_with(
|
||||
assert journey["stopped"], fail_with(
|
||||
m, f"after update `hermes gateway stop` left the serving gateway (pid {pid}) running", stop)
|
||||
assert stop.returncode == 0, fail_with(m, f"hermes gateway stop exited {stop.returncode}", stop)
|
||||
|
||||
@@ -26,6 +26,7 @@ from tests.e2e.core.windows_update._machine import (
|
||||
Journey,
|
||||
Machine,
|
||||
fail_with,
|
||||
failure_line,
|
||||
new_machine,
|
||||
)
|
||||
from tests.fakes.fake_llm_provider import FakeLLMServer
|
||||
@@ -34,8 +35,9 @@ pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
|
||||
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
|
||||
|
||||
KNOWN = {
|
||||
"partial_fetch": (r"^hermes update over the installer's partial clone failed: .*BUG: builtin/pack-objects",
|
||||
"gated on #124323: the pinned Git's pack-objects BUG on the --filter=tree:0 clone"),
|
||||
"partial_fetch": (r"^hermes update over the installer's partial clone failed: rc=1, .*"
|
||||
r"✗ Update failed: \[WinError 2\]",
|
||||
"gated on #124634: the pinned Git install.ps1 staged never reaches `hermes update`'s PATH"),
|
||||
"holders": (r"^`hermes update --list-venv-holders` did not report the live venv holders .*"
|
||||
r"\(rc=0, reported pids \[\]\)",
|
||||
"gated on #123050: --list-venv-holders reads the retired hermes_cli.main stub and always "
|
||||
@@ -108,7 +110,8 @@ def test_update_fetches_into_the_installers_partial_clone(journey: Journey) -> N
|
||||
m, run, flt = journey.machine, journey["update"], journey["partial_filter"]
|
||||
assert flt, fail_with(m, "harness: the installer's clone is not partial (no remote.origin.partialclonefilter); "
|
||||
"serve.git should allow filters")
|
||||
fetch_bug = next((ln.strip() for ln in run.stdout.splitlines() if "BUG:" in ln or "fatal:" in ln), "")
|
||||
fetch_bug = next((ln.strip() for ln in run.stdout.splitlines() if "BUG:" in ln or "fatal:" in ln),
|
||||
failure_line(run))
|
||||
with known_gate(KNOWN, "partial_fetch"):
|
||||
assert run.returncode == 0 and m.installed_head() == m.next, fail_with(
|
||||
m, f"hermes update over the installer's partial clone failed: rc={run.returncode}, checkout at "
|
||||
|
||||
@@ -84,7 +84,7 @@ def _under(path: str, root: Path) -> bool:
|
||||
def journey(tmp_path_factory):
|
||||
system_python = _system_python_dir()
|
||||
with FakeLLMServer() as srv:
|
||||
machine = new_machine(tmp_path_factory.mktemp("interp"), srv.base_url, label="interp")
|
||||
machine = new_machine(tmp_path_factory.mktemp("interp"), srv.base_url, label="interp", system_git=True)
|
||||
j = Journey(machine)
|
||||
try:
|
||||
install = j.step("install", machine.install)
|
||||
@@ -103,11 +103,13 @@ def journey(tmp_path_factory):
|
||||
j.step("update_ok", lambda: j.require("update", update.returncode == 0,
|
||||
f"hermes update exited {update.returncode}", update))
|
||||
if j.ok("update_ok"):
|
||||
j.step("spawn", machine.spawn_gateway)
|
||||
state = j.step("state", machine.wait_gateway_running)
|
||||
if j.ok("state"):
|
||||
time.sleep(15) # supervised workers start ~2 s after boot; give them room to die
|
||||
j.step("gateway_proc", lambda: _inspect(int(state["pid"])))
|
||||
with machine.gateway_phase():
|
||||
j.step("spawn", machine.spawn_gateway)
|
||||
state = j.step("state", machine.wait_gateway_running)
|
||||
if j.ok("state"):
|
||||
time.sleep(15) # supervised workers start ~2 s after boot; give them room to die
|
||||
j.step("gateway_proc", lambda: _inspect(int(state["pid"])))
|
||||
machine.kill_owned() # nothing of this machine outlives its gateway phase
|
||||
j.step("turn", lambda: one_shot_turn(machine, srv, "turn-stale-venv"))
|
||||
yield j
|
||||
finally:
|
||||
|
||||
@@ -32,7 +32,7 @@ from tests.fakes.fake_llm_provider import FakeLLMServer
|
||||
pytestmark = [pytest.mark.platforms("windows"), pytest.mark.integration,
|
||||
pytest.mark.live_system_guard_bypass, REQUIRES_OPT_IN]
|
||||
|
||||
PROFILE_NAME = "Jörg Ñúñez e2e"
|
||||
PERSON = "Jörg Ñúñez" # the profile is "Jörg Ñúñez hermes-e2e-<id>"
|
||||
KNOWN = {
|
||||
"install": (r"^install\.ps1 failed for a profile path with non-ASCII characters and spaces: .*"
|
||||
r"is not recognized as the name of a cmdlet",
|
||||
@@ -138,7 +138,7 @@ def _tool_runs(hermes_home: Path) -> list[tuple[Path, int | None, str]]:
|
||||
def journey(tmp_path_factory):
|
||||
with FakeLLMServer() as srv:
|
||||
machine = new_machine(tmp_path_factory.mktemp("paths"), srv.base_url, label="paths",
|
||||
profile_name=PROFILE_NAME)
|
||||
person=PERSON, system_git=True)
|
||||
j = Journey(machine)
|
||||
try:
|
||||
install = j.step("install", machine.install)
|
||||
|
||||
Reference in New Issue
Block a user