Opening a window whose target was removed from the registry (its tab stays open by design) and failing landed in the action menu with a "重试连接" row that indexed self.entries directly and raised KeyError, killing the popup with a traceback. Only offer the retry action for registered targets, degrade a stale retry row to a message, and keep the popup alive when a handler raises anything unexpected instead of dying with a traceback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1050 lines
43 KiB
Python
1050 lines
43 KiB
Python
from __future__ import annotations
|
||
|
||
import curses
|
||
from dataclasses import replace
|
||
import os
|
||
from pathlib import Path
|
||
import pty
|
||
import select
|
||
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 FakeScreen:
|
||
def __init__(self, height: int = 12, width: int = 80) -> None:
|
||
self.height = height
|
||
self.width = width
|
||
self.writes: list[tuple[int, int, str]] = []
|
||
|
||
def erase(self) -> None:
|
||
self.writes.clear()
|
||
|
||
def getmaxyx(self) -> tuple[int, int]:
|
||
return self.height, self.width
|
||
|
||
def addstr(self, y: int, x: int, value: str, _attribute: int) -> None:
|
||
self.writes.append((y, x, value))
|
||
|
||
def move(self, _y: int, _x: int) -> None:
|
||
pass
|
||
|
||
def refresh(self) -> None:
|
||
pass
|
||
|
||
|
||
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(
|
||
"@1",
|
||
"local:default",
|
||
"L default",
|
||
False,
|
||
True,
|
||
kind="local",
|
||
session="default",
|
||
default_label="L default",
|
||
),
|
||
CatalogWindow(
|
||
"@2",
|
||
"remote:orangepi:work",
|
||
"R orangepi/work",
|
||
True,
|
||
False,
|
||
kind="remote",
|
||
target="orangepi",
|
||
session="work",
|
||
default_label="R orangepi/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, "▶ 本机")
|
||
self.assertEqual(by_key["window:@2"].main, "× 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.assertEqual(by_key["remote:orangepi:old"].detail, "已停止")
|
||
self.assertFalse(by_key["remote:orangepi:default"].deletable)
|
||
self.assertIn("new-session", by_key)
|
||
self.assertEqual(by_key["local:draft"].main, "○ 本机 / draft")
|
||
self.assertNotIn("close-active", by_key)
|
||
self.assertNotIn("manage-sessions", by_key)
|
||
|
||
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))
|
||
retry = next(row for row in failed if row.kind == "retry")
|
||
self.assertEqual(retry.main, "! prod")
|
||
self.assertEqual(retry.detail, "无法连接")
|
||
self.assertEqual(retry.error, "offline")
|
||
matches = filter_rows(failed, "offline")
|
||
self.assertEqual([row.kind for row in matches], ["retry"])
|
||
|
||
def test_stopped_remote_with_a_dead_tab_remains_deletable(self) -> None:
|
||
window = CatalogWindow(
|
||
"@2",
|
||
"remote:orangepi:old",
|
||
"orangepi/old",
|
||
True,
|
||
False,
|
||
kind="remote",
|
||
target="orangepi",
|
||
session="old",
|
||
)
|
||
result = RemoteQueryResult(
|
||
RemoteEntry("orangepi"),
|
||
sessions=(
|
||
RemoteSession("default", False),
|
||
RemoteSession("old", False),
|
||
),
|
||
)
|
||
|
||
rows = build_main_rows(
|
||
[window],
|
||
[RemoteEntry("orangepi")],
|
||
{"orangepi": result},
|
||
set(),
|
||
)
|
||
|
||
row = next(item for item in rows if item.key == "window:@2")
|
||
self.assertTrue(row.deletable)
|
||
self.assertEqual(row.window_id, "@2")
|
||
self.assertEqual(row.detail, "已停止")
|
||
default = next(
|
||
item for item in rows if item.key == "remote:orangepi:default"
|
||
)
|
||
self.assertFalse(default.deletable)
|
||
self.assertIn("default 保留项", default.detail)
|
||
|
||
def test_active_remote_session_has_close_and_stop_action(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",
|
||
)
|
||
selector = SessionSelector(
|
||
[local, remote],
|
||
registry,
|
||
str(Path(directory) / "missing-ssh"),
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
selector.results["orangepi"] = RemoteQueryResult(
|
||
RemoteEntry("orangepi"),
|
||
sessions=(RemoteSession("work", True),),
|
||
)
|
||
|
||
main_rows = selector._rows() # type: ignore[attr-defined]
|
||
self.assertFalse(any(row.kind == "stop-session" for row in main_rows))
|
||
remote_row = next(row for row in main_rows if row.key == "window:@2")
|
||
selector._show_actions(remote_row) # type: ignore[attr-defined]
|
||
actions = selector._rows() # type: ignore[attr-defined]
|
||
stop = next(row for row in actions if row.kind == "action-stop")
|
||
self.assertEqual(stop.window_id, "@2")
|
||
self.assertIn("中断", stop.detail)
|
||
self.assertTrue(any(row.kind == "action-close" for row in actions))
|
||
|
||
selector.windows = [remote]
|
||
actions = selector._rows() # type: ignore[attr-defined]
|
||
self.assertFalse(any(row.kind == "action-close" for row in actions))
|
||
self.assertTrue(any(row.kind == "action-stop" for row in actions))
|
||
|
||
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(),
|
||
)
|
||
empty_row = next(item for item in rows if item.kind == "remote-empty")
|
||
self.assertEqual(empty_row.remote_transport, "ssh")
|
||
self.assertTrue(any(item.kind == "new-session" for item in rows))
|
||
|
||
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_menu = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.kind == "new-session"
|
||
)
|
||
selector._activate(new_menu) # type: ignore[attr-defined]
|
||
new_row = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.kind == "action-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_open_failure_stays_in_context_and_retry_can_succeed(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
attempts: list[SelectorResult] = []
|
||
|
||
def activate(result: SelectorResult) -> None:
|
||
attempts.append(result)
|
||
if len(attempts) == 1:
|
||
raise RuntimeError(
|
||
"Bad owner or permissions on /etc/ssh/ssh_config"
|
||
)
|
||
|
||
selector = SessionSelector(
|
||
[
|
||
CatalogWindow(
|
||
"@1",
|
||
"remote:pi:default",
|
||
"pi",
|
||
True,
|
||
False,
|
||
kind="remote",
|
||
target="pi",
|
||
session="default",
|
||
)
|
||
],
|
||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||
"/missing/ssh",
|
||
activate_handler=activate,
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
row = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.key == "window:@1"
|
||
)
|
||
|
||
self.assertIsNone(selector._activate(row)) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "actions")
|
||
self.assertIn("打开失败", selector.message)
|
||
self.assertIn("ssh_config", selector.context_row.error)
|
||
retry = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.kind == "action-window"
|
||
)
|
||
self.assertEqual(selector.selected_key, retry.key)
|
||
|
||
self.assertFalse(selector._activate(retry)) # type: ignore[attr-defined]
|
||
self.assertEqual(len(attempts), 2)
|
||
|
||
def test_search_is_explicit_and_escape_preserves_selection(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
window = CatalogWindow(
|
||
"@1",
|
||
"local:default",
|
||
"L default",
|
||
False,
|
||
True,
|
||
kind="local",
|
||
session="default",
|
||
default_label="L default",
|
||
)
|
||
selector = SessionSelector(
|
||
[window],
|
||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||
"/missing/ssh",
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
rows = selector._rows() # type: ignore[attr-defined]
|
||
selector.selected_key = "window:@1"
|
||
|
||
selector._handle_key("d", rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertEqual(selector.query, "")
|
||
|
||
selector._handle_key("/", rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "search")
|
||
selector._handle_key("本", rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.query, "本")
|
||
selector.selected_key = "window:@1"
|
||
selector._handle_key("\x1b", rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertEqual(selector.query, "")
|
||
self.assertEqual(selector.selected_key, "window:@1")
|
||
|
||
def test_space_opens_actions_and_escape_returns_to_same_session(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
selector = SessionSelector(
|
||
[
|
||
CatalogWindow(
|
||
"@1",
|
||
"local:default",
|
||
"L default",
|
||
False,
|
||
True,
|
||
kind="local",
|
||
session="default",
|
||
)
|
||
],
|
||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||
"/missing/ssh",
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
rows = selector._rows() # type: ignore[attr-defined]
|
||
selector.selected_key = "window:@1"
|
||
|
||
selector._handle_key(" ", rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "actions")
|
||
self.assertEqual(selector.context_row.key, "window:@1")
|
||
selector._handle_key( # type: ignore[attr-defined]
|
||
"\x1b",
|
||
selector._rows(), # type: ignore[attr-defined]
|
||
)
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertEqual(selector.selected_key, "window:@1")
|
||
|
||
def test_confirmation_defaults_to_cancel_and_escape_returns_to_actions(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||
entry = RemoteEntry("offline-host")
|
||
registry.upsert(entry)
|
||
selector = SessionSelector([], registry, "/missing/ssh")
|
||
self.addCleanup(selector.pool.close)
|
||
selector.results[entry.target] = RemoteQueryResult(entry, error="offline")
|
||
source = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.kind == "retry"
|
||
)
|
||
selector._show_actions(source) # type: ignore[attr-defined]
|
||
remove = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.kind == "action-remove"
|
||
)
|
||
selector._activate(remove) # type: ignore[attr-defined]
|
||
|
||
self.assertEqual(selector.view, "confirm")
|
||
self.assertEqual(selector.selected_key, "confirm-no")
|
||
selector._handle_key( # type: ignore[attr-defined]
|
||
"\x1b",
|
||
selector._rows(), # type: ignore[attr-defined]
|
||
)
|
||
self.assertEqual(selector.view, "actions")
|
||
self.assertEqual(selector.context_row.key, source.key)
|
||
|
||
def test_close_tab_action_updates_list_without_closing_popup(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
first = CatalogWindow(
|
||
"@1", "local:default", "L default", False, True,
|
||
kind="local", session="default",
|
||
)
|
||
second = CatalogWindow(
|
||
"@2", "local:work", "L work", False, False,
|
||
kind="local", session="work",
|
||
)
|
||
closed: list[str] = []
|
||
|
||
def close_handler(window_id: str) -> list[CatalogWindow]:
|
||
closed.append(window_id)
|
||
return [first]
|
||
|
||
selector = SessionSelector(
|
||
[first, second],
|
||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||
"/missing/ssh",
|
||
close_window_handler=close_handler,
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
source = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.key == "window:@2"
|
||
)
|
||
selector._show_actions(source) # type: ignore[attr-defined]
|
||
close = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.kind == "action-close"
|
||
)
|
||
|
||
result = selector._activate(close) # type: ignore[attr-defined]
|
||
self.assertIsNone(result)
|
||
self.assertEqual(closed, ["@2"])
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertEqual(selector.windows, [first])
|
||
self.assertIn("已关闭 Hub Tab", selector.message)
|
||
|
||
def test_narrow_layout_prioritizes_name_and_row_menu_is_clickable(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
selector = SessionSelector(
|
||
[
|
||
CatalogWindow(
|
||
"@1",
|
||
"local:important-work",
|
||
"L important-work",
|
||
False,
|
||
False,
|
||
kind="local",
|
||
session="important-work",
|
||
)
|
||
],
|
||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||
"/missing/ssh",
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
rows = selector._rows() # type: ignore[attr-defined]
|
||
selector._normalize_selection(rows) # type: ignore[attr-defined]
|
||
screen = FakeScreen(width=32)
|
||
with patch("herdr_hub.selector.curses.curs_set"):
|
||
selector._draw(screen, rows) # type: ignore[attr-defined]
|
||
|
||
self.assertFalse(any("已打开" in value for _, _, value in screen.writes))
|
||
self.assertTrue(any("本机 / important" in value for _, _, value in screen.writes))
|
||
button_y, (button_start, _, source) = next(
|
||
iter(selector._row_action_buttons.items()) # type: ignore[attr-defined]
|
||
)
|
||
with patch(
|
||
"herdr_hub.selector.curses.getmouse",
|
||
return_value=(0, button_start, button_y, 0, curses.BUTTON1_RELEASED),
|
||
):
|
||
selector._handle_mouse(rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "actions")
|
||
self.assertEqual(selector.context_row.key, source.key)
|
||
|
||
def test_unreachable_remote_can_be_removed_from_its_action_menu(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||
entry = RemoteEntry("tang1@10.8.0.7")
|
||
registry.upsert(entry)
|
||
selector = SessionSelector([], registry, "/missing/ssh")
|
||
self.addCleanup(selector.pool.close)
|
||
self.addCleanup(selector.action_executor.shutdown, wait=True)
|
||
selector.results[entry.target] = RemoteQueryResult(
|
||
entry,
|
||
error="ssh: connect to host 10.8.0.7 port 22: Connection timed out",
|
||
)
|
||
|
||
rows = selector._rows() # type: ignore[attr-defined]
|
||
retry = next(item for item in rows if item.kind == "retry")
|
||
selector.selected_key = retry.key
|
||
selector._handle_key(" ", rows) # type: ignore[attr-defined]
|
||
|
||
self.assertEqual(selector.view, "actions")
|
||
actions = selector._rows() # type: ignore[attr-defined]
|
||
remove = next(item for item in actions if item.kind == "action-remove")
|
||
selector._activate(remove) # type: ignore[attr-defined]
|
||
|
||
self.assertEqual(selector.view, "confirm")
|
||
self.assertEqual(selector.confirm_kind, "remove")
|
||
self.assertEqual(selector.input_target, entry.target)
|
||
confirm_rows = selector._rows() # type: ignore[attr-defined]
|
||
confirm = next(item for item in confirm_rows if item.kind == "confirm-yes")
|
||
selector._activate(confirm) # type: ignore[attr-defined]
|
||
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertEqual(registry.load(), [])
|
||
self.assertNotIn(entry.target, selector.entries)
|
||
self.assertIn("已移除", selector.message)
|
||
|
||
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),),
|
||
)
|
||
|
||
remote_row = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.key == "window:@2"
|
||
)
|
||
selector._show_actions(remote_row) # type: ignore[attr-defined]
|
||
stop_row = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.kind == "action-stop"
|
||
)
|
||
selector._activate(stop_row) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "confirm")
|
||
self.assertEqual(selector.confirm_kind, "stop")
|
||
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")
|
||
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, str | None]] = []
|
||
selector = SessionSelector(
|
||
[],
|
||
registry,
|
||
str(Path(directory) / "missing-ssh"),
|
||
delete_session_handler=lambda target, session, window_id: deleted.append(
|
||
(target, session, window_id)
|
||
),
|
||
)
|
||
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(" ", rows) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "actions")
|
||
self.assertFalse(
|
||
any(
|
||
item.kind == "action-delete"
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
)
|
||
)
|
||
self.assertEqual(deleted, [])
|
||
self.assertEqual(selector.query, "")
|
||
selector._go_back() # type: ignore[attr-defined]
|
||
|
||
rows = selector._rows() # type: ignore[attr-defined]
|
||
old = next(row for row in rows if row.key == "remote:orangepi:old")
|
||
selector.selected_key = old.key
|
||
selector._handle_key(" ", rows) # type: ignore[attr-defined]
|
||
delete = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.kind == "action-delete"
|
||
)
|
||
selector._activate(delete) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "confirm")
|
||
self.assertEqual(selector.confirm_kind, "delete")
|
||
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")
|
||
wait_for_action(selector)
|
||
|
||
self.assertEqual(deleted, [("orangepi", "old", None)])
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertIn("已删除历史 Session", selector.message)
|
||
self.assertNotIn(
|
||
"old",
|
||
[item.name for item in selector.results["orangepi"].sessions],
|
||
)
|
||
|
||
def test_stopped_remote_session_with_tab_is_deleted_and_tab_is_closed(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
registry = RemoteRegistry(Path(directory) / "remotes.json")
|
||
registry.upsert(RemoteEntry("orangepi"))
|
||
local = CatalogWindow(
|
||
"@1",
|
||
"local:default",
|
||
"local",
|
||
False,
|
||
True,
|
||
kind="local",
|
||
session="default",
|
||
)
|
||
remote = CatalogWindow(
|
||
"@2",
|
||
"remote:orangepi:old",
|
||
"orangepi/old",
|
||
True,
|
||
False,
|
||
kind="remote",
|
||
target="orangepi",
|
||
session="old",
|
||
)
|
||
deleted: list[tuple[str, str, str | None]] = []
|
||
|
||
def delete_handler(
|
||
target: str,
|
||
session: str,
|
||
window_id: str | None,
|
||
) -> DeleteSessionOutcome:
|
||
deleted.append((target, session, window_id))
|
||
return DeleteSessionOutcome(
|
||
windows=(local,),
|
||
tab_closed=True,
|
||
)
|
||
|
||
selector = SessionSelector(
|
||
[local, remote],
|
||
registry,
|
||
str(Path(directory) / "missing-ssh"),
|
||
delete_session_handler=delete_handler,
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
self.addCleanup(selector.action_executor.shutdown, wait=True)
|
||
selector.results["orangepi"] = RemoteQueryResult(
|
||
RemoteEntry("orangepi"),
|
||
sessions=(RemoteSession("old", False),),
|
||
)
|
||
|
||
rows = selector._rows() # type: ignore[attr-defined]
|
||
row = next(item for item in rows if item.key == "window:@2")
|
||
selector.selected_key = row.key
|
||
selector._handle_key(" ", rows) # type: ignore[attr-defined]
|
||
delete = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.kind == "action-delete"
|
||
)
|
||
selector._activate(delete) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "confirm")
|
||
self.assertEqual(selector.confirm_kind, "delete")
|
||
confirm = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.kind == "delete-session-yes"
|
||
)
|
||
self.assertEqual(confirm.window_id, "@2")
|
||
selector._activate(confirm) # type: ignore[attr-defined]
|
||
wait_for_action(selector)
|
||
|
||
self.assertEqual(deleted, [("orangepi", "old", "@2")])
|
||
self.assertEqual(selector.windows, [local])
|
||
self.assertIn("已关闭 Tab 并删除", selector.message)
|
||
|
||
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, _window_id: (
|
||
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")
|
||
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", "action-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 = 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)
|
||
session_row = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.key == "window:@3"
|
||
)
|
||
selector._show_actions(session_row) # type: ignore[attr-defined]
|
||
rename_row = next(
|
||
row
|
||
for row in selector._rows() # type: ignore[attr-defined]
|
||
if row.kind == "action-rename"
|
||
)
|
||
selector._activate(rename_row) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "input")
|
||
self.assertEqual(selector.input_kind, "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_session_row = next(
|
||
row
|
||
for row in live_selector._rows() # type: ignore[attr-defined]
|
||
if row.key == "window:@3"
|
||
)
|
||
live_selector._show_actions(live_session_row) # 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 == "action-rename"
|
||
)
|
||
)
|
||
self.assertIsNone(
|
||
live_selector._finish_rename("连续改名") # type: ignore[attr-defined]
|
||
)
|
||
self.assertEqual(renamed, [("local:default", "连续改名")])
|
||
self.assertEqual(live_selector.view, "main")
|
||
self.assertEqual(live_selector.selected_key, "window:@3")
|
||
self.assertEqual(live_selector.aliases["local:default"], "连续改名")
|
||
self.assertIn("已保存", live_selector.message)
|
||
|
||
selector.view = "input"
|
||
selector.input_kind = "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. It opens one small connection-mode menu.
|
||
os.write(descriptor, b"\x1b[<0;5;5M\x1b[<0;5;5m")
|
||
mode_menu = b""
|
||
mode_deadline = time.monotonic() + 2
|
||
while time.monotonic() < mode_deadline and b"Linux / macOS" not in mode_menu:
|
||
readable, _, _ = select.select([descriptor], [], [], 0.1)
|
||
if readable:
|
||
mode_menu += os.read(descriptor, 4096)
|
||
self.assertIn(b"Linux / macOS", mode_menu)
|
||
os.write(descriptor, b"\x1b[<0;5;6M\x1b[<0;5;6m")
|
||
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\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)
|
||
|
||
|
||
class SelectorRobustnessTests(unittest.TestCase):
|
||
def test_retry_is_not_offered_for_unregistered_remote_and_never_crashes(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
|
||
def activate(result: SelectorResult) -> None:
|
||
raise RuntimeError("ssh: connect to host orangepi port 22: No route to host")
|
||
|
||
# The registry is empty: this window's target was removed via
|
||
# "移除远端记录" while its tab stayed open.
|
||
window = CatalogWindow(
|
||
"@7",
|
||
"remote:orangepi:default",
|
||
"R orangepi/default",
|
||
True,
|
||
False,
|
||
kind="remote",
|
||
target="orangepi",
|
||
session="default",
|
||
default_label="R orangepi/default",
|
||
)
|
||
selector = SessionSelector(
|
||
[window],
|
||
RemoteRegistry(Path(directory) / "remotes.json"),
|
||
"/missing/ssh",
|
||
activate_handler=activate,
|
||
)
|
||
self.addCleanup(selector.pool.close)
|
||
row = next(
|
||
item
|
||
for item in selector._rows() # type: ignore[attr-defined]
|
||
if item.key == "window:@7"
|
||
)
|
||
self.assertIsNone(selector._activate(row)) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "actions")
|
||
kinds = [item.kind for item in selector._rows()] # type: ignore[attr-defined]
|
||
self.assertIn("action-window", kinds)
|
||
self.assertNotIn("action-retry", kinds)
|
||
|
||
# A stale retry row must degrade to a message instead of a KeyError.
|
||
stale = PickerRow("retry:orangepi", "retry", "! orangepi", target="orangepi")
|
||
self.assertIsNone(selector._activate(stale)) # type: ignore[attr-defined]
|
||
self.assertEqual(selector.view, "main")
|
||
self.assertIn("未登记", selector.message)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|