replica-omnisciente/telminal/tests/test_process.py
Aurelio 56be25c1ed feat(telminal): add hardened Terminal-in-Telegram bot to aurelio
Port fristhon/telminal (MIT) into the aurelio monorepo as a self-hosted
Telegram shell bot for the fleet gateway (CT-217).

- telminal/ package: config (env + state file, refuses empty admins),
  core orchestrator (event handlers, router, watchers, interactive mode,
  file up/download, xterm.js image render), process (pexpect PTY + streaming
  + inline control buttons), telegram (Telethon wrapper, swappable for tests),
  cli (entry point reading TELEGRAM_* env), utils, values.
- aurelio hardening vs upstream: no first-run random token auth (explicit
  admin allowlist required); cd sandbox validated against working root;
  secrets from env mirroring the CT-217 gateway .env.
- 35 real tests (pty capture/control-char, router, watchers, perms, sandbox,
  fake-client orchestration) -- all green.
- deployment: systemd/telminal.service, .env.example entries, README, AGENTS.md.

Verified: pytest 35 passed; CLI refuses start with missing env / no admins.
2026-07-15 23:42:31 +01:00

192 lines
5.2 KiB
Python

"""Tests for the PTY-backed process engine (real pexpect)."""
from __future__ import annotations
import asyncio
import time
from pathlib import Path
import pytest
from telminal.process import TProcess
def _drive(coro_factory):
"""Run a coroutine factory under a fresh event loop and return its result.
The factory is responsible for calling ``proc.run(stream=True)`` (which uses
``asyncio.create_task`` and therefore requires a running loop).
"""
async def _wrapper():
return await coro_factory()
try:
loop = asyncio.get_event_loop()
if loop.is_running():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(_wrapper())
def test_process_captures_output():
def factory():
proc = TProcess("echo hello-world", 1)
proc.set_temp_path(Path("/tmp"))
proc.run(stream=True)
async def wait():
deadline = time.time() + 5
while proc.is_running and time.time() < deadline:
await asyncio.sleep(0.05)
await asyncio.sleep(0.15)
return proc.full_output
return wait()
assert "hello-world" in _drive(factory)
def test_process_exit_code_and_done():
def factory():
proc = TProcess("exit 7", 2)
proc.set_temp_path(Path("/tmp"))
proc.run(stream=True)
async def wait():
deadline = time.time() + 5
while proc.is_running and time.time() < deadline:
await asyncio.sleep(0.05)
await asyncio.sleep(0.1)
return proc.is_running
return wait()
assert _drive(factory) is False
def test_process_streaming_updates_buffer():
def factory():
proc = TProcess("printf 'line1\\nline2\\n'", 3)
proc.set_temp_path(Path("/tmp"))
proc.run(stream=True)
async def wait():
deadline = time.time() + 5
while proc.is_running and time.time() < deadline:
await asyncio.sleep(0.05)
await asyncio.sleep(0.1)
return proc.full_output
return wait()
out = _drive(factory)
assert "line1" in out
assert "line2" in out
def test_push_sends_plain_text_and_newline():
def factory():
proc = TProcess("cat", 4)
proc.set_temp_path(Path("/tmp"))
proc.run(stream=True)
proc.push("ping\n")
async def wait():
# let the streaming task copy pty bytes into the buffer
await asyncio.sleep(0.4)
sent = "ping" in proc.full_output
proc.terminate()
return sent
return wait()
assert _drive(factory) is True
def test_push_control_char():
# `push("^c")` must route to sendcontrol (transmit Ctrl-C) without error.
# We assert the control char is delivered to the pty (echoed as ^C) rather
# than relying on the child's SIGINT handler, which depends on real TTY job
# control not present in this non-controlling pty.
def factory():
proc = TProcess("cat", 5)
proc.set_temp_path(Path("/tmp"))
proc.run(stream=True)
proc.push("^c")
async def wait():
await asyncio.sleep(0.3)
delivered = "^C" in proc.full_output or "\x03" in proc.full_output
proc.terminate()
return delivered
return wait()
assert _drive(factory) is True
def test_media_strip_truncates_long_output():
proc = TProcess("true", 6)
proc.set_temp_path(Path("/tmp"))
proc._buffer.write("x" * 2000)
stripped = proc.media_output
assert len(stripped) == 1024
assert stripped.endswith("x")
def test_html_render_contains_output():
import tempfile
d = Path(tempfile.mkdtemp())
def factory():
proc = TProcess("echo abc", 7)
proc.set_temp_path(d)
proc.run(stream=True)
async def wait():
try:
proc._process.expect("abc", timeout=5)
except Exception:
pass
deadline = time.time() + 5
while proc.is_running and time.time() < deadline:
await asyncio.sleep(0.05)
await asyncio.sleep(0.1)
return proc.html
return wait()
html_path = _drive(factory)
content = Path(html_path).read_text()
assert "abc" in content
assert "<html" in content
def test_update_buttons_changes_for_running_vs_done():
def factory():
proc = TProcess("sleep 0.2", 8)
proc.set_temp_path(Path("/tmp"))
proc.run(stream=True)
async def wait():
first = proc.update_buttons()
first_len = len(proc.buttons)
deadline = time.time() + 5
while proc.is_running and time.time() < deadline:
await asyncio.sleep(0.05)
await asyncio.sleep(0.1)
second = proc.update_buttons()
second_len = len(proc.buttons)
return first, first_len, second, second_len
return wait()
first, first_len, second, second_len = _drive(factory)
assert first is True
assert first_len == 5
assert second is True
assert second_len == 2