Initial release of herdr session switcher
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Test suite for herdr-hub."""
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from herdr_hub.cli import TmuxHub
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
|
||||
class CliIntegrationTests(unittest.TestCase):
|
||||
def test_remote_registry_cli_round_trip(self) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
launcher = repository_root / "bin" / "herdr-hub"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HERDR_HUB_REMOTES_FILE": str(Path(directory) / "remotes.json"),
|
||||
}
|
||||
add = subprocess.run(
|
||||
[
|
||||
str(launcher),
|
||||
"remote",
|
||||
"add",
|
||||
"orangepi",
|
||||
"--remote-keybindings",
|
||||
"server",
|
||||
],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(add.returncode, 0, add.stderr)
|
||||
listing = subprocess.run(
|
||||
[str(launcher), "remote", "list"],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(listing.stdout.strip(), "orangepi\tserver")
|
||||
remove = subprocess.run(
|
||||
[str(launcher), "remote", "remove", "orangepi"],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(remove.returncode, 0, remove.stderr)
|
||||
self.assertIn("removed orangepi", remove.stdout)
|
||||
|
||||
def test_repository_launcher_refreshes_and_stops_isolated_hub(self) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
launcher = repository_root / "bin" / "herdr-hub"
|
||||
socket_name = f"herdr-hub-cli-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
socket_path = Path(
|
||||
os.environ.get("TMUX_TMPDIR", f"/tmp/tmux-{os.getuid()}")
|
||||
) / socket_name
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
proc_root = root / "proc"
|
||||
proc_root.mkdir()
|
||||
fake_herdr = root / "herdr"
|
||||
fake_herdr.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"if [ \"${1:-}\" = \"session\" ]; then\n"
|
||||
" printf '%s\\n' '{\"sessions\":[{\"name\":\"default\",\"running\":true}]}'\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"exec sleep 30\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_herdr.chmod(0o755)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HERDR_HUB_HERDR_BIN": str(fake_herdr),
|
||||
"HERDR_HUB_TMUX_BIN": shutil.which("tmux") or "tmux",
|
||||
"HERDR_HUB_SOCKET": socket_name,
|
||||
"HERDR_HUB_SESSION": "hub",
|
||||
"HERDR_HUB_TMUX_CONFIG": "/dev/null",
|
||||
"HERDR_HUB_PROC_ROOT": str(proc_root),
|
||||
}
|
||||
|
||||
refresh = subprocess.run(
|
||||
[str(launcher), "refresh"],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(refresh.returncode, 0, refresh.stderr)
|
||||
self.assertIn("added 1 session", refresh.stdout)
|
||||
|
||||
hub = TmuxHub(
|
||||
tmux_bin=shutil.which("tmux") or "tmux",
|
||||
socket_name=socket_name,
|
||||
session_name="hub",
|
||||
config_file="/dev/null",
|
||||
script_path=str(launcher),
|
||||
)
|
||||
|
||||
def cleanup_hub() -> None:
|
||||
if hub.exists():
|
||||
hub.stop()
|
||||
socket_path.unlink(missing_ok=True)
|
||||
|
||||
self.addCleanup(cleanup_hub)
|
||||
self.assertEqual(
|
||||
[window.key for window in hub.managed_windows()],
|
||||
["local:default"],
|
||||
)
|
||||
|
||||
stop = subprocess.run(
|
||||
[str(launcher), "stop"],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(stop.returncode, 0, stop.stderr)
|
||||
self.assertIn("Herdr servers were left running", stop.stdout)
|
||||
self.assertFalse(hub.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from herdr_hub.cli import (
|
||||
DEFAULT_SESSION_NAME,
|
||||
Endpoint,
|
||||
decode_cmdline,
|
||||
dedupe_endpoints,
|
||||
deserialize_endpoint,
|
||||
discover_local_sessions,
|
||||
discover_remote_processes,
|
||||
parse_remote_argv,
|
||||
serialize_endpoint,
|
||||
)
|
||||
|
||||
|
||||
class EndpointTests(unittest.TestCase):
|
||||
def test_local_and_remote_commands(self) -> None:
|
||||
self.assertEqual(Endpoint.local().command("herdr"), ["herdr"])
|
||||
self.assertEqual(
|
||||
Endpoint.local("work").command("/bin/herdr"),
|
||||
["/bin/herdr", "--session", "work"],
|
||||
)
|
||||
self.assertEqual(
|
||||
Endpoint.remote("orangepi", "work", "server").command("herdr"),
|
||||
[
|
||||
"herdr",
|
||||
"--remote",
|
||||
"orangepi",
|
||||
"--remote-keybindings",
|
||||
"server",
|
||||
"--session",
|
||||
"work",
|
||||
],
|
||||
)
|
||||
|
||||
def test_identity_ignores_remote_keybinding_mode(self) -> None:
|
||||
local = Endpoint.remote("orangepi", remote_keybindings="local")
|
||||
server = Endpoint.remote("orangepi", remote_keybindings="server")
|
||||
self.assertEqual(local.key, server.key)
|
||||
self.assertEqual(dedupe_endpoints([local, server]), [local])
|
||||
|
||||
def test_short_labels_put_the_host_first_and_omit_default(self) -> None:
|
||||
self.assertEqual(Endpoint.local().short_label, "local")
|
||||
self.assertEqual(Endpoint.local("work").short_label, "local/work")
|
||||
self.assertEqual(Endpoint.remote("orangepi").short_label, "orangepi")
|
||||
self.assertEqual(Endpoint.remote("orangepi", "work").short_label, "orangepi/work")
|
||||
|
||||
def test_default_local_sorts_first(self) -> None:
|
||||
endpoints = dedupe_endpoints(
|
||||
[
|
||||
Endpoint.remote("z"),
|
||||
Endpoint.local("work"),
|
||||
Endpoint.local(),
|
||||
Endpoint.remote("a"),
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
[endpoint.key for endpoint in endpoints],
|
||||
["local:default", "local:work", "remote:a:default", "remote:z:default"],
|
||||
)
|
||||
|
||||
def test_endpoint_metadata_and_legacy_command_round_trip(self) -> None:
|
||||
endpoint = Endpoint.remote("ssh://me@host:2222", "work", "server")
|
||||
self.assertEqual(deserialize_endpoint(serialize_endpoint(endpoint)), endpoint)
|
||||
self.assertEqual(
|
||||
deserialize_endpoint(
|
||||
"",
|
||||
"exec /usr/local/bin/herdr --remote orangepi --session work",
|
||||
),
|
||||
Endpoint.remote("orangepi", "work"),
|
||||
)
|
||||
|
||||
|
||||
class RemoteArgvTests(unittest.TestCase):
|
||||
def test_separate_arguments(self) -> None:
|
||||
endpoint = parse_remote_argv(
|
||||
[
|
||||
"/home/me/.local/bin/herdr",
|
||||
"--remote",
|
||||
"orangepi",
|
||||
"--session",
|
||||
"work",
|
||||
"--remote-keybindings",
|
||||
"server",
|
||||
]
|
||||
)
|
||||
self.assertEqual(endpoint, Endpoint.remote("orangepi", "work", "server"))
|
||||
|
||||
def test_equal_arguments_and_arbitrary_order(self) -> None:
|
||||
endpoint = parse_remote_argv(
|
||||
["herdr", "--session=work", "--remote=ssh://me@host:2222"]
|
||||
)
|
||||
self.assertEqual(endpoint, Endpoint.remote("ssh://me@host:2222", "work"))
|
||||
|
||||
def test_default_session(self) -> None:
|
||||
endpoint = parse_remote_argv(["herdr", "--remote", "orangepi"])
|
||||
self.assertIsNotNone(endpoint)
|
||||
assert endpoint is not None
|
||||
self.assertEqual(endpoint.session, DEFAULT_SESSION_NAME)
|
||||
|
||||
def test_non_herdr_and_malformed_commands_are_ignored(self) -> None:
|
||||
self.assertIsNone(parse_remote_argv(["bash", "-c", "herdr --remote host"]))
|
||||
self.assertIsNone(parse_remote_argv(["herdr", "--remote"]))
|
||||
self.assertIsNone(parse_remote_argv(["herdr", "--remote", "-bad"]))
|
||||
|
||||
def test_cmdline_is_nul_delimited(self) -> None:
|
||||
self.assertEqual(
|
||||
decode_cmdline(b"/bin/herdr\0--remote\0host name\0"),
|
||||
["/bin/herdr", "--remote", "host name"],
|
||||
)
|
||||
|
||||
|
||||
class DiscoveryTests(unittest.TestCase):
|
||||
def test_local_discovery_returns_only_running_sessions(self) -> None:
|
||||
payload = {
|
||||
"sessions": [
|
||||
{"name": "default", "running": True},
|
||||
{"name": "work", "running": True},
|
||||
{"name": "old", "running": False},
|
||||
]
|
||||
}
|
||||
|
||||
def fake_run(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
return subprocess.CompletedProcess(args[0], 0, json.dumps(payload), "")
|
||||
|
||||
report = discover_local_sessions("herdr", run=fake_run)
|
||||
self.assertEqual(
|
||||
[endpoint.key for endpoint in report.endpoints],
|
||||
["local:default", "local:work"],
|
||||
)
|
||||
self.assertEqual(report.warnings, [])
|
||||
|
||||
def test_invalid_local_json_is_a_warning(self) -> None:
|
||||
def fake_run(*args, **kwargs): # type: ignore[no-untyped-def]
|
||||
return subprocess.CompletedProcess(args[0], 0, "not json", "")
|
||||
|
||||
report = discover_local_sessions("herdr", run=fake_run)
|
||||
self.assertEqual(report.endpoints, [])
|
||||
self.assertEqual(len(report.warnings), 1)
|
||||
|
||||
def test_proc_discovery_reads_exact_argv_and_deduplicates(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
commands = {
|
||||
"101": ["/usr/bin/herdr", "--remote", "orangepi"],
|
||||
"102": ["herdr", "--remote=orangepi"],
|
||||
"103": ["herdr", "--remote", "prod", "--session", "work"],
|
||||
"104": ["bash", "-c", "herdr --remote ignored"],
|
||||
}
|
||||
for process_id, argv in commands.items():
|
||||
process = root / process_id
|
||||
process.mkdir()
|
||||
(process / "cmdline").write_bytes(
|
||||
b"\0".join(os.fsencode(argument) for argument in argv) + b"\0"
|
||||
)
|
||||
|
||||
report = discover_remote_processes(root, user_id=os.getuid())
|
||||
|
||||
self.assertEqual(
|
||||
[endpoint.key for endpoint in report.endpoints],
|
||||
["remote:orangepi:default", "remote:prod:work"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from herdr_hub.remotes import (
|
||||
RegistryError,
|
||||
RemoteEntry,
|
||||
RemoteQueryPool,
|
||||
RemoteRegistry,
|
||||
RemoteTargetError,
|
||||
parse_remote_session_result,
|
||||
remote_session_command,
|
||||
ssh_destination_arguments,
|
||||
)
|
||||
|
||||
|
||||
class RemoteRegistryTests(unittest.TestCase):
|
||||
def test_round_trip_update_remove_and_permissions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config" / "remotes.json"
|
||||
registry = RemoteRegistry(path)
|
||||
self.assertEqual(registry.load(), [])
|
||||
self.assertTrue(registry.upsert(RemoteEntry("orangepi")))
|
||||
self.assertFalse(registry.upsert(RemoteEntry("orangepi")))
|
||||
self.assertTrue(registry.upsert(RemoteEntry("prod", "server")))
|
||||
self.assertEqual(
|
||||
registry.load(),
|
||||
[RemoteEntry("orangepi"), RemoteEntry("prod", "server")],
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assertTrue(registry.remove("orangepi"))
|
||||
self.assertFalse(registry.remove("missing"))
|
||||
self.assertEqual(registry.load(), [RemoteEntry("prod", "server")])
|
||||
|
||||
def test_corrupt_registry_is_not_overwritten(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "remotes.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
registry = RemoteRegistry(path)
|
||||
with self.assertRaises(RegistryError):
|
||||
registry.upsert(RemoteEntry("orangepi"))
|
||||
self.assertEqual(path.read_text(encoding="utf-8"), "not json")
|
||||
|
||||
|
||||
class RemoteSshTests(unittest.TestCase):
|
||||
def test_alias_and_url_targets_become_safe_ssh_arguments(self) -> None:
|
||||
self.assertEqual(ssh_destination_arguments("orangepi"), ["orangepi"])
|
||||
self.assertEqual(
|
||||
ssh_destination_arguments("ssh://me@example.com:2222"),
|
||||
["-p", "2222", "-l", "me", "example.com"],
|
||||
)
|
||||
command = remote_session_command("/usr/bin/ssh", RemoteEntry("host name"))
|
||||
self.assertEqual(command[0], "/usr/bin/ssh")
|
||||
self.assertIn("host name", command)
|
||||
self.assertNotIn("sh", command[:1])
|
||||
with self.assertRaises(RemoteTargetError):
|
||||
ssh_destination_arguments("ssh://me:secret@example.com")
|
||||
with self.assertRaises(RemoteTargetError):
|
||||
ssh_destination_arguments("ssh://example.com/a/path")
|
||||
|
||||
def test_remote_session_json_keeps_running_and_stopped(self) -> None:
|
||||
result = parse_remote_session_result(
|
||||
RemoteEntry("orangepi"),
|
||||
0,
|
||||
'{"sessions":['
|
||||
'{"name":"old","running":false},'
|
||||
'{"name":"default","running":true}]}',
|
||||
"",
|
||||
)
|
||||
self.assertIsNone(result.error)
|
||||
self.assertEqual(
|
||||
[(session.name, session.running) for session in result.sessions],
|
||||
[("default", True), ("old", False)],
|
||||
)
|
||||
|
||||
def test_query_pool_reports_results_and_spawn_failures(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake_ssh = Path(directory) / "ssh"
|
||||
fake_ssh.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"printf '%s\\n' '{\"sessions\":[{\"name\":\"work\",\"running\":true}]}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_ssh.chmod(0o755)
|
||||
pool = RemoteQueryPool(str(fake_ssh), query_timeout=1)
|
||||
self.addCleanup(pool.close)
|
||||
pool.enqueue(RemoteEntry("orangepi"))
|
||||
results = []
|
||||
deadline = time.monotonic() + 2
|
||||
while not results and time.monotonic() < deadline:
|
||||
results = pool.poll()
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(results[0].sessions[0].name, "work")
|
||||
|
||||
missing = RemoteQueryPool(str(Path(directory) / "missing"))
|
||||
self.addCleanup(missing.close)
|
||||
missing.enqueue(RemoteEntry("orangepi"))
|
||||
failure = missing.poll()
|
||||
self.assertEqual(len(failure), 1)
|
||||
self.assertIn("cannot start", failure[0].error or "")
|
||||
|
||||
def test_query_pool_times_out_without_blocking_other_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
slow_ssh = Path(directory) / "ssh"
|
||||
slow_ssh.write_text("#!/bin/sh\nexec sleep 5\n", encoding="utf-8")
|
||||
slow_ssh.chmod(0o755)
|
||||
pool = RemoteQueryPool(str(slow_ssh), query_timeout=0.05)
|
||||
self.addCleanup(pool.close)
|
||||
pool.enqueue(RemoteEntry("slow"))
|
||||
time.sleep(0.08)
|
||||
results = pool.poll()
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn("timed out", results[0].error or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from herdr_hub.remotes import RemoteEntry, RemoteQueryResult, RemoteRegistry, RemoteSession
|
||||
from herdr_hub.selector import (
|
||||
CatalogWindow,
|
||||
PickerRow,
|
||||
SessionSelector,
|
||||
SelectorResult,
|
||||
build_main_rows,
|
||||
filter_rows,
|
||||
)
|
||||
|
||||
|
||||
class SelectorCatalogTests(unittest.TestCase):
|
||||
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
|
||||
windows = [
|
||||
CatalogWindow(
|
||||
"@1",
|
||||
"local:default",
|
||||
"L default",
|
||||
False,
|
||||
True,
|
||||
kind="local",
|
||||
session="default",
|
||||
),
|
||||
CatalogWindow(
|
||||
"@2",
|
||||
"remote:orangepi:work",
|
||||
"R orangepi/work",
|
||||
True,
|
||||
False,
|
||||
kind="remote",
|
||||
target="orangepi",
|
||||
session="work",
|
||||
),
|
||||
]
|
||||
result = RemoteQueryResult(
|
||||
RemoteEntry("orangepi"),
|
||||
sessions=(
|
||||
RemoteSession("default", True),
|
||||
RemoteSession("old", False),
|
||||
RemoteSession("work", True),
|
||||
),
|
||||
)
|
||||
rows = build_main_rows(
|
||||
windows,
|
||||
[RemoteEntry("orangepi")],
|
||||
{"orangepi": result},
|
||||
set(),
|
||||
local_sessions=["default", "draft"],
|
||||
)
|
||||
by_key = {row.key: row for row in rows}
|
||||
self.assertEqual(by_key["window:@1"].main, "▶ L default")
|
||||
self.assertEqual(by_key["window:@2"].main, "× R orangepi/work")
|
||||
self.assertIn("remote:orangepi:default", by_key)
|
||||
self.assertIn("remote:orangepi:old", by_key)
|
||||
self.assertIn("new:orangepi", by_key)
|
||||
self.assertEqual(by_key["local:draft"].main, "○ draft")
|
||||
self.assertEqual(by_key["close-active"].window_id, "@1")
|
||||
|
||||
def test_loading_error_and_filter_rows(self) -> None:
|
||||
entry = RemoteEntry("prod")
|
||||
loading = build_main_rows([], [entry], {}, {"prod"})
|
||||
self.assertTrue(any(row.kind == "loading" for row in loading))
|
||||
failed = build_main_rows(
|
||||
[],
|
||||
[entry],
|
||||
{"prod": RemoteQueryResult(entry, error="offline")},
|
||||
set(),
|
||||
)
|
||||
self.assertTrue(any(row.kind == "retry" for row in failed))
|
||||
matches = filter_rows(failed, "offline")
|
||||
self.assertEqual([row.kind for row in matches], ["retry"])
|
||||
|
||||
|
||||
class SelectorActionTests(unittest.TestCase):
|
||||
def test_dead_window_reconnect_action_and_new_session_action(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
registry.upsert(RemoteEntry("orangepi", "server"))
|
||||
selector = SessionSelector(
|
||||
[CatalogWindow("@2", "key", "remote", True, False)],
|
||||
registry,
|
||||
str(Path(directory) / "missing-ssh"),
|
||||
)
|
||||
self.addCleanup(selector.pool.close)
|
||||
window_result = selector._activate( # type: ignore[attr-defined]
|
||||
next(row for row in selector._rows() if row.kind == "window") # type: ignore[attr-defined]
|
||||
)
|
||||
self.assertEqual(window_result, SelectorResult.window("@2", dead=True))
|
||||
|
||||
close_result = selector._activate( # type: ignore[attr-defined]
|
||||
PickerRow("close", "close", "close", window_id="@2")
|
||||
)
|
||||
self.assertEqual(close_result, SelectorResult.close("@2"))
|
||||
|
||||
local_result = selector._activate( # type: ignore[attr-defined]
|
||||
PickerRow("local", "local", "local", session="draft")
|
||||
)
|
||||
self.assertEqual(local_result, SelectorResult.local("draft"))
|
||||
|
||||
selector.view = "input-new"
|
||||
selector.input_target = "orangepi"
|
||||
new_result = selector._finish_new("work") # type: ignore[attr-defined]
|
||||
self.assertEqual(
|
||||
new_result,
|
||||
SelectorResult.remote("orangepi", "work", "server"),
|
||||
)
|
||||
self.assertIsNone(selector._finish_new("bad name")) # type: ignore[attr-defined]
|
||||
|
||||
@unittest.skipUnless(hasattr(pty, "fork"), "a Unix PTY is required")
|
||||
def test_curses_selector_handles_single_click_and_esc(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pid, descriptor = pty.fork()
|
||||
if pid == 0:
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
selector = SessionSelector(
|
||||
[],
|
||||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||||
"/missing/ssh",
|
||||
)
|
||||
try:
|
||||
result = selector.run()
|
||||
os._exit(0 if result is None else 2)
|
||||
except BaseException:
|
||||
os._exit(3)
|
||||
|
||||
status: int | None = None
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
output = b""
|
||||
while time.monotonic() < deadline and b"Sessions" not in output:
|
||||
readable, _, _ = select.select([descriptor], [], [], 0.1)
|
||||
if readable:
|
||||
output += os.read(descriptor, 4096)
|
||||
self.assertIn(b"Sessions", output)
|
||||
# Empty registries render "+ 添加远端" as the first selectable
|
||||
# row at terminal row 5. A single SGR mouse click must activate it.
|
||||
os.write(descriptor, b"\x1b[<0;5;5M\x1b[<0;5;5m")
|
||||
opened_input = b""
|
||||
input_deadline = time.monotonic() + 2
|
||||
while time.monotonic() < input_deadline and b"SSH target" not in opened_input:
|
||||
readable, _, _ = select.select([descriptor], [], [], 0.1)
|
||||
if readable:
|
||||
opened_input += os.read(descriptor, 4096)
|
||||
self.assertIn(b"SSH target", opened_input)
|
||||
os.write(descriptor, b"\x1b\x1b")
|
||||
while time.monotonic() < deadline:
|
||||
completed, wait_status = os.waitpid(pid, os.WNOHANG)
|
||||
if completed == pid:
|
||||
status = wait_status
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertIsNotNone(status, "selector did not exit after Esc")
|
||||
assert status is not None
|
||||
self.assertTrue(os.WIFEXITED(status))
|
||||
self.assertEqual(os.WEXITSTATUS(status), 0)
|
||||
finally:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
if status is None:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
os.waitpid(pid, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from herdr_hub.cli import (
|
||||
BOOTSTRAP_WINDOW_NAME,
|
||||
Endpoint,
|
||||
HubError,
|
||||
TmuxHub,
|
||||
WINDOW_SHORT_LABEL_OPTION,
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("tmux"), "tmux is required")
|
||||
class TmuxIntegrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary_directory.cleanup)
|
||||
root = Path(self.temporary_directory.name)
|
||||
self.fake_herdr = root / "herdr"
|
||||
self.fake_herdr.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"if [ \"${1:-}\" = \"--session\" ] && [ \"${2:-}\" = \"dead\" ]; then\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"exec sleep 30\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.fake_herdr.chmod(0o755)
|
||||
self.socket_name = f"herdr-hub-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
self.socket_path = Path(
|
||||
os.environ.get("TMUX_TMPDIR", f"/tmp/tmux-{os.getuid()}")
|
||||
) / self.socket_name
|
||||
self.hub = TmuxHub(
|
||||
tmux_bin=shutil.which("tmux") or "tmux",
|
||||
socket_name=self.socket_name,
|
||||
session_name="hub",
|
||||
config_file="/dev/null",
|
||||
script_path="/dev/null",
|
||||
)
|
||||
self.addCleanup(self._stop_hub)
|
||||
|
||||
def _stop_hub(self) -> None:
|
||||
try:
|
||||
self.hub.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.socket_path.unlink(missing_ok=True)
|
||||
|
||||
def test_configures_isolated_server_and_adds_without_duplicates(self) -> None:
|
||||
self.assertTrue(self.hub.ensure_server())
|
||||
endpoints = [Endpoint.local(), Endpoint.remote("orangepi")]
|
||||
added = self.hub.add_missing(endpoints, str(self.fake_herdr))
|
||||
self.assertEqual(len(added), 2)
|
||||
self.assertEqual(self.hub.add_missing(endpoints, str(self.fake_herdr)), [])
|
||||
|
||||
windows = self.hub.managed_windows()
|
||||
self.assertEqual(
|
||||
{window.key for window in windows},
|
||||
{"local:default", "remote:orangepi:default"},
|
||||
)
|
||||
remote_window = next(
|
||||
window for window in windows if window.key == "remote:orangepi:default"
|
||||
)
|
||||
self.assertEqual(remote_window.endpoint, Endpoint.remote("orangepi"))
|
||||
all_names = self.hub.run(
|
||||
["list-windows", "-t", "hub", "-F", "#{window_name}"]
|
||||
).stdout.splitlines()
|
||||
self.assertNotIn(BOOTSTRAP_WINDOW_NAME, all_names)
|
||||
|
||||
prefix = self.hub.run(["show-options", "-gv", "prefix"]).stdout.strip()
|
||||
status = self.hub.run(["show-options", "-gv", "status"]).stdout.strip()
|
||||
position = self.hub.run(
|
||||
["show-options", "-gv", "status-position"]
|
||||
).stdout.strip()
|
||||
mouse = self.hub.run(["show-options", "-gv", "mouse"]).stdout.strip()
|
||||
clipboard = self.hub.run(
|
||||
["show-options", "-sv", "set-clipboard"]
|
||||
).stdout.strip()
|
||||
status_right = self.hub.run(
|
||||
["show-options", "-gv", "status-right"]
|
||||
).stdout.strip()
|
||||
dead_format = self.hub.run(
|
||||
["show-options", "-wgv", "remain-on-exit-format"]
|
||||
).stdout.strip()
|
||||
inactive_window_format = self.hub.run(
|
||||
["show-options", "-wgv", "window-status-format"]
|
||||
).stdout.strip()
|
||||
current_window_format = self.hub.run(
|
||||
["show-options", "-wgv", "window-status-current-format"]
|
||||
).stdout.strip()
|
||||
status_left_binding = self.hub.run(
|
||||
["list-keys", "-T", "root", "MouseDown1StatusLeft"]
|
||||
).stdout.strip()
|
||||
close_binding = self.hub.run(
|
||||
["list-keys", "-T", "prefix", "&"]
|
||||
).stdout.strip()
|
||||
self.assertEqual(prefix, "C-a")
|
||||
self.assertEqual(status, "on")
|
||||
self.assertEqual(position, "top")
|
||||
self.assertEqual(mouse, "on")
|
||||
self.assertEqual(clipboard, "on")
|
||||
self.assertIn("连接已断开", status_right)
|
||||
self.assertIn("C-a R", status_right)
|
||||
self.assertIn("连接已断开", dead_format)
|
||||
self.assertIn("Ctrl+A R", dead_format)
|
||||
self.assertIn("client_width", inactive_window_format)
|
||||
self.assertIn(WINDOW_SHORT_LABEL_OPTION, inactive_window_format)
|
||||
self.assertNotIn("#I", inactive_window_format)
|
||||
self.assertIn("client_width", current_window_format)
|
||||
self.assertIn(WINDOW_SHORT_LABEL_OPTION, current_window_format)
|
||||
self.assertIn("display-popup", status_left_binding)
|
||||
self.assertIn("confirm-before", close_binding)
|
||||
self.assertIn(" close --window ", close_binding)
|
||||
|
||||
local_short_label = self.hub.run(
|
||||
[
|
||||
"show-options",
|
||||
"-wv",
|
||||
"-t",
|
||||
next(window.window_id for window in windows if window.key == "local:default"),
|
||||
WINDOW_SHORT_LABEL_OPTION,
|
||||
]
|
||||
).stdout.strip()
|
||||
remote_short_label = self.hub.run(
|
||||
[
|
||||
"show-options",
|
||||
"-wv",
|
||||
"-t",
|
||||
remote_window.window_id,
|
||||
WINDOW_SHORT_LABEL_OPTION,
|
||||
]
|
||||
).stdout.strip()
|
||||
rendered_local_short_label = self.hub.run(
|
||||
[
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
next(window.window_id for window in windows if window.key == "local:default"),
|
||||
"#{=12:@herdr_hub_short_label}",
|
||||
]
|
||||
).stdout.strip()
|
||||
self.assertEqual(local_short_label, "local")
|
||||
self.assertEqual(remote_short_label, "orangepi")
|
||||
self.assertEqual(rendered_local_short_label, "local")
|
||||
|
||||
def test_dismissed_window_stays_hidden_until_explicitly_reopened(self) -> None:
|
||||
self.hub.ensure_server()
|
||||
endpoints = [Endpoint.local(), Endpoint.local("work")]
|
||||
self.hub.add_missing(endpoints, str(self.fake_herdr))
|
||||
|
||||
default_window = self.hub.window_for_key("local:default")
|
||||
assert default_window is not None
|
||||
self.hub.dismiss_window(default_window.window_id)
|
||||
self.assertEqual(self.hub.dismissed_keys(), {"local:default"})
|
||||
self.assertIsNone(self.hub.window_for_key("local:default"))
|
||||
self.assertEqual(self.hub.add_missing(endpoints, str(self.fake_herdr)), [])
|
||||
|
||||
reopened = self.hub.activate_endpoint(Endpoint.local(), str(self.fake_herdr))
|
||||
self.assertEqual(reopened.key, "local:default")
|
||||
self.assertEqual(self.hub.dismissed_keys(), set())
|
||||
|
||||
work_window = self.hub.window_for_key("local:work")
|
||||
assert work_window is not None
|
||||
self.hub.dismiss_window(work_window.window_id)
|
||||
with self.assertRaises(HubError):
|
||||
self.hub.dismiss_window(reopened.window_id)
|
||||
|
||||
def test_dead_window_is_retained_and_can_be_respawned(self) -> None:
|
||||
self.hub.ensure_server()
|
||||
window = self.hub.create_window(Endpoint.local("dead"), str(self.fake_herdr))
|
||||
self.hub.remove_bootstrap()
|
||||
|
||||
deadline = time.monotonic() + 3
|
||||
dead = False
|
||||
while time.monotonic() < deadline:
|
||||
current = self.hub.window_for_key("local:dead")
|
||||
dead = current.dead if current is not None else False
|
||||
if dead:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(dead)
|
||||
|
||||
rendered_status = self.hub.run(
|
||||
[
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
window.window_id,
|
||||
"#{?pane_dead,× #{window_name} [断开],● #{window_name}}",
|
||||
]
|
||||
).stdout.strip()
|
||||
self.assertEqual(rendered_status, f"× {window.name} [断开]")
|
||||
|
||||
self.hub.reconnect(window.window_id)
|
||||
self.assertIsNotNone(self.hub.window_for_key("local:dead"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user