Files
herdr-session-switch/tests/test_selector.py
T
gitea-adminandClaude Fable 5.1 6b906de476 Fix retry crash for unregistered remotes in the Sessions popup
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>
2026-10-03 02:58:45 +00:00

1050 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()