126 lines
5.1 KiB
Python
126 lines
5.1 KiB
Python
"""Harness: the image ships a prebuilt TUI bundle, not a runtime npm install.
|
|
|
|
Regression guard for the hosted-chat failure where the embedded dashboard
|
|
Chat tab died with a 502 / "[session ended]". Root cause: the image installs
|
|
only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),
|
|
so the actualized node_modules permanently disagrees with the canonical
|
|
package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls
|
|
through to source dependency preparation, racing itself across
|
|
concurrent /api/pty connections → ENOTEMPTY.
|
|
|
|
The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which
|
|
makes the launcher take the prebuilt-bundle fast path (``node --expose-gc
|
|
.../dist/entry.js``) and skip the install check entirely. These tests assert
|
|
that invariant holds in the built image.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shlex
|
|
import subprocess
|
|
|
|
|
|
def _exec_py(image: str, py: str) -> str:
|
|
"""Run a Python snippet inside the image as the hermes user, return stdout."""
|
|
inner = (
|
|
"source /opt/hermes/.venv/bin/activate && "
|
|
"cd /opt/hermes && "
|
|
f"python3 -c {shlex.quote(py)}"
|
|
)
|
|
# Drop to the hermes user (UID 10000) so we exercise the same path the
|
|
# dashboard PTY child runs as — not root.
|
|
cmd = [
|
|
"docker", "run", "--rm", "--network=none", "--entrypoint", "su", image,
|
|
"hermes", "-s", "/bin/bash", "-c", inner,
|
|
]
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}"
|
|
return r.stdout.strip()
|
|
|
|
|
|
|
|
|
|
def test_photon_baked_dependencies_load_without_writes_or_network(built_image: str) -> None:
|
|
"""The non-root runtime uses the baked sidecar, including its npm patch."""
|
|
py = '''
|
|
import subprocess
|
|
from plugins.platforms.photon.sidecar_paths import SOURCE_SIDECAR_DIR, resolve_sidecar_dir, dir_writable
|
|
sidecar = resolve_sidecar_dir()
|
|
assert sidecar == SOURCE_SIDECAR_DIR, sidecar
|
|
assert not dir_writable(sidecar / 'node_modules')
|
|
child = subprocess.run(['node', '--input-type=module', '-e',
|
|
"import {patchSpectrumTs} from './patch-spectrum-mixed-attachments.mjs'; "
|
|
"patchSpectrumTs(); await import('spectrum-ts'); console.log('PHOTON_LOADED')"],
|
|
cwd=sidecar, capture_output=True, text=True, timeout=30)
|
|
assert child.returncode == 0, child.stderr
|
|
print(child.stdout.strip())
|
|
'''
|
|
assert _exec_py(built_image, py).endswith('PHOTON_LOADED')
|
|
|
|
|
|
def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:
|
|
"""The launcher must (a) find the prebuilt bundle and (b) NOT want an
|
|
npm install — i.e. it takes the same path as a nix/packaged release."""
|
|
py = (
|
|
"import json\n"
|
|
"from pathlib import Path\n"
|
|
"from hermes_cli.main_tui_launch import _make_tui_argv\n"
|
|
"ui = Path('/opt/hermes/ui-tui')\n"
|
|
"argv, cwd = _make_tui_argv(ui, tui_dev=False)\n"
|
|
"out = {\n"
|
|
" 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n"
|
|
" 'argv': argv,\n"
|
|
" 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n"
|
|
"}\n"
|
|
"print(json.dumps(out))\n"
|
|
)
|
|
out = json.loads(_exec_py(built_image, py))
|
|
assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image"
|
|
# With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE
|
|
# ever reaching the install check — so the resolved argv is what matters.
|
|
assert out["uses_prebuilt"], f"launcher did not take prebuilt path: argv={out['argv']!r}"
|
|
assert "npm" not in out["argv"][0].lower(), (
|
|
f"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}"
|
|
)
|
|
|
|
|
|
def test_tui_gateway_child_uses_packaged_python(built_image: str) -> None:
|
|
"""The TUI child must inherit the image's dependency environment."""
|
|
py = '''
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from hermes_cli.main_tui_launch import _apply_tui_python_env
|
|
|
|
env = dict(os.environ)
|
|
_apply_tui_python_env(env)
|
|
child = subprocess.run(
|
|
[env["HERMES_PYTHON"], "-c",
|
|
"import os, sys; print(os.path.realpath(sys.executable), flush=True); import tui_gateway.entry"],
|
|
cwd="/opt/hermes", env=env, capture_output=True, text=True, timeout=30,
|
|
)
|
|
print(json.dumps({
|
|
"parent": os.path.realpath(sys.executable),
|
|
"child": child.stdout.strip().splitlines()[0] if child.stdout.strip() else "",
|
|
"returncode": child.returncode,
|
|
"stderr": child.stderr[-1000:],
|
|
}))
|
|
'''
|
|
# Run as the image user without `su`, which would erase Docker's
|
|
# HERMES_PYTHON and hide the broken runtime environment.
|
|
inner = (
|
|
"source /opt/hermes/.venv/bin/activate && "
|
|
"cd /opt/hermes && "
|
|
f"python3 -c {shlex.quote(py)}"
|
|
)
|
|
r = subprocess.run(
|
|
["docker", "run", "--rm", "--network=none", "--user", "hermes",
|
|
"--entrypoint", "/bin/bash", built_image, "-c", inner],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
assert r.returncode == 0, r.stderr[-2000:]
|
|
out = json.loads(r.stdout.strip())
|
|
assert out["returncode"] == 0, out["stderr"]
|
|
assert out["child"] == out["parent"], out
|