diff options
Diffstat (limited to 'packages/meshbay-cli/tests')
| -rw-r--r-- | packages/meshbay-cli/tests/test_chat_files.py | 44 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 78 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_interactive.py | 80 |
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") |