Improve remote session management and Windows support
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from herdr_hub.aliases import (
|
||||
AliasRegistry,
|
||||
AliasRegistryError,
|
||||
MAX_ALIAS_LENGTH,
|
||||
normalize_alias,
|
||||
)
|
||||
|
||||
|
||||
class AliasRegistryTests(unittest.TestCase):
|
||||
def test_set_reset_round_trip_and_permissions(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config" / "aliases.json"
|
||||
registry = AliasRegistry(path)
|
||||
|
||||
self.assertEqual(registry.load(), {})
|
||||
self.assertTrue(registry.set("remote:orangepi:default", " 客厅主机 "))
|
||||
self.assertFalse(registry.set("remote:orangepi:default", "客厅主机"))
|
||||
self.assertEqual(
|
||||
registry.load(),
|
||||
{"remote:orangepi:default": "客厅主机"},
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assertTrue(registry.set("remote:orangepi:default", ""))
|
||||
self.assertEqual(registry.load(), {})
|
||||
|
||||
def test_invalid_aliases_and_corrupt_registry_are_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_alias("x" * (MAX_ALIAS_LENGTH + 1))
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_alias("bad\nname")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "aliases.json"
|
||||
path.write_text("not json", encoding="utf-8")
|
||||
registry = AliasRegistry(path)
|
||||
with self.assertRaises(AliasRegistryError):
|
||||
registry.set("local:default", "工作")
|
||||
self.assertEqual(path.read_text(encoding="utf-8"), "not json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from herdr_hub.aliases import AliasRegistry
|
||||
from herdr_hub.cli import TmuxHub
|
||||
|
||||
|
||||
@@ -29,6 +30,8 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
"orangepi",
|
||||
"--remote-keybindings",
|
||||
"server",
|
||||
"--transport",
|
||||
"ssh",
|
||||
],
|
||||
cwd=repository_root,
|
||||
env=environment,
|
||||
@@ -45,7 +48,7 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(listing.stdout.strip(), "orangepi\tserver")
|
||||
self.assertEqual(listing.stdout.strip(), "orangepi\tserver\tssh")
|
||||
remove = subprocess.run(
|
||||
[str(launcher), "remote", "remove", "orangepi"],
|
||||
cwd=repository_root,
|
||||
@@ -87,7 +90,9 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
"HERDR_HUB_SESSION": "hub",
|
||||
"HERDR_HUB_TMUX_CONFIG": "/dev/null",
|
||||
"HERDR_HUB_PROC_ROOT": str(proc_root),
|
||||
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
|
||||
}
|
||||
AliasRegistry(root / "aliases.json").set("local:default", "主工作")
|
||||
|
||||
refresh = subprocess.run(
|
||||
[str(launcher), "refresh"],
|
||||
@@ -118,7 +123,11 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
[window.key for window in hub.managed_windows()],
|
||||
["local:default"],
|
||||
)
|
||||
self.assertEqual(hub.managed_windows()[0].name, "主工作")
|
||||
|
||||
# Operational recovery must remain possible even if optional
|
||||
# display-name configuration is later corrupted.
|
||||
(root / "aliases.json").write_text("not json", encoding="utf-8")
|
||||
stop = subprocess.run(
|
||||
[str(launcher), "stop"],
|
||||
cwd=repository_root,
|
||||
@@ -131,6 +140,43 @@ class CliIntegrationTests(unittest.TestCase):
|
||||
self.assertIn("Herdr servers were left running", stop.stdout)
|
||||
self.assertFalse(hub.exists())
|
||||
|
||||
def test_empty_refresh_does_not_leave_a_bootstrap_hub(self) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
launcher = repository_root / "bin" / "herdr-hub"
|
||||
socket_name = f"herdr-hub-empty-test-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
proc_root = root / "proc"
|
||||
proc_root.mkdir()
|
||||
environment = {
|
||||
**os.environ,
|
||||
"HERDR_HUB_HERDR_BIN": "/bin/false",
|
||||
"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),
|
||||
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
|
||||
"HERDR_HUB_REMOTES_FILE": str(root / "remotes.json"),
|
||||
}
|
||||
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)
|
||||
hub = TmuxHub(
|
||||
tmux_bin=shutil.which("tmux") or "tmux",
|
||||
socket_name=socket_name,
|
||||
session_name="hub",
|
||||
config_file="/dev/null",
|
||||
)
|
||||
self.addCleanup(hub.stop)
|
||||
self.assertFalse(hub.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+31
-1
@@ -39,12 +39,37 @@ class EndpointTests(unittest.TestCase):
|
||||
"work",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
Endpoint.remote(
|
||||
"ssh://me@windows:2222",
|
||||
"work",
|
||||
remote_transport="ssh",
|
||||
).command("/bin/herdr", "/bin/ssh"),
|
||||
[
|
||||
"/bin/ssh",
|
||||
"-tt",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
"-p",
|
||||
"2222",
|
||||
"-l",
|
||||
"me",
|
||||
"windows",
|
||||
"herdr --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])
|
||||
self.assertEqual(
|
||||
Endpoint.remote("orangepi").key,
|
||||
Endpoint.remote("orangepi", remote_transport="ssh").key,
|
||||
)
|
||||
|
||||
def test_short_labels_put_the_host_first_and_omit_default(self) -> None:
|
||||
self.assertEqual(Endpoint.local().short_label, "local")
|
||||
@@ -67,7 +92,12 @@ class EndpointTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_endpoint_metadata_and_legacy_command_round_trip(self) -> None:
|
||||
endpoint = Endpoint.remote("ssh://me@host:2222", "work", "server")
|
||||
endpoint = Endpoint.remote(
|
||||
"ssh://me@host:2222",
|
||||
"work",
|
||||
"server",
|
||||
"ssh",
|
||||
)
|
||||
self.assertEqual(deserialize_endpoint(serialize_endpoint(endpoint)), endpoint)
|
||||
self.assertEqual(
|
||||
deserialize_endpoint(
|
||||
|
||||
+181
-3
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
@@ -7,14 +8,21 @@ import time
|
||||
import unittest
|
||||
|
||||
from herdr_hub.remotes import (
|
||||
REMOTE_DIRECT_COMMAND,
|
||||
REMOTE_LIST_SCRIPT,
|
||||
RegistryError,
|
||||
RemoteEntry,
|
||||
RemoteQueryPool,
|
||||
RemoteRegistry,
|
||||
RemoteTargetError,
|
||||
delete_remote_session,
|
||||
decode_remote_output,
|
||||
parse_remote_session_result,
|
||||
remote_delete_session_command,
|
||||
remote_session_command,
|
||||
remote_stop_session_command,
|
||||
ssh_destination_arguments,
|
||||
stop_remote_session,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,15 +34,31 @@ class RemoteRegistryTests(unittest.TestCase):
|
||||
self.assertEqual(registry.load(), [])
|
||||
self.assertTrue(registry.upsert(RemoteEntry("orangepi")))
|
||||
self.assertFalse(registry.upsert(RemoteEntry("orangepi")))
|
||||
self.assertTrue(registry.upsert(RemoteEntry("prod", "server")))
|
||||
self.assertTrue(registry.upsert(RemoteEntry("prod", "server", "ssh")))
|
||||
self.assertEqual(
|
||||
registry.load(),
|
||||
[RemoteEntry("orangepi"), RemoteEntry("prod", "server")],
|
||||
[RemoteEntry("orangepi"), RemoteEntry("prod", "server", "ssh")],
|
||||
)
|
||||
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")])
|
||||
self.assertEqual(
|
||||
registry.load(),
|
||||
[RemoteEntry("prod", "server", "ssh")],
|
||||
)
|
||||
|
||||
def test_discovery_does_not_overwrite_preferences_and_ssh_is_sticky(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
registry.upsert(RemoteEntry("host", "server"))
|
||||
registry.remember_many([RemoteEntry("host", "local")])
|
||||
self.assertEqual(registry.load(), [RemoteEntry("host", "server")])
|
||||
|
||||
registry.remember_many([RemoteEntry("host", "local", "ssh")])
|
||||
self.assertEqual(
|
||||
registry.load(),
|
||||
[RemoteEntry("host", "server", "ssh")],
|
||||
)
|
||||
|
||||
def test_corrupt_registry_is_not_overwritten(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -45,6 +69,25 @@ class RemoteRegistryTests(unittest.TestCase):
|
||||
registry.upsert(RemoteEntry("orangepi"))
|
||||
self.assertEqual(path.read_text(encoding="utf-8"), "not json")
|
||||
|
||||
def test_legacy_registry_defaults_to_native_transport(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "remotes.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"remotes": [
|
||||
{"target": "legacy", "remote_keybindings": "server"}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.assertEqual(
|
||||
RemoteRegistry(path).load(),
|
||||
[RemoteEntry("legacy", "server", "native")],
|
||||
)
|
||||
|
||||
|
||||
class RemoteSshTests(unittest.TestCase):
|
||||
def test_alias_and_url_targets_become_safe_ssh_arguments(self) -> None:
|
||||
@@ -56,12 +99,93 @@ class RemoteSshTests(unittest.TestCase):
|
||||
command = remote_session_command("/usr/bin/ssh", RemoteEntry("host name"))
|
||||
self.assertEqual(command[0], "/usr/bin/ssh")
|
||||
self.assertIn("host name", command)
|
||||
self.assertEqual(command[-1], REMOTE_DIRECT_COMMAND)
|
||||
fallback = remote_session_command(
|
||||
"/usr/bin/ssh",
|
||||
RemoteEntry("orangepi"),
|
||||
posix_fallback=True,
|
||||
)
|
||||
self.assertEqual(fallback[-1], REMOTE_LIST_SCRIPT)
|
||||
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_delete_stopped_remote_session_uses_safe_command_and_posix_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake_ssh = Path(directory) / "ssh"
|
||||
fake_ssh.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"last=''\n"
|
||||
"for argument do last=$argument; done\n"
|
||||
"case \"$last\" in\n"
|
||||
" *\"if [ -x\"*)\n"
|
||||
" printf '%s\\n' '{\"deleted\":true}'\n"
|
||||
" exit 0\n"
|
||||
" ;;\n"
|
||||
"esac\n"
|
||||
"exit 127\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_ssh.chmod(0o755)
|
||||
delete_remote_session(
|
||||
str(fake_ssh),
|
||||
RemoteEntry("orangepi"),
|
||||
"old-session",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
command = remote_delete_session_command(
|
||||
"/usr/bin/ssh",
|
||||
RemoteEntry("orangepi"),
|
||||
"old-session",
|
||||
)
|
||||
self.assertEqual(
|
||||
command[-1],
|
||||
"herdr session delete --json -- old-session",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
remote_delete_session_command(
|
||||
"/usr/bin/ssh",
|
||||
RemoteEntry("orangepi"),
|
||||
"bad session",
|
||||
)
|
||||
|
||||
def test_stop_running_remote_session_uses_safe_command_and_posix_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake_ssh = Path(directory) / "ssh"
|
||||
fake_ssh.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"last=''\n"
|
||||
"for argument do last=$argument; done\n"
|
||||
"case \"$last\" in\n"
|
||||
" *\"if [ -x\"*) exit 0 ;;\n"
|
||||
"esac\n"
|
||||
"exit 127\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_ssh.chmod(0o755)
|
||||
stop_remote_session(
|
||||
str(fake_ssh),
|
||||
RemoteEntry("orangepi"),
|
||||
"work",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
command = remote_stop_session_command(
|
||||
"/usr/bin/ssh",
|
||||
RemoteEntry("orangepi"),
|
||||
"work",
|
||||
)
|
||||
self.assertEqual(command[-1], "herdr session stop -- work")
|
||||
with self.assertRaises(ValueError):
|
||||
remote_stop_session_command(
|
||||
"/usr/bin/ssh",
|
||||
RemoteEntry("orangepi"),
|
||||
"bad session",
|
||||
)
|
||||
|
||||
def test_remote_session_json_keeps_running_and_stopped(self) -> None:
|
||||
result = parse_remote_session_result(
|
||||
RemoteEntry("orangepi"),
|
||||
@@ -77,6 +201,15 @@ class RemoteSshTests(unittest.TestCase):
|
||||
[("default", True), ("old", False)],
|
||||
)
|
||||
|
||||
windows = parse_remote_session_result(
|
||||
RemoteEntry("windows"),
|
||||
0,
|
||||
'{"sessions":[{"name":"default","running":true,'
|
||||
'"session_dir":"C:\\\\Users\\\\me\\\\AppData\\\\Roaming\\\\herdr"}]}',
|
||||
"",
|
||||
)
|
||||
self.assertEqual(windows.platform, "windows")
|
||||
|
||||
def test_query_pool_reports_results_and_spawn_failures(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
fake_ssh = Path(directory) / "ssh"
|
||||
@@ -103,6 +236,51 @@ class RemoteSshTests(unittest.TestCase):
|
||||
self.assertEqual(len(failure), 1)
|
||||
self.assertIn("cannot start", failure[0].error or "")
|
||||
|
||||
def test_query_pool_falls_back_for_posix_path_and_decodes_windows_errors(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
fallback_ssh = root / "fallback-ssh"
|
||||
fallback_ssh.write_text(
|
||||
"#!/bin/sh\n"
|
||||
"last=''\n"
|
||||
"for argument do last=$argument; done\n"
|
||||
"case \"$last\" in\n"
|
||||
" *\"if [ -x\"*)\n"
|
||||
" printf '%s\\n' '{\"sessions\":[{\"name\":\"work\",\"running\":true}]}'\n"
|
||||
" exit 0\n"
|
||||
" ;;\n"
|
||||
"esac\n"
|
||||
"exit 127\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fallback_ssh.chmod(0o755)
|
||||
pool = RemoteQueryPool(str(fallback_ssh), query_timeout=1)
|
||||
self.addCleanup(pool.close)
|
||||
pool.enqueue(RemoteEntry("linux-host"))
|
||||
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")
|
||||
|
||||
windows_ssh = root / "windows-ssh"
|
||||
windows_ssh.write_bytes(
|
||||
b"#!/bin/sh\nprintf '\\264\\355\\316\\363' >&2\nexit 1\n"
|
||||
)
|
||||
windows_ssh.chmod(0o755)
|
||||
windows_pool = RemoteQueryPool(str(windows_ssh), query_timeout=1)
|
||||
self.addCleanup(windows_pool.close)
|
||||
windows_pool.enqueue(RemoteEntry("windows-host"))
|
||||
windows_results = []
|
||||
deadline = time.monotonic() + 2
|
||||
while not windows_results and time.monotonic() < deadline:
|
||||
windows_results = windows_pool.poll()
|
||||
time.sleep(0.01)
|
||||
self.assertIn("错误", windows_results[0].error or "")
|
||||
|
||||
self.assertEqual(decode_remote_output("错误".encode("gbk")), "错误")
|
||||
|
||||
def test_query_pool_times_out_without_blocking_other_results(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
slow_ssh = Path(directory) / "ssh"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import curses
|
||||
from dataclasses import replace
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
@@ -8,19 +10,42 @@ import signal
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from herdr_hub.remotes import RemoteEntry, RemoteQueryResult, RemoteRegistry, RemoteSession
|
||||
from herdr_hub.selector import (
|
||||
CatalogWindow,
|
||||
DeleteSessionOutcome,
|
||||
PickerRow,
|
||||
SessionSelector,
|
||||
SelectorResult,
|
||||
build_main_rows,
|
||||
filter_rows,
|
||||
input_display_line,
|
||||
pad_terminal_text,
|
||||
terminal_display_width,
|
||||
truncate_terminal_text,
|
||||
)
|
||||
|
||||
|
||||
def wait_for_action(selector: SessionSelector) -> None:
|
||||
deadline = time.monotonic() + 2
|
||||
while selector.action_future is not None and time.monotonic() < deadline:
|
||||
selector._poll_action() # type: ignore[attr-defined]
|
||||
time.sleep(0.01)
|
||||
if selector.action_future is not None:
|
||||
raise AssertionError("selector action did not finish")
|
||||
|
||||
|
||||
class SelectorCatalogTests(unittest.TestCase):
|
||||
def test_terminal_display_width_counts_cjk_and_combining_characters(self) -> None:
|
||||
self.assertEqual(terminal_display_width("Rename: work"), 12)
|
||||
self.assertEqual(terminal_display_width("显示名称: 工作"), 14)
|
||||
self.assertEqual(terminal_display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
|
||||
self.assertEqual(truncate_terminal_text("主机abc", 5), "主机a")
|
||||
self.assertEqual(terminal_display_width(pad_terminal_text("主机", 6)), 6)
|
||||
self.assertEqual(input_display_line("名称", "abcdefgh", 9), "名称: …gh")
|
||||
|
||||
def test_rows_merge_open_running_stopped_and_new_sessions(self) -> None:
|
||||
windows = [
|
||||
CatalogWindow(
|
||||
@@ -31,6 +56,7 @@ class SelectorCatalogTests(unittest.TestCase):
|
||||
True,
|
||||
kind="local",
|
||||
session="default",
|
||||
default_label="L default",
|
||||
),
|
||||
CatalogWindow(
|
||||
"@2",
|
||||
@@ -41,6 +67,7 @@ class SelectorCatalogTests(unittest.TestCase):
|
||||
kind="remote",
|
||||
target="orangepi",
|
||||
session="work",
|
||||
default_label="R orangepi/work",
|
||||
),
|
||||
]
|
||||
result = RemoteQueryResult(
|
||||
@@ -63,9 +90,13 @@ class SelectorCatalogTests(unittest.TestCase):
|
||||
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.assertTrue(by_key["remote:orangepi:old"].deletable)
|
||||
self.assertIn("d 删除", by_key["remote:orangepi:old"].detail)
|
||||
self.assertFalse(by_key["remote:orangepi:default"].deletable)
|
||||
self.assertIn("new:orangepi", by_key)
|
||||
self.assertEqual(by_key["local:draft"].main, "○ draft")
|
||||
self.assertEqual(by_key["close-active"].window_id, "@1")
|
||||
self.assertIn("manage-sessions", by_key)
|
||||
|
||||
def test_loading_error_and_filter_rows(self) -> None:
|
||||
entry = RemoteEntry("prod")
|
||||
@@ -81,8 +112,288 @@ class SelectorCatalogTests(unittest.TestCase):
|
||||
matches = filter_rows(failed, "offline")
|
||||
self.assertEqual([row.kind for row in matches], ["retry"])
|
||||
|
||||
def test_active_remote_session_has_close_and_stop_action(self) -> None:
|
||||
local = CatalogWindow(
|
||||
"@1",
|
||||
"local:default",
|
||||
"L default",
|
||||
False,
|
||||
False,
|
||||
kind="local",
|
||||
session="default",
|
||||
)
|
||||
remote = CatalogWindow(
|
||||
"@2",
|
||||
"remote:orangepi:work",
|
||||
"R orangepi/work",
|
||||
False,
|
||||
True,
|
||||
kind="remote",
|
||||
target="orangepi",
|
||||
session="work",
|
||||
)
|
||||
rows = build_main_rows(
|
||||
[local, remote],
|
||||
[RemoteEntry("orangepi")],
|
||||
{},
|
||||
{"orangepi"},
|
||||
)
|
||||
stop = next(row for row in rows if row.kind == "stop-session")
|
||||
self.assertTrue(stop.selectable)
|
||||
self.assertEqual(stop.window_id, "@2")
|
||||
self.assertIn("任务会中断", stop.detail)
|
||||
|
||||
last_tab_rows = build_main_rows(
|
||||
[remote],
|
||||
[RemoteEntry("orangepi")],
|
||||
{},
|
||||
{"orangepi"},
|
||||
)
|
||||
last_stop = next(
|
||||
row for row in last_tab_rows if row.kind == "stop-session"
|
||||
)
|
||||
self.assertTrue(last_stop.selectable)
|
||||
self.assertIn("Tab 将保留", last_stop.detail)
|
||||
|
||||
def test_windows_remote_migrates_to_ssh_transport(self) -> None:
|
||||
window = CatalogWindow(
|
||||
"@4",
|
||||
"remote:windows:default",
|
||||
"R windows/default",
|
||||
True,
|
||||
False,
|
||||
kind="remote",
|
||||
target="windows",
|
||||
session="default",
|
||||
remote_transport="native",
|
||||
)
|
||||
result = RemoteQueryResult(
|
||||
RemoteEntry("windows"),
|
||||
sessions=(RemoteSession("default", True),),
|
||||
platform="windows",
|
||||
)
|
||||
rows = build_main_rows(
|
||||
[window],
|
||||
[RemoteEntry("windows")],
|
||||
{"windows": result},
|
||||
set(),
|
||||
)
|
||||
remote = next(row for row in rows if row.kind == "remote")
|
||||
self.assertEqual(remote.remote_transport, "ssh")
|
||||
self.assertIn("切换为 SSH 模式", remote.detail)
|
||||
|
||||
def test_existing_ssh_window_is_never_downgraded_while_query_is_unknown(self) -> None:
|
||||
entry = RemoteEntry("windows")
|
||||
window = CatalogWindow(
|
||||
"@4",
|
||||
"remote:windows:default",
|
||||
"windows",
|
||||
False,
|
||||
True,
|
||||
kind="remote",
|
||||
target="windows",
|
||||
session="default",
|
||||
remote_transport="ssh",
|
||||
)
|
||||
for results, loading in [
|
||||
({}, {"windows"}),
|
||||
({"windows": RemoteQueryResult(entry, error="offline")}, set()),
|
||||
({"windows": RemoteQueryResult(entry)}, set()),
|
||||
]:
|
||||
rows = build_main_rows([window], [entry], results, loading)
|
||||
row = next(item for item in rows if item.key == "window:@4")
|
||||
self.assertEqual(row.kind, "window")
|
||||
|
||||
def test_explicit_ssh_entry_supports_an_empty_windows_host(self) -> None:
|
||||
entry = RemoteEntry("windows", remote_transport="ssh")
|
||||
rows = build_main_rows(
|
||||
[],
|
||||
[entry],
|
||||
{"windows": RemoteQueryResult(entry)},
|
||||
set(),
|
||||
)
|
||||
new_row = next(item for item in rows if item.kind == "new")
|
||||
self.assertEqual(new_row.remote_transport, "ssh")
|
||||
|
||||
def test_windows_new_session_keeps_the_row_transport(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
entry = RemoteEntry("windows")
|
||||
registry.upsert(entry)
|
||||
selector = SessionSelector([], registry, "/missing/ssh")
|
||||
self.addCleanup(selector.pool.close)
|
||||
selector.results["windows"] = RemoteQueryResult(
|
||||
entry,
|
||||
sessions=(RemoteSession("default", True),),
|
||||
platform="windows",
|
||||
)
|
||||
new_row = next(
|
||||
item
|
||||
for item in selector._rows() # type: ignore[attr-defined]
|
||||
if item.kind == "new"
|
||||
)
|
||||
selector._activate(new_row) # type: ignore[attr-defined]
|
||||
result = selector._finish_new("work") # type: ignore[attr-defined]
|
||||
self.assertEqual(
|
||||
result,
|
||||
SelectorResult.remote("windows", "work", "local", "ssh"),
|
||||
)
|
||||
|
||||
|
||||
class SelectorActionTests(unittest.TestCase):
|
||||
def test_remote_session_can_be_stopped_and_closed_without_closing_popup(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
registry.upsert(RemoteEntry("orangepi"))
|
||||
local = CatalogWindow(
|
||||
"@1",
|
||||
"local:default",
|
||||
"L default",
|
||||
False,
|
||||
False,
|
||||
kind="local",
|
||||
session="default",
|
||||
)
|
||||
remote = CatalogWindow(
|
||||
"@2",
|
||||
"remote:orangepi:work",
|
||||
"R orangepi/work",
|
||||
False,
|
||||
True,
|
||||
kind="remote",
|
||||
target="orangepi",
|
||||
session="work",
|
||||
)
|
||||
stopped: list[tuple[str, str, str]] = []
|
||||
|
||||
def stop_handler(
|
||||
window_id: str,
|
||||
target: str,
|
||||
session: str,
|
||||
) -> list[CatalogWindow]:
|
||||
stopped.append((window_id, target, session))
|
||||
return [replace(local, active=True)]
|
||||
|
||||
selector = SessionSelector(
|
||||
[local, remote],
|
||||
registry,
|
||||
str(Path(directory) / "missing-ssh"),
|
||||
stop_session_handler=stop_handler,
|
||||
)
|
||||
self.addCleanup(selector.pool.close)
|
||||
self.addCleanup(selector.action_executor.shutdown, wait=True)
|
||||
selector.results["orangepi"] = RemoteQueryResult(
|
||||
RemoteEntry("orangepi"),
|
||||
sessions=(RemoteSession("work", True),),
|
||||
)
|
||||
|
||||
stop_row = next(
|
||||
row
|
||||
for row in selector._rows() # type: ignore[attr-defined]
|
||||
if row.kind == "stop-session"
|
||||
)
|
||||
selector._activate(stop_row) # type: ignore[attr-defined]
|
||||
self.assertEqual(selector.view, "confirm-stop-session")
|
||||
confirm = next(
|
||||
row
|
||||
for row in selector._rows() # type: ignore[attr-defined]
|
||||
if row.kind == "stop-session-yes"
|
||||
)
|
||||
selector._activate(confirm) # type: ignore[attr-defined]
|
||||
self.assertEqual(selector.view, "working-stop-session")
|
||||
wait_for_action(selector)
|
||||
|
||||
self.assertEqual(stopped, [("@2", "orangepi", "work")])
|
||||
self.assertEqual(selector.view, "main")
|
||||
self.assertEqual([window.window_id for window in selector.windows], ["@1"])
|
||||
self.assertFalse(selector.results["orangepi"].sessions[0].running)
|
||||
self.assertIn("已关闭 Tab 并停止", selector.message)
|
||||
|
||||
def test_stopped_remote_session_can_be_deleted_without_closing_popup(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
registry.upsert(RemoteEntry("orangepi"))
|
||||
deleted: list[tuple[str, str]] = []
|
||||
selector = SessionSelector(
|
||||
[],
|
||||
registry,
|
||||
str(Path(directory) / "missing-ssh"),
|
||||
delete_session_handler=lambda target, session: deleted.append(
|
||||
(target, session)
|
||||
),
|
||||
)
|
||||
self.addCleanup(selector.pool.close)
|
||||
self.addCleanup(selector.action_executor.shutdown, wait=True)
|
||||
selector.results["orangepi"] = RemoteQueryResult(
|
||||
RemoteEntry("orangepi"),
|
||||
sessions=(
|
||||
RemoteSession("default", True),
|
||||
RemoteSession("old", False),
|
||||
),
|
||||
)
|
||||
|
||||
rows = selector._rows() # type: ignore[attr-defined]
|
||||
running = next(
|
||||
row for row in rows if row.key == "remote:orangepi:default"
|
||||
)
|
||||
selector.selected_key = running.key
|
||||
selector._handle_key("d", rows) # type: ignore[attr-defined]
|
||||
self.assertEqual(selector.view, "main")
|
||||
self.assertEqual(deleted, [])
|
||||
|
||||
selector.query = ""
|
||||
old = next(row for row in rows if row.key == "remote:orangepi:old")
|
||||
selector.selected_key = old.key
|
||||
selector._handle_key("d", rows) # type: ignore[attr-defined]
|
||||
self.assertEqual(selector.view, "confirm-delete-session")
|
||||
confirm = next(
|
||||
row
|
||||
for row in selector._rows() # type: ignore[attr-defined]
|
||||
if row.kind == "delete-session-yes"
|
||||
)
|
||||
selector._drawn_rows[6] = confirm # type: ignore[attr-defined]
|
||||
with patch(
|
||||
"herdr_hub.selector.curses.getmouse",
|
||||
return_value=(0, 0, 6, 0, curses.BUTTON1_RELEASED),
|
||||
):
|
||||
selector._handle_mouse( # type: ignore[attr-defined]
|
||||
selector._rows() # type: ignore[attr-defined]
|
||||
)
|
||||
self.assertEqual(selector.view, "working-delete-session")
|
||||
wait_for_action(selector)
|
||||
|
||||
self.assertEqual(deleted, [("orangepi", "old")])
|
||||
self.assertEqual(selector.view, "main")
|
||||
self.assertIn("已删除历史 Session", selector.message)
|
||||
self.assertNotIn(
|
||||
"old",
|
||||
[item.name for item in selector.results["orangepi"].sessions],
|
||||
)
|
||||
|
||||
def test_delete_partial_success_is_reported_as_warning_not_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
registry.upsert(RemoteEntry("host"))
|
||||
selector = SessionSelector(
|
||||
[],
|
||||
registry,
|
||||
"/missing/ssh",
|
||||
delete_session_handler=lambda _target, _session: DeleteSessionOutcome(
|
||||
"显示名称清理失败"
|
||||
),
|
||||
)
|
||||
self.addCleanup(selector.pool.close)
|
||||
self.addCleanup(selector.action_executor.shutdown, wait=True)
|
||||
selector.results["host"] = RemoteQueryResult(
|
||||
RemoteEntry("host"),
|
||||
sessions=(RemoteSession("old", False),),
|
||||
)
|
||||
selector._finish_delete_session("host", "old") # type: ignore[attr-defined]
|
||||
wait_for_action(selector)
|
||||
self.assertEqual(selector.view, "main")
|
||||
self.assertIn("已删除历史 Session", selector.message)
|
||||
self.assertIn("显示名称清理失败", selector.message)
|
||||
|
||||
def test_dead_window_reconnect_action_and_new_session_action(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||||
@@ -108,6 +419,74 @@ class SelectorActionTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(local_result, SelectorResult.local("draft"))
|
||||
|
||||
selector = SessionSelector(
|
||||
[
|
||||
CatalogWindow(
|
||||
"@3",
|
||||
"local:default",
|
||||
"日常工作",
|
||||
False,
|
||||
True,
|
||||
kind="local",
|
||||
session="default",
|
||||
default_label="L default",
|
||||
)
|
||||
],
|
||||
registry,
|
||||
str(Path(directory) / "missing-ssh"),
|
||||
aliases={"local:default": "日常工作"},
|
||||
)
|
||||
self.addCleanup(selector.pool.close)
|
||||
selector._activate( # type: ignore[attr-defined]
|
||||
next(
|
||||
row
|
||||
for row in selector._rows() # type: ignore[attr-defined]
|
||||
if row.kind == "manage-sessions"
|
||||
)
|
||||
)
|
||||
rename_row = next(
|
||||
row
|
||||
for row in selector._rows() # type: ignore[attr-defined]
|
||||
if row.kind == "rename"
|
||||
)
|
||||
selector._activate(rename_row) # type: ignore[attr-defined]
|
||||
self.assertEqual(selector.view, "input-rename")
|
||||
self.assertEqual(selector.input_value, "日常工作")
|
||||
self.assertEqual(
|
||||
selector._finish_rename(" 新名称 "), # type: ignore[attr-defined]
|
||||
SelectorResult.rename("local:default", "新名称"),
|
||||
)
|
||||
self.assertEqual(
|
||||
selector._finish_rename(""), # type: ignore[attr-defined]
|
||||
SelectorResult.rename("local:default", ""),
|
||||
)
|
||||
|
||||
renamed: list[tuple[str, str]] = []
|
||||
live_selector = SessionSelector(
|
||||
selector.windows,
|
||||
registry,
|
||||
str(Path(directory) / "missing-ssh"),
|
||||
aliases={"local:default": "日常工作"},
|
||||
rename_handler=lambda key, alias: renamed.append((key, alias)),
|
||||
)
|
||||
self.addCleanup(live_selector.pool.close)
|
||||
live_selector._show_manage_sessions() # type: ignore[attr-defined]
|
||||
live_selector._activate( # type: ignore[attr-defined]
|
||||
next(
|
||||
row
|
||||
for row in live_selector._rows() # type: ignore[attr-defined]
|
||||
if row.kind == "rename"
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
live_selector._finish_rename("连续改名") # type: ignore[attr-defined]
|
||||
)
|
||||
self.assertEqual(renamed, [("local:default", "连续改名")])
|
||||
self.assertEqual(live_selector.view, "manage-sessions")
|
||||
self.assertEqual(live_selector.selected_key, "rename:local:default")
|
||||
self.assertEqual(live_selector.aliases["local:default"], "连续改名")
|
||||
self.assertIn("已保存", live_selector.message)
|
||||
|
||||
selector.view = "input-new"
|
||||
selector.input_target = "orangepi"
|
||||
new_result = selector._finish_new("work") # type: ignore[attr-defined]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -33,12 +34,16 @@ class TmuxIntegrationTests(unittest.TestCase):
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.fake_herdr.chmod(0o755)
|
||||
self.fake_ssh = root / "ssh"
|
||||
self.fake_ssh.write_text("#!/bin/sh\nexec sleep 30\n", encoding="utf-8")
|
||||
self.fake_ssh.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",
|
||||
ssh_bin=str(self.fake_ssh),
|
||||
socket_name=self.socket_name,
|
||||
session_name="hub",
|
||||
config_file="/dev/null",
|
||||
@@ -86,6 +91,9 @@ class TmuxIntegrationTests(unittest.TestCase):
|
||||
status_right = self.hub.run(
|
||||
["show-options", "-gv", "status-right"]
|
||||
).stdout.strip()
|
||||
expanded_status_left = self.hub.run(
|
||||
["display-message", "-p", "#{E:status-left}"]
|
||||
).stdout.strip()
|
||||
dead_format = self.hub.run(
|
||||
["show-options", "-wgv", "remain-on-exit-format"]
|
||||
).stdout.strip()
|
||||
@@ -99,14 +107,21 @@ class TmuxIntegrationTests(unittest.TestCase):
|
||||
["list-keys", "-T", "root", "MouseDown1StatusLeft"]
|
||||
).stdout.strip()
|
||||
close_binding = self.hub.run(
|
||||
["list-keys", "-T", "prefix", "&"]
|
||||
["list-keys", "-T", "prefix", "q"]
|
||||
).stdout.strip()
|
||||
legacy_close_binding = self.hub.run(
|
||||
["list-keys", "-T", "prefix", "&"], check=False
|
||||
)
|
||||
uppercase_close_binding = self.hub.run(
|
||||
["list-keys", "-T", "prefix", "Q"], check=False
|
||||
)
|
||||
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.assertTrue(expanded_status_left.startswith("#[bold,fg=colour81]"))
|
||||
self.assertIn("C-a R", status_right)
|
||||
self.assertIn("连接已断开", dead_format)
|
||||
self.assertIn("Ctrl+A R", dead_format)
|
||||
@@ -116,8 +131,11 @@ class TmuxIntegrationTests(unittest.TestCase):
|
||||
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("-EE", status_left_binding)
|
||||
self.assertIn("confirm-before", close_binding)
|
||||
self.assertIn(" close --window ", close_binding)
|
||||
self.assertNotEqual(legacy_close_binding.returncode, 0)
|
||||
self.assertNotEqual(uppercase_close_binding.returncode, 0)
|
||||
|
||||
local_short_label = self.hub.run(
|
||||
[
|
||||
@@ -201,6 +219,87 @@ class TmuxIntegrationTests(unittest.TestCase):
|
||||
self.hub.reconnect(window.window_id)
|
||||
self.assertIsNotNone(self.hub.window_for_key("local:dead"))
|
||||
|
||||
def test_alias_is_applied_immediately_and_can_be_reset(self) -> None:
|
||||
self.hub.aliases["remote:orangepi:work"] = "客厅开发"
|
||||
self.hub.ensure_server()
|
||||
window = self.hub.create_window(
|
||||
Endpoint.remote("orangepi", "work"),
|
||||
str(self.fake_herdr),
|
||||
)
|
||||
self.hub.remove_bootstrap()
|
||||
|
||||
current = self.hub.window_for_key(window.key)
|
||||
assert current is not None
|
||||
self.assertEqual(current.name, "客厅开发")
|
||||
short_label = self.hub.run(
|
||||
[
|
||||
"show-options",
|
||||
"-wv",
|
||||
"-t",
|
||||
window.window_id,
|
||||
WINDOW_SHORT_LABEL_OPTION,
|
||||
]
|
||||
).stdout.strip()
|
||||
self.assertEqual(short_label, "客厅开发")
|
||||
|
||||
renamed = self.hub.apply_alias(window.key, "临时名称")
|
||||
assert renamed is not None
|
||||
self.assertEqual(renamed.name, "临时名称")
|
||||
restored = self.hub.apply_alias(window.key, "")
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.name, "R orangepi/work")
|
||||
|
||||
def test_failed_native_window_can_migrate_to_windows_ssh_transport(self) -> None:
|
||||
self.hub.ensure_server()
|
||||
native = self.hub.create_window(
|
||||
Endpoint.remote("windows"),
|
||||
str(self.fake_herdr),
|
||||
)
|
||||
self.hub.remove_bootstrap()
|
||||
|
||||
migrated = self.hub.activate_endpoint(
|
||||
Endpoint.remote("windows", remote_transport="ssh"),
|
||||
str(self.fake_herdr),
|
||||
)
|
||||
self.assertEqual(migrated.window_id, native.window_id)
|
||||
assert migrated.endpoint is not None
|
||||
self.assertEqual(migrated.endpoint.remote_transport, "ssh")
|
||||
command = self.hub.run(
|
||||
[
|
||||
"show-options",
|
||||
"-wv",
|
||||
"-t",
|
||||
native.window_id,
|
||||
"@herdr_hub_command",
|
||||
]
|
||||
).stdout.strip()
|
||||
self.assertIn(str(self.fake_ssh), command)
|
||||
self.assertIn("-tt", command)
|
||||
|
||||
def test_concurrent_open_of_the_same_endpoint_creates_one_window(self) -> None:
|
||||
self.hub.ensure_server()
|
||||
second_hub = TmuxHub(
|
||||
tmux_bin=shutil.which("tmux") or "tmux",
|
||||
ssh_bin=str(self.fake_ssh),
|
||||
socket_name=self.socket_name,
|
||||
session_name="hub",
|
||||
config_file="/dev/null",
|
||||
script_path="/dev/null",
|
||||
)
|
||||
endpoint = Endpoint.local("concurrent")
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
windows = list(
|
||||
executor.map(
|
||||
lambda hub: hub.activate_endpoint(endpoint, str(self.fake_herdr)),
|
||||
[self.hub, second_hub],
|
||||
)
|
||||
)
|
||||
self.assertEqual(windows[0].window_id, windows[1].window_id)
|
||||
self.assertEqual(
|
||||
[window.key for window in self.hub.managed_windows()],
|
||||
["local:concurrent"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user