summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:57:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:57:18 +0200
commit2f90f4b60cd50f7e9649daaf8e76a120e608cff6 (patch)
tree9d3724602959b05602f7fadb8a45bd26d0fc9b91 /packages/meshbay-cli/tests
parent5044bdfdfffceceecc0d92a776983ad361079839 (diff)
downloadmeshbay-2f90f4b60cd50f7e9649daaf8e76a120e608cff6.tar.gz
feat(cli): /put and /get in the interactive chat
/put FILE sends one local file, relative to the current directory or absolute, into the chat's folder, then posts the application's attachment message naming it ({"text": "", "attachment": {filename, size, type}}), so the page shows it as its paperclip's. /get NAME saves a file of that folder, named from it, into the current directory, never over a file already there. The chat's folder is the page's rule: the operator's choice while its root is writable and present, else the first such root; a chat_directory_ack moves it. A line above the one written in shows the transfer's progress. Attachment messages, from the page or from /put, read as [file: name, size] in the room and in `chat`, and as an `attachment` field with --json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests')
-rw-r--r--packages/meshbay-cli/tests/test_chat_files.py44
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py78
-rw-r--r--packages/meshbay-cli/tests/test_interactive.py80
3 files changed, 201 insertions, 1 deletions
diff --git a/packages/meshbay-cli/tests/test_chat_files.py b/packages/meshbay-cli/tests/test_chat_files.py
new file mode 100644
index 0000000..4c83cba
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_chat_files.py
@@ -0,0 +1,44 @@
+"""Files in the chat: where they go, and how a message names one."""
+
+import json
+
+from meshbay_cli.client.chat import Message, attachment_text, chat_folder, read_attachment
+from meshbay_cli.commands.chat import as_json, print_message
+
+ROOTS = {"music": {"name": "music", "writable": False},
+ "share": {"name": "share", "writable": True},
+ "usb": {"name": "usb", "writable": True, "available": False}}
+
+
+def test_the_chat_folder_is_the_operators_choice_while_it_can_be_written():
+ assert chat_folder({"chat_directory": "share/chat"}, ROOTS) == "share/chat"
+ assert chat_folder({"chat_directory": "/share/chat/"}, ROOTS) == "share/chat"
+ # Read-only, unplugged, gone, or never chosen: the first writable root.
+ for chosen in ("music/x", "usb/x", "gone/x", ""):
+ assert chat_folder({"chat_directory": chosen}, ROOTS) == "share"
+ assert chat_folder({}, {"music": ROOTS["music"]}) == ""
+
+
+def test_an_attachment_reads_back_as_the_application_writes_it():
+ text = attachment_text("IMG 1.JPG", 1234)
+ assert json.loads(text) == {"text": "", "attachment": {
+ "filename": "IMG 1.JPG", "size": 1234, "type": "image"}}
+ assert read_attachment(text) == ("", {"filename": "IMG 1.JPG", "size": 1234,
+ "type": "image"})
+ assert json.loads(attachment_text("clip.mkv", 1))["attachment"]["type"] == "video"
+ assert json.loads(attachment_text("README", 1))["attachment"]["type"] == "file"
+
+
+def test_text_that_only_looks_like_json_stays_text():
+ for text in ('{"a": 1}', "{not json", '{"attachment": "x"}',
+ '{"attachment": {"filename": ""}}', "[1]", "hello"):
+ assert read_attachment(text) == (text, None)
+
+
+def test_the_chat_action_prints_a_file_by_name_and_size(capsys):
+ m = Message(1, 1_700_000_000, "bob", "look", attachment={
+ "filename": "a.pdf", "size": 2048, "type": "file"})
+ print_message(m)
+ out = capsys.readouterr().out.splitlines()
+ assert out[0].endswith("bob: [file: a.pdf, 2.0 KB]") and out[1].strip() == "look"
+ assert as_json(m)["attachment"] == {"filename": "a.pdf", "size": 2048, "type": "file"}
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index 359ab06..fb251bd 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -1070,6 +1070,82 @@ async def test_the_interactive_chat_against_the_real_node(world, monkeypatch, tm
assert r.stdout.strip().endswith(f"{USERNAME}: typed in the room"), r.stdout
+async def test_put_and_get_in_the_interactive_chat_against_the_real_node(
+ world, monkeypatch, tmp_path):
+ """/put sends a file into the chat's folder on a real node and names it in
+ the chat, as the page's paperclip does: `chat` and the group page show it as
+ a file. /get saves a file of that folder into the current directory."""
+ from meshbay_cli import __main__ as main_module
+ from meshbay_cli.commands.context import Context
+ from meshbay_cli.tui.app import MeshBayApp
+ from meshbay_cli.tui.chat import ChatGroupsScreen, ChatLog, ChatRoomScreen
+ from textual.widgets import DataTable, Input
+
+ group = _handle()
+ r = await asyncio.to_thread(cli, world, "chat", "-1", group, home="cli-chat")
+ assert r.returncode == 0, "the chat tests above did not leave this machine linked"
+ monkeypatch.setenv("MESHBAY_CLI_HOME", str(world["root"] / "cli-chat"))
+ here = tmp_path / "here"
+ here.mkdir()
+ monkeypatch.chdir(here)
+ sent = secrets.token_bytes(3 * 1024 * 1024 + 17) # several chunks
+ (here / "from the room.bin").write_bytes(sent)
+ app = MeshBayApp(Context(main_module.build_parser().parse_args(
+ ["--hub", world["hub_url"]])), ChatGroupsScreen)
+
+ def said(screen) -> str:
+ return "\n".join(t for *_, t in screen.query_one(ChatLog)._blocks)
+
+ async def until(pilot, cond, timeout=60):
+ deadline = time.monotonic() + timeout
+ while not cond():
+ assert time.monotonic() < deadline, [n.message for n in app._notifications]
+ await pilot.pause(0.1)
+
+ async with app.run_test(size=(140, 40)) as pilot:
+ await until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)
+ and app.screen.query_one(DataTable).row_count >= 1)
+ await pilot.press("enter")
+ await until(pilot, lambda: isinstance(app.screen, ChatRoomScreen))
+ room = app.screen
+ await until(pilot, lambda: "typed in the room" in said(room))
+
+ # No folder chosen for the chat: the first writable root, `share`.
+ room.query_one(Input).value = "/put from the room.bin"
+ await pilot.press("enter")
+ await until(pilot, lambda: "[file: from the room.bin, 3.0 MB]" in said(room))
+ assert (world["share"] / "from the room.bin").read_bytes() == sent
+
+ room.query_one(Input).value = "/get notes.txt"
+ await pilot.press("enter")
+ await until(pilot, lambda: "Saved " in said(room) or "! " in said(room))
+ assert (here / "notes.txt").read_text() == "hello\n", said(room)
+ await pilot.press("escape")
+
+ r = await asyncio.to_thread(cli, world, "chat", "-1", group, home="cli-chat")
+ assert r.stdout.strip().endswith(f"{USERNAME}: [file: from the room.bin, 3.0 MB]"), \
+ r.stdout
+ r = await asyncio.to_thread(cli, world, "--json", "chat", "-1", group, home="cli-chat")
+ assert json.loads(r.stdout)[0]["attachment"] == {
+ "filename": "from the room.bin", "size": len(sent), "type": "file"}
+
+ if shutil.which("google-chrome"):
+ await asyncio.to_thread(_the_page_shows_the_file, world, "from the room.bin")
+
+
+def _the_page_shows_the_file(world: dict, name: str) -> None:
+ # Not revoke_the_others: `chat --follow` below runs on this machine's key.
+ chrome = _linked_group_page(world)
+ try:
+ files = chrome.until(
+ "[...document.querySelectorAll('.chat-attachment')].map(a => a.innerText)",
+ lambda found: any(name in f for f in found or []))
+ assert any("3" in f and "MB" in f for f in files if name in f), files
+ assert '"attachment"' not in chrome.js("document.body.innerText")
+ finally:
+ chrome.close()
+
+
def test_chat_follow_against_the_real_node(world):
"""`chat --follow` in its own process: messages posted from another run
arrive as the node pushes them, across more than one liveness ping, and
@@ -1083,7 +1159,7 @@ def test_chat_follow_against_the_real_node(world):
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
first = json.loads(follower.stdout.readline()) # the last one, at once
- assert first["text"]
+ assert first["text"] or first.get("attachment") # /put's, above
for text, wait in (("followed one", 0), ("followed two, after a ping", 36)):
time.sleep(wait)
r = cli(world, "chat", group, "-m", text, home="cli-chat")
diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py
index 5b9d9b0..36b7f47 100644
--- a/packages/meshbay-cli/tests/test_interactive.py
+++ b/packages/meshbay-cli/tests/test_interactive.py
@@ -219,6 +219,12 @@ async def _right_click(pilot, widget, offset=(0, 0)) -> None:
await pilot.pause()
+def _said(screen) -> str:
+ """Each thing written to the conversation whole, however it wrapped."""
+ from meshbay_cli.tui.chat import ChatLog
+ return "\n".join(text for *_, text in screen.query_one(ChatLog)._blocks)
+
+
def _log_text(screen) -> str:
from textual.widgets import RichLog
return "\n".join("".join(seg.text for seg in strip)
@@ -364,3 +370,77 @@ async def test_a_right_click_copies_from_the_chat_and_pastes_into_the_line(world
assert field.value == "bob-test"
assert app.screen is room # a right-click left nothing behind
await pilot.press("escape")
+
+
+async def test_put_sends_a_file_into_the_chat_folder_and_get_saves_one_here(
+ world, monkeypatch, tmp_path):
+ """As the application's paperclip: the file in the chat's folder, then a
+ message naming it; and a file of that folder saved by its name."""
+ from meshbay_cli.client.chat import ChatKeys, open_row
+ from meshbay_cli.tui.chat import ChatGroupsScreen, ChatInput, ChatRoomScreen
+ hub, node, work = world
+ node.ack["chat_directory"] = "share/chat"
+ node.dirs.add("share/chat")
+ photo = bytes(range(256)) * 12_000 # several chunks
+ (work / "photo.jpg").write_bytes(photo)
+ elsewhere = tmp_path / "elsewhere"
+ elsewhere.mkdir()
+ (elsewhere / "a note.txt").write_bytes(b"absolute")
+ node.add("share/chat/old", "notes.txt", b"from the chat folder")
+ assert await asyncio.to_thread(cli.main, ["--hub", "https://hub.test", "-u", USERNAME,
+ "--password", PASSPHRASE, "login"]) == 0
+
+ app = MeshBayApp(context.Context(cli.build_parser().parse_args(
+ ["--hub", "https://hub.test"])), ChatGroupsScreen)
+ async with app.run_test(size=(120, 30)) as pilot:
+ await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen))
+ await pilot.pause(0.3)
+ await pilot.press("enter")
+ await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen))
+ room = app.screen
+ field = room.query_one(ChatInput)
+ await _until(pilot, lambda: "No message yet" in _said(room))
+
+ async def say(line: str) -> None:
+ field.value = line
+ await pilot.press("enter")
+
+ # Relative to the current directory.
+ await say("/put photo.jpg")
+ await _until(pilot, lambda: len(node.chat) == 1)
+ assert node.stored("share/chat/photo.jpg") == photo
+ sent = await open_row(node.chat[0], ChatKeys(node.chat_epochs, 1), "g1")
+ assert sent.attachment == {"filename": "photo.jpg", "size": len(photo),
+ "type": "image"}
+ assert sent.text == ""
+ await _until(pilot, lambda: "[file: photo.jpg, " in _said(room))
+
+ # Absolute, quoted as a file manager pastes it; a taken name is renamed.
+ await say(f'/put "{elsewhere / "a note.txt"}"')
+ await _until(pilot, lambda: len(node.chat) == 2)
+ assert node.stored("share/chat/a note.txt") == b"absolute"
+ await say("/put photo.jpg")
+ await _until(pilot, lambda: len(node.chat) == 3)
+ assert node.stored("share/chat/photo (1).jpg") == photo
+ await _until(pilot, lambda: "stored as photo (1).jpg" in _said(room))
+
+ # Nothing sent for a folder or a missing file.
+ await say("/put .")
+ await say("/put nope.bin")
+ await _until(pilot, lambda: "no such file" in _said(room))
+ assert "a folder; /put sends one file" in _said(room)
+ assert len(node.chat) == 3
+
+ # Named from the chat's folder, saved here under its own name.
+ await say("/get old/notes.txt")
+ await _until(pilot, lambda: (work / "notes.txt").exists())
+ assert (work / "notes.txt").read_bytes() == b"from the chat folder"
+ await _until(pilot, lambda: "Saved " in _said(room))
+ await say("/get old/notes.txt")
+ await _until(pilot, lambda: "is here already" in _said(room))
+ await say("/get notes.txt")
+ await _until(pilot, lambda: "No file notes.txt in the chat's folder (share/chat)"
+ in _said(room))
+ await say("/get ../share/x")
+ await _until(pilot, lambda: "name a file of the chat's folder" in _said(room))
+ await pilot.press("escape")