Initial implementation

This commit is contained in:
2026-08-26 15:13:41 +01:00
commit 7e28074eff
18 changed files with 2306 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import asyncio
import signal
import unittest
from unittest.mock import AsyncMock, patch
from rex.client import action_manager, receive_actions, run_command
from rex.client.action_manager import ClientAction
class FakeWebSocket:
def __init__(self, messages: list[str]) -> None:
self.messages = iter(messages)
def __aiter__(self):
return self
async def __anext__(self) -> str:
await asyncio.sleep(0)
try:
return next(self.messages)
except StopIteration:
raise StopAsyncIteration
class ClientTests(unittest.IsolatedAsyncioTestCase):
async def test_run_command_waits_for_process(self) -> None:
process = AsyncMock()
process.wait.return_value = 0
with patch("rex.client.asyncio.create_subprocess_shell", return_value=process):
await run_command("test command")
process.wait.assert_awaited_once()
async def test_cancelling_command_terminates_process_group(self) -> None:
process = AsyncMock()
process.pid = 123
process.returncode = None
wait_started = asyncio.Event()
wait_calls = 0
async def wait() -> int:
nonlocal wait_calls
wait_calls += 1
if wait_calls == 1:
wait_started.set()
await asyncio.Event().wait()
return 0
process.wait.side_effect = wait
with (
patch("rex.client.asyncio.create_subprocess_shell", return_value=process),
patch("rex.client.os.name", "posix"),
patch("rex.client.os.killpg") as killpg,
):
task = asyncio.create_task(run_command("test command"))
await wait_started.wait()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
killpg.assert_called_once_with(123, signal.SIGTERM)
self.assertEqual(process.wait.await_count, 2)
async def test_disconnect_cancels_running_commands(self) -> None:
action = ClientAction(name="wake", command="test command")
command_started = asyncio.Event()
command_cancelled = asyncio.Event()
async def running_command(_: str) -> None:
command_started.set()
try:
await asyncio.Event().wait()
finally:
command_cancelled.set()
websocket = FakeWebSocket(['{"name":"wake"}'])
with (
patch.object(action_manager, "get_action", return_value=action),
patch("rex.client.run_command", side_effect=running_command),
):
await receive_actions(websocket) # type: ignore[arg-type]
self.assertTrue(command_started.is_set())
self.assertTrue(command_cancelled.is_set())
if __name__ == "__main__":
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import httpx
from textual.widgets import DataTable, TabbedContent, Tree
from rex.management.app import RexManager
from rex.management.client import ManagementClient, ManagementError
from rex.server.device_manager import DeviceManager
class ManagementClientTests(unittest.IsolatedAsyncioTestCase):
async def test_reads_keys_and_encodes_names(self) -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.method == "GET":
return httpx.Response(
200, json={"keys": [{"name": "home", "secret": "abc"}]}
)
return httpx.Response(200, json={"key": {}})
client = ManagementClient(
"http://rex.test", transport=httpx.MockTransport(handler)
)
self.addAsyncCleanup(client.close)
self.assertEqual((await client.keys())[0]["name"], "home")
await client.create_key("living room")
self.assertEqual(requests[1].url.raw_path, b"/key/living%20room")
async def test_reports_api_error_detail(self) -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(409, json={"detail": "already exists"})
client = ManagementClient(
"http://rex.test", transport=httpx.MockTransport(handler)
)
self.addAsyncCleanup(client.close)
with self.assertRaisesRegex(ManagementError, "already exists"):
await client.create_device("desk")
class DeviceManagerTests(unittest.TestCase):
def test_action_changes_are_persisted(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "devices.json"
manager = DeviceManager(path)
manager.create_device("desk")
manager.create_action("desk", "wake")
reloaded = DeviceManager(path)
self.assertIsNotNone(reloaded.get_action("desk", "wake"))
reloaded.delete_action("desk", "wake")
self.assertIsNone(DeviceManager(path).get_action("desk", "wake"))
class FakeManagementClient:
def __init__(self) -> None:
self.created_keys: list[str] = []
async def close(self) -> None:
pass
async def keys(self) -> list[dict[str, str]]:
return [{"name": "home", "secret": "abc"}]
async def devices(self) -> list[dict[str, object]]:
return [{"name": "desk", "actions": [{"name": "wake"}]}]
async def create_key(self, name: str) -> None:
self.created_keys.append(name)
class ManagerAppTests(unittest.IsolatedAsyncioTestCase):
async def test_refresh_populates_keys_and_devices(self) -> None:
app = RexManager("http://rex.test")
app.client = FakeManagementClient() # type: ignore[assignment]
async with app.run_test() as pilot:
await pilot.pause()
keys = app.query_one("#keys", DataTable)
devices = app.query_one("#devices", Tree)
self.assertEqual(keys.get_row_at(0), ["home", "abc"])
self.assertEqual(devices.root.children[0].label.plain, "desk")
self.assertEqual(devices.root.children[0].children[0].label.plain, "wake")
async def test_vim_navigation_and_create_prompt(self) -> None:
app = RexManager("http://rex.test")
client = FakeManagementClient()
app.client = client # type: ignore[assignment]
async with app.run_test() as pilot:
await pilot.press("n")
await pilot.press(*"office", "enter")
await pilot.pause()
self.assertEqual(client.created_keys, ["office"])
await pilot.press("shift+l", "j")
self.assertEqual(app.query_one(TabbedContent).active, "devices-tab")
self.assertEqual(
app.query_one("#devices", Tree).cursor_node.data,
("device", "desk"),
)
async def test_moves_between_key_columns_and_yanks_value(self) -> None:
app = RexManager("http://rex.test")
app.client = FakeManagementClient() # type: ignore[assignment]
async with app.run_test() as pilot:
with patch.object(app, "copy_to_clipboard") as copy:
await pilot.press("l", "y")
self.assertEqual(app.query_one("#keys", DataTable).cursor_column, 1)
copy.assert_called_once_with("abc")
if __name__ == "__main__":
unittest.main()