aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_e2e_local.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 17:42:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 17:45:41 +0200
commit41d03f0a35a95daa7d54e7f29ab924776bedf6e4 (patch)
tree2e631013746f913413b9811562b9c9a4734a0e41 /packages/meshbay-cli/tests/test_e2e_local.py
parent39cac2878de059992cb09a1ccf09b6bcece99fae (diff)
downloadmeshbay-41d03f0a35a95daa7d54e7f29ab924776bedf6e4.tar.gz
fix(cli): make the command-line client work on Windows
Its suite had only run on Linux. On Windows, against a real hub and node and from the installed meshbay-cli.exe: - secrets.enc was written in text mode (os.open without O_BINARY): every 0x0A of the ciphertext became CR LF and the saved session "does not open with its key" on the next run. - isatty() is true for NUL, so a script run with `< NUL` or by the Task Scheduler was asked questions instead of told to pass --yes; output.terminal() checks for a real console. - Ctrl+C waited for the selector loop's next timer, up to half a minute in `chat --follow`; the loop now points the signal wakeup at its self-pipe, as the proactor does. - Right-click copy and paste raised: asyncio cannot start a process on the selector loop; the clipboard tool runs in a thread. - Tab completion of remote paths ran on the default (proactor) loop, which never opens a node, and wrote in the locale encoding; the PowerShell script now reads it as UTF-8. - `get -R` into a folder past MAX_PATH died with a traceback; get, put, the resume file and the interactive mode use the extended form at each disk call, as the node does. - `groups` showed a SQLite hub's naive UTC time as local time. The opt-in end-to-end suite now runs on Windows (16 tests: Chrome found under Program Files and started with the real environment, VLC as the player, Ctrl+C through a console of its own, PowerShell 5.1 completion of an accented name). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests/test_e2e_local.py')
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py118
1 files changed, 77 insertions, 41 deletions
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index 6b7869b..b131658 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -42,6 +42,7 @@ from pathlib import Path
import httpx
import pytest
+from console import ctrl_c, interruptible
pytestmark = [
pytest.mark.skipif(os.environ.get("MESHBAY_CLI_E2E") != "1",
@@ -53,6 +54,28 @@ USERNAME = "alice-e2e-cli"
PASSPHRASE = "a long enough test passphrase, nobody's"
GROUP_NAME = "Some Saga/Box @Set"
BIN = Path(sys.executable).parent
+# The environment before conftest points HOME, USERPROFILE and LOCALAPPDATA at an
+# empty directory for each test: Chrome on Windows does not start in one.
+REAL_ENV = dict(os.environ)
+# Windows refuses a control character in a file name, so a node there cannot
+# hold the name with an escape character in it.
+ESCAPE_NAME = sys.platform != "win32"
+
+
+def _program(linux: str, *windows: str) -> str | None:
+ """A program by its Linux name, or where Windows installs it."""
+ found = shutil.which(linux)
+ if found or sys.platform != "win32":
+ return found
+ for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
+ candidate = os.path.join(base or "", *windows)
+ if base and os.path.isfile(candidate):
+ return candidate
+ return None
+
+
+CHROME = _program("google-chrome", "Google", "Chrome", "Application", "chrome.exe")
+VLC = _program("cvlc", "VideoLAN", "VLC", "vlc.exe")
def _free_port() -> int:
@@ -139,8 +162,13 @@ def _build(root: Path, procs: list) -> dict:
(share / "2024" / "A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac")\
.write_bytes(secrets.token_bytes(2_500_000))
(share / "notes.txt").write_text("hello\n")
- (share / "evil\x1b[31mred.txt").write_text("escape\n")
- node_env = {**os.environ, "HOME": str(node_home),
+ if ESCAPE_NAME:
+ (share / "evil\x1b[31mred.txt").write_text("escape\n")
+ # USERPROFILE, LOCALAPPDATA and APPDATA: where the node's directories
+ # are on Windows; HOME alone would leave it in the developer's own.
+ node_env = {**os.environ, "HOME": str(node_home), "USERPROFILE": str(node_home),
+ "LOCALAPPDATA": str(node_home / "AppData" / "Local"),
+ "APPDATA": str(node_home / "AppData" / "Roaming"),
"XDG_CONFIG_HOME": str(node_home / ".config"),
"XDG_DATA_HOME": str(node_home / ".local/share"),
"XDG_STATE_HOME": str(node_home / ".local/state")}
@@ -398,7 +426,7 @@ def cli(world: dict, *args: str, stdin: str | None = None,
env.update(env_extra or {})
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
return subprocess.run([exe, *args], env=env, input=stdin,
- capture_output=True, text=True, timeout=180)
+ capture_output=True, text=True, encoding="utf-8", timeout=180)
def _handle() -> str:
@@ -438,7 +466,8 @@ def test_the_cli_against_a_real_hub_and_node(world):
out = r.stdout
assert "plage de l'ouest.jpg" in out
assert "A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac" in out
- assert "\x1b" not in out and "evil\\x1b[31mred.txt" in out
+ assert "\x1b" not in out
+ assert not ESCAPE_NAME or "evil\\x1b[31mred.txt" in out
assert "été à la mer/" in out
# By name alone, case-insensitively, and as JSON.
@@ -467,8 +496,9 @@ def test_the_cli_against_a_real_hub_and_node(world):
assert (got / "2024" / "été à la mer" / "plage de l'ouest.jpg").read_bytes() == \
(world["share"] / "2024" / "été à la mer" / "plage de l'ouest.jpg").read_bytes()
# The escape character could not be written as it is: renamed, and said so.
- assert "evil\\x1b[31mred.txt is saved as evil_[31mred.txt" in r.stderr
- assert (got / "evil_[31mred.txt").exists()
+ if ESCAPE_NAME:
+ assert "evil\\x1b[31mred.txt is saved as evil_[31mred.txt" in r.stderr
+ assert (got / "evil_[31mred.txt").exists()
r = cli(world, "get", f"{_handle()}/share/notes.txt", "-o", "-")
assert r.returncode == 0 and r.stdout == "hello\n"
@@ -486,7 +516,8 @@ def test_the_cli_against_a_real_hub_and_node(world):
# notes.txt is there already, whatever the case: nothing sent.
r = cli(world, "put", str(local / "Notes.TXT"), f"{_handle()}/share")
assert r.returncode == 8 and "already there" in r.stderr
- assert not (world["share"] / "Notes.TXT").exists()
+ # By listing, not exists(): on Windows that finds notes.txt under any case.
+ assert "Notes.TXT" not in os.listdir(world["share"])
r = cli(world, "put", "-", f"{_handle()}/share/piped.txt", stdin="through stdin")
assert r.returncode == 0, r.stderr
assert (world["share"] / "piped.txt").read_text() == "through stdin"
@@ -739,10 +770,10 @@ class _Chrome:
ignore_cleanup_errors=True)
port = _free_port()
self.proc = subprocess.Popen(
- ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ [CHROME, "--headless=new", "--disable-gpu", "--no-sandbox",
f"--remote-debugging-port={port}", f"--user-data-dir={self.profile.name}",
"--lang=en-US", "about:blank"],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=REAL_ENV)
deadline = time.monotonic() + 30
while True:
try:
@@ -830,8 +861,7 @@ def _linked_group_page(world: dict) -> "_Chrome":
raise
-@pytest.mark.skipif(not shutil.which("google-chrome"),
- reason="google-chrome is not available")
+@pytest.mark.skipif(not CHROME, reason="Chrome is not available")
def test_the_application_links_itself_from_its_group_page(world):
_linked_group_page(world).close()
@@ -997,8 +1027,7 @@ def _cli_signed_in(world: dict, home: str) -> None:
time.sleep(5)
-@pytest.mark.skipif(not shutil.which("google-chrome"),
- reason="google-chrome is not available")
+@pytest.mark.skipif(not CHROME, reason="Chrome is not available")
def test_chat_between_the_command_line_and_the_application(world):
"""Posted here, read on the group page in Chrome; answered there, read here —
the Python seal and the page's crypto.js, on a real node."""
@@ -1162,7 +1191,7 @@ async def test_put_and_get_in_the_interactive_chat_against_the_real_node(
assert json.loads(r.stdout)[0]["attachment"] == {
"filename": "from the room.bin", "size": len(sent), "type": "file"}
- if shutil.which("google-chrome"):
+ if CHROME:
await asyncio.to_thread(_the_page_shows_the_file, world, "from the room.bin")
@@ -1183,13 +1212,11 @@ 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
Ctrl+C ends it. Runs on the machine the chat tests above linked."""
- import signal
group = _handle()
env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"}
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
- follower = subprocess.Popen([exe, "--json", "chat", "-1", group, "--follow"], env=env,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ follower = interruptible([exe, "--json", "chat", "-1", group, "--follow"], env)
try:
first = json.loads(follower.stdout.readline()) # the last one, at once
assert first["text"] or first.get("attachment") # /put's, above
@@ -1201,20 +1228,19 @@ def test_chat_follow_against_the_real_node(world):
assert got["text"] == text and got["sender"] == USERNAME, got
assert got["trust"] in ("pinned", "first", "linked")
finally:
- follower.send_signal(signal.SIGINT)
+ ctrl_c(follower)
_, err = follower.communicate(timeout=20)
assert follower.returncode == 130
assert "reconnecting" not in err, err
@pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe")
- and shutil.which("cvlc")),
+ and VLC),
reason="ffmpeg, ffprobe and VLC are needed")
def test_stream_a_film_to_real_players(world, tmp_path):
"""A film put on the real node, then streamed: ffprobe reads it, ffmpeg seeks
near its end, a foreign Host is refused, Ctrl+C stops it; then VLC started
with --player plays it to the end and, closing, ends the stream."""
- import signal
_cli_signed_in(world, "cli-stream")
film = tmp_path / "Night Drive.mkv"
subprocess.run(["ffmpeg", "-v", "error", "-f", "lavfi", "-i",
@@ -1241,8 +1267,7 @@ def test_stream_a_film_to_real_players(world, tmp_path):
env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-stream"),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"}
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
- streamer = subprocess.Popen([exe, "--json", "stream", path], env=env,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ streamer = interruptible([exe, "--json", "stream", path], env)
try:
line = streamer.stdout.readline()
assert line, streamer.stderr.read()
@@ -1266,20 +1291,24 @@ def test_stream_a_film_to_real_players(world, tmp_path):
trust_env=False)
assert foreign.status_code == 403
finally:
- streamer.send_signal(signal.SIGINT)
+ ctrl_c(streamer)
_, err = streamer.communicate(timeout=20)
assert streamer.returncode == 130, err
with pytest.raises(httpx.HTTPError):
httpx.get(url, timeout=5, trust_env=False)
- player = tmp_path / "vlc-to-the-end"
log = tmp_path / "vlc.log"
- player.write_text("#!/bin/sh\nexec cvlc --play-and-exit --intf dummy --vout dummy "
- f"--aout dummy \"$1\" >{log} 2>&1\n")
- player.chmod(0o755)
+ flags = "--play-and-exit --intf dummy --vout dummy --aout dummy"
+ if sys.platform == "win32":
+ player = tmp_path / "vlc-to-the-end.cmd"
+ player.write_text(f'@"{VLC}" {flags} "%~1" >"{log}" 2>&1\r\n')
+ else:
+ player = tmp_path / "vlc-to-the-end"
+ player.write_text(f"#!/bin/sh\nexec {VLC} {flags} \"$1\" >{log} 2>&1\n")
+ player.chmod(0o755)
started = time.monotonic()
r = subprocess.run([exe, "stream", "--player", str(player), path], env=env,
- capture_output=True, text=True, timeout=120)
+ capture_output=True, text=True, encoding="utf-8", timeout=120)
assert r.returncode == 0, r.stderr + log.read_text()
assert "The player was closed" in r.stderr
assert time.monotonic() - started > 10, "VLC played the film, in real time"
@@ -1354,23 +1383,30 @@ def test_tab_completes_remote_paths_from_the_real_node(world):
"COMP_CWORD=2\n_meshbay_cli\nprintf '%s\\n' \"${COMPREPLY[@]}\"\n"],
env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
- "PATH": f"{BIN}:{os.environ['PATH']}"},
- capture_output=True, text=True, timeout=60)
+ "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"},
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert bash.stdout.splitlines() == [group + "/"], (bash.stdout, bash.stderr)
- pwsh = shutil.which("pwsh")
+ # PowerShell 7, or Windows PowerShell 5.1, which decodes a program's output
+ # with the console's code page unless told otherwise: an accented name is the
+ # test. The matches come back as code points, past PowerShell's own output.
+ pwsh = shutil.which("pwsh") or (sys.platform == "win32" and shutil.which("powershell"))
if pwsh:
ps = cli(world, "completion", "powershell").stdout
- line = f"meshbay-cli ls '{group}/sh"
- r = subprocess.run(
- [pwsh, "-NoProfile", "-Command",
- ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}"
- "\n$r.CompletionMatches | ForEach-Object { $_.CompletionText }"],
- env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
- "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
- "PATH": f"{BIN}:{os.environ['PATH']}"},
- capture_output=True, text=True, timeout=120)
- assert r.stdout.splitlines() == [f"'{group}/share/'"], (r.stdout, r.stderr)
+ for line, want in ((f"meshbay-cli ls '{group}/sh", f"'{group}/share/'"),
+ (f"meshbay-cli ls '{group}/share/2024/é",
+ f"'{group}/share/2024/été à la mer/'")):
+ r = subprocess.run(
+ [pwsh, "-NoProfile", "-Command",
+ ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}"
+ "\n$r.CompletionMatches | ForEach-Object {"
+ " ($_.CompletionText.ToCharArray() | ForEach-Object { [int]$_ }) -join ',' }"],
+ env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
+ "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
+ "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"},
+ capture_output=True, text=True, timeout=120)
+ got = ["".join(map(chr, map(int, m.split(",")))) for m in r.stdout.split()]
+ assert got == [want], (got, r.stderr)
def _cli(world: dict, *args: str, new: str | None = None, **kw):