Мастер сообщил «Графический дисплей не обнаружен», хотя окно хаба было открыто на рабочем столе владельца. Причина: хаб запускается через nohup и наследует окружение той оболочки, из которой его запустили. Запуск по SSH или службой оставляет процесс без DISPLAY. Наследование не единственный источник. Измерено на сервере владельца: loginctl show-user ochenstarik -p Display даёт c1, а show-session c1 — Type=x11, Display=:10, Active=yes. Спросить у системы честнее, чем сдаться. Теперь дисплей ищется сначала в окружении, затем у systemd, и найденное значение передаётся в окружение терминала — иначе окно не открылось бы даже при верном обнаружении. Отказ остаётся правомерным, только когда графического сеанса не знает и systemd; в сообщении перечисляется всё проверенное, включая сам loginctl. XAUTHORITY не выставляем: клиенты X11 по умолчанию берут ~/.Xauthority того же пользователя, а хаб работает под ним же. 674 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
9.7 KiB
Python
247 lines
9.7 KiB
Python
"""Проверки по итогам разбора A57 ревьюером.
|
||
|
||
Мастер сообщал «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
|
||
ag-6», а окна не появлялось. На сервере владельца в это время висел
|
||
`[xfce4-terminal] <defunct>` — терминал стартовал и немедленно умирал.
|
||
|
||
Причин три, и каждая проверяется здесь.
|
||
|
||
1. `x-terminal-emulator` на Ubuntu указывает на `xfce4-terminal.wrapper`, а
|
||
xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание
|
||
работающему экземпляру и завершается. Нужен `--disable-server`.
|
||
2. При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и
|
||
подменённый HOME не применяется — вход ушёл бы в настоящий домашний
|
||
каталог владельца мимо всей изоляции слотов. Поэтому окружение задаёт сам
|
||
сценарий, а не наследование.
|
||
3. С ключом -e окно закрывается вместе с командой, и причину отказа прочесть
|
||
нельзя. Сценарий ждёт нажатия клавиши.
|
||
|
||
И отдельно: возврат Popen ничего не говорит об открытии окна.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import shutil
|
||
import subprocess
|
||
|
||
import pytest
|
||
|
||
from antigravity_provider import agy_subprocess
|
||
from antigravity_provider.agy_subprocess import (
|
||
find_terminal_emulator,
|
||
write_login_helper,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def linux(monkeypatch):
|
||
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
|
||
monkeypatch.setenv("DISPLAY", ":0")
|
||
|
||
|
||
def _only(available: set[str]):
|
||
def _which(name):
|
||
return f"/usr/bin/{name}" if name in available else None
|
||
|
||
return _which
|
||
|
||
|
||
# ── Сценарий входа ──
|
||
|
||
def test_helper_sets_environment_itself(tmp_path):
|
||
helper = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6")
|
||
body = helper.read_text(encoding="utf-8")
|
||
|
||
assert "HOME=" in body and str(tmp_path) in body
|
||
assert "export HOME" in body
|
||
assert "/usr/local/bin/agy" in body
|
||
|
||
|
||
def test_helper_keeps_the_window_open(tmp_path):
|
||
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8")
|
||
|
||
assert "read " in body, "иначе окно исчезнет вместе с agy и причина отказа пропадёт"
|
||
assert "$status" in body, "код возврата agy должен быть виден владельцу"
|
||
|
||
|
||
def test_helper_contains_no_secrets(tmp_path):
|
||
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8").lower()
|
||
|
||
for forbidden in ("access_token", "refresh_token", "api_key", "bearer", "ya29."):
|
||
assert forbidden not in body
|
||
|
||
|
||
# ── Выбор эмулятора ──
|
||
|
||
def test_xfce4_terminal_gets_disable_server(linux, monkeypatch, tmp_path):
|
||
monkeypatch.setattr(shutil, "which", _only({"xfce4-terminal", "x-terminal-emulator"}))
|
||
|
||
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||
|
||
assert err is None
|
||
assert cmd[0] == "/usr/bin/xfce4-terminal"
|
||
assert "--disable-server" in cmd, (
|
||
"без этого вызов уходит работающему экземпляру и окно не открывается"
|
||
)
|
||
|
||
|
||
def test_alternatives_wrapper_is_the_last_resort(linux, monkeypatch, tmp_path):
|
||
"""x-terminal-emulator — обёртка над альтернативами, лишний слой."""
|
||
monkeypatch.setattr(shutil, "which", _only({"x-terminal-emulator", "xterm"}))
|
||
|
||
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||
|
||
assert err is None
|
||
assert cmd[0] == "/usr/bin/xterm"
|
||
|
||
|
||
def test_terminal_runs_the_helper_not_agy_directly(linux, monkeypatch, tmp_path):
|
||
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
|
||
|
||
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||
|
||
assert err is None
|
||
assert "/usr/local/bin/agy" not in cmd
|
||
assert str(tmp_path / ".hermes-agy-login.sh") in cmd
|
||
|
||
|
||
def test_missing_terminal_lists_what_was_checked(linux, monkeypatch, tmp_path):
|
||
monkeypatch.setattr(shutil, "which", _only(set()))
|
||
|
||
cmd, err, checked = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
|
||
|
||
assert cmd is None
|
||
assert err
|
||
assert len(checked) >= 5
|
||
assert any("xterm" in item for item in checked)
|
||
|
||
|
||
# ── Запуск подтверждается, а не объявляется ──
|
||
|
||
def test_immediate_exit_is_reported_as_failure(linux, monkeypatch, tmp_path):
|
||
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
|
||
monkeypatch.setattr(agy_subprocess.time, "sleep", lambda _s: None)
|
||
monkeypatch.setattr(
|
||
agy_subprocess, "get_agy_exe", lambda: "/usr/local/bin/agy", raising=False
|
||
)
|
||
|
||
class _DeadProcess:
|
||
def __init__(self, *a, **k):
|
||
pass
|
||
|
||
def poll(self):
|
||
return 1
|
||
|
||
monkeypatch.setattr(subprocess, "Popen", _DeadProcess)
|
||
|
||
ok, msg, data = agy_subprocess.start_native_agy_login(profile_id="ag-6", force=True)
|
||
|
||
assert ok is False
|
||
assert "окно не открылось" in msg
|
||
assert data.get("exit_code") == 1
|
||
|
||
|
||
# ── Пустые слоты появлялись сами ──
|
||
|
||
def test_asking_for_a_path_creates_nothing(tmp_path, monkeypatch):
|
||
"""У владельца накопилось 25 каталогов профилей при единицах заведённых.
|
||
|
||
Причина: get_profile_dir создавал каталог при каждом обращении, а в коде
|
||
зашит список «стандартных» слотов — ag-orch-primary, ag-orch-fallback,
|
||
ag-1..ag-20, ag-w1..ag-w10. Любой обход этого списка материализовал их все.
|
||
Спросить, где профиль жил бы, и завести его — разные действия.
|
||
"""
|
||
from antigravity_provider import paths
|
||
|
||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
root = tmp_path / "agy_profiles"
|
||
|
||
for pid in ("ag-orch-primary", "ag-w7", "ag-19", "ag-cold-3"):
|
||
paths.get_profile_dir(pid, "antigravity")
|
||
|
||
assert not root.exists() or not any(root.iterdir()), (
|
||
"запрос пути не должен оставлять каталоги на диске"
|
||
)
|
||
|
||
created = paths.get_profile_dir("ag-1", "antigravity", create=True)
|
||
assert created.is_dir()
|
||
assert sorted(p.name for p in root.iterdir()) == ["ag-1"]
|
||
|
||
|
||
# ── Дисплей ищется не только в окружении ──
|
||
|
||
def test_display_from_environment_wins(monkeypatch):
|
||
from antigravity_provider.agy_subprocess import detect_graphical_session
|
||
|
||
monkeypatch.setenv("DISPLAY", ":0")
|
||
found, checked = detect_graphical_session()
|
||
|
||
assert found["DISPLAY"] == ":0"
|
||
assert any("DISPLAY (задан: :0)" in item for item in checked)
|
||
|
||
|
||
def test_display_is_asked_from_systemd_when_environment_is_empty(monkeypatch):
|
||
"""Хаб запускается через nohup: по SSH он остаётся без DISPLAY.
|
||
|
||
Измерено на сервере владельца: loginctl show-user даёт сеанс c1, а
|
||
show-session c1 — Type=x11, Display=:10. Отказываться, не спросив, рано.
|
||
"""
|
||
from antigravity_provider import agy_subprocess as m
|
||
|
||
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
|
||
monkeypatch.delenv(var, raising=False)
|
||
monkeypatch.setenv("USER", "ochenstarik")
|
||
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
|
||
|
||
answers = {("show-user", "ochenstarik"): "c1", ("show-session", "c1", "Type"): "x11",
|
||
("show-session", "c1", "Display"): ":10"}
|
||
|
||
class _Res:
|
||
def __init__(self, out):
|
||
self.stdout, self.returncode = out, 0
|
||
|
||
def _run(cmd, **kwargs):
|
||
if cmd[1] == "show-user":
|
||
return _Res(answers[("show-user", cmd[2])])
|
||
return _Res(answers[("show-session", cmd[2], cmd[-1])])
|
||
|
||
monkeypatch.setattr(m.subprocess, "run", _run)
|
||
found, checked = m.detect_graphical_session()
|
||
|
||
assert found == {"DISPLAY": ":10"}
|
||
assert any("loginctl сеанс c1" in item for item in checked)
|
||
|
||
|
||
def test_wayland_session_is_reported_as_wayland(monkeypatch):
|
||
from antigravity_provider import agy_subprocess as m
|
||
|
||
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
|
||
monkeypatch.delenv(var, raising=False)
|
||
monkeypatch.setenv("USER", "owner")
|
||
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
|
||
|
||
class _Res:
|
||
def __init__(self, out):
|
||
self.stdout, self.returncode = out, 0
|
||
|
||
def _run(cmd, **kwargs):
|
||
if cmd[1] == "show-user":
|
||
return _Res("2")
|
||
return _Res("wayland" if cmd[-1] == "Type" else "wayland-0")
|
||
|
||
monkeypatch.setattr(m.subprocess, "run", _run)
|
||
found, _ = m.detect_graphical_session()
|
||
|
||
assert found == {"WAYLAND_DISPLAY": "wayland-0"}
|
||
|
||
|
||
def test_no_session_anywhere_lists_what_was_asked(monkeypatch):
|
||
from antigravity_provider import agy_subprocess as m
|
||
|
||
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
|
||
monkeypatch.delenv(var, raising=False)
|
||
monkeypatch.setattr(m.shutil, "which", lambda _n: None)
|
||
|
||
found, checked = m.detect_graphical_session()
|
||
|
||
assert found == {}
|
||
assert any("loginctl (не найден)" in item for item in checked)
|