Files
herdr-session-switch/tests/test_selector.py
T
gitea-adminandClaude Fable 5.1 ac6bb30f47 Unify terminal display-width helpers
key_help, selector and control each carried their own East Asian width
and truncation logic with slightly different rules, and cli clipped tmux
labels by code point, so a CJK display name ran twice as wide as an
ASCII one before its ellipsis appeared.

Move the cell-width helpers into herdr_hub.text, make truncate_label
cell-aware, and sanitise control characters before curses draws a row
so an escape sequence in a remote error cannot be rendered as ^[ and
misalign the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 03:09:28 +00:00

1099 lines
45 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
from concurrent.futures import Future
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,
)
from herdr_hub.text import (
display_width,
input_display_line,
pad_display,
truncate_display,
)
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(display_width("Rename: work"), 12)
self.assertEqual(display_width("显示名称: 工作"), 14)
self.assertEqual(display_width("e\N{COMBINING ACUTE ACCENT}"), 1)
self.assertEqual(truncate_display("主机abc", 5), "主机a")
self.assertEqual(display_width(pad_display("主机", 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)
class SelectorBackgroundLoadingTests(unittest.TestCase):
def test_local_sessions_arrive_after_the_popup_is_drawn(self) -> None:
with tempfile.TemporaryDirectory() as directory:
pending: Future[tuple[list[str], list[str]]] = Future()
selector = SessionSelector(
[],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
pending_local_sessions=pending,
)
self.addCleanup(selector.pool.close)
selector.message = "startup"
# Before the listing finishes the popup shows a placeholder row.
selector._poll_local_sessions() # type: ignore[attr-defined]
kinds = {row.key: row for row in selector._rows()} # type: ignore[attr-defined]
self.assertIn("loading:local", kinds)
self.assertFalse(kinds["loading:local"].selectable)
self.assertNotIn("local:work", kinds)
pending.set_result((["work", "default"], ["local Herdr session discovery was slow"]))
selector._poll_local_sessions() # type: ignore[attr-defined]
self.assertIsNone(selector.pending_local_sessions)
keys = {row.key for row in selector._rows()} # type: ignore[attr-defined]
self.assertIn("local:work", keys)
self.assertIn("local:default", keys)
self.assertNotIn("loading:local", keys)
self.assertEqual(selector.message, "startup; local Herdr session discovery was slow")
def test_failed_local_listing_becomes_a_message(self) -> None:
with tempfile.TemporaryDirectory() as directory:
pending: Future[tuple[list[str], list[str]]] = Future()
pending.set_exception(OSError("herdr is not executable"))
selector = SessionSelector(
[],
RemoteRegistry(Path(directory) / "remotes.json"),
"/missing/ssh",
pending_local_sessions=pending,
)
self.addCleanup(selector.pool.close)
selector._poll_local_sessions() # type: ignore[attr-defined]
self.assertIsNone(selector.pending_local_sessions)
self.assertIn("herdr is not executable", selector.message)
self.assertEqual(selector.local_sessions, [])
if __name__ == "__main__":
unittest.main()