aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_e2e_windows_app.py286
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py5
-rw-r--r--packages/meshbay-node/tests/test_windows_node_lifecycle.py235
3 files changed, 525 insertions, 1 deletions
diff --git a/packages/meshbay-node/tests/test_e2e_windows_app.py b/packages/meshbay-node/tests/test_e2e_windows_app.py
new file mode 100644
index 0000000..b407aa8
--- /dev/null
+++ b/packages/meshbay-node/tests/test_e2e_windows_app.py
@@ -0,0 +1,286 @@
+"""The installed Windows application, driven the way a person uses it.
+
+Opt-in (MESHBAY_WIN_E2E=1): it needs an installed MeshBay, launches it, and
+starts and stops the real node. Everything it asserts was broken at least once
+on a real install while every other test passed:
+
+- a fresh account signs in and, with nothing else done, has a node set up,
+ started, linked and listed in the sidebar (it used to take `meshbay-node
+ init` in a terminal and a key pasted on the profile page);
+- Stop leaves no meshbay-node.exe behind and only then says "Stopped";
+- Start and Restart answer with a running node, a new one for Restart.
+
+Before running it:
+- install MeshBay with the setup .exe ("background service" is the default;
+ MESHBAY_E2E_MODE=signin|open when another was chosen);
+- no %LOCALAPPDATA%\\meshbay and no %APPDATA%\\meshbay-client: it refuses to
+ touch an existing node or profile -- move them aside first;
+- run it from an ordinary terminal. Inside a packaged application (MSIX,
+ such as an IDE or agent installed from the Store) %LOCALAPPDATA% and
+ %APPDATA% are virtualised for every child process: what this test writes
+ there, a node started by Task Scheduler never sees. Start it outside the
+ package (Win32_Process.Create, a plain console window).
+
+ $env:MESHBAY_WIN_E2E = 1
+ pytest packages/meshbay-node/tests/test_e2e_windows_app.py -v -s
+
+The hub is a throwaway one on loopback (SQLite, a fresh key), never a real one.
+"""
+
+import json
+import os
+import secrets
+import socket
+import sqlite3
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+pytestmark = [
+ pytest.mark.skipif(sys.platform != "win32", reason="the Windows application"),
+ pytest.mark.skipif(os.environ.get("MESHBAY_WIN_E2E") != "1",
+ reason="opt-in: set MESHBAY_WIN_E2E=1 (see the module docstring)"),
+ pytest.mark.timeout(900),
+]
+
+LOCALAPPDATA = Path(os.environ.get("LOCALAPPDATA", ""))
+APPDATA = Path(os.environ.get("APPDATA", ""))
+INSTALL = Path(os.environ.get("MESHBAY_E2E_INSTALL", LOCALAPPDATA / "Programs" / "MeshBay"))
+NODE_HOME = LOCALAPPDATA / "meshbay"
+APP_PROFILE = APPDATA / "meshbay-client"
+MODE = os.environ.get("MESHBAY_E2E_MODE", "service")
+CDP_PORT = 9333
+
+
+def _free_port() -> int:
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def _launch(argv: list[str]) -> None:
+ """Start a process detached: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP."""
+ subprocess.Popen(argv, creationflags=0x00000008 | 0x00000200, close_fds=True)
+
+
+def _node_pids() -> list[int]:
+ r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq meshbay-node.exe", "/NH", "/FO", "CSV"],
+ capture_output=True, text=True)
+ return [int(c[1]) for c in (line.split('","') for line in r.stdout.splitlines())
+ if len(c) > 1 and c[1].isdigit()]
+
+
+def _wait(what: str, cond, timeout: float, every: float = 1.0):
+ deadline = time.monotonic() + timeout
+ last = None
+ while time.monotonic() < deadline:
+ last = cond()
+ if last:
+ return last
+ time.sleep(every)
+ raise AssertionError(f"timed out after {timeout:.0f}s waiting for {what} (last: {last!r})")
+
+
+SUBMIT = "[...document.querySelectorAll('main form button[type=submit]')].pop().click()"
+
+
+class Page:
+ """The application's window over the DevTools protocol."""
+
+ HELPERS = """
+ var fill = (el, v) => {
+ const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype
+ : HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, v);
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ };
+ var clickText = (t) => {
+ const els = [...document.querySelectorAll('button, a')].filter(e => !e.disabled);
+ const el = els.find(e => e.innerText.trim() === t)
+ || els.find(e => e.innerText.trim().startsWith(t));
+ if (!el) throw new Error('nothing to click named ' + t);
+ el.click();
+ };
+ """
+
+ def __init__(self):
+ from websockets.sync.client import connect
+ targets = json.loads(_http_get(f"http://127.0.0.1:{CDP_PORT}/json/list"))
+ page = next(t for t in targets if t["type"] == "page" and t["url"].startswith("app://"))
+ self._conn = connect(page["webSocketDebuggerUrl"], max_size=None)
+ self._ws = self._conn.__enter__()
+ self._id = 0
+
+ def eval(self, expr: str):
+ self._id += 1
+ self._ws.send(json.dumps({"id": self._id, "method": "Runtime.evaluate", "params": {
+ "expression": f"{self.HELPERS}\n(async () => ({expr}))()",
+ "awaitPromise": True, "returnByValue": True, "userGesture": True}}))
+ while True:
+ msg = json.loads(self._ws.recv(timeout=120))
+ if msg.get("id") == self._id:
+ break
+ res = msg["result"]
+ if "exceptionDetails" in res:
+ raise AssertionError(res["exceptionDetails"].get("exception", {}).get("description"))
+ return res["result"].get("value")
+
+ def text(self) -> str:
+ return self.eval("document.body.innerText")
+
+ def sidebar(self) -> str:
+ return self.eval("(document.querySelector('aside') || {}).innerText || ''")
+
+ def click(self, label: str, timeout: float = 30) -> None:
+ """Click a button or link by its label once it is there and enabled."""
+ time.sleep(0.3) # after a fill, let the page render what it was given
+ _wait(f"a control named {label!r}",
+ lambda: _try(lambda: self.eval(f"(clickText({json.dumps(label)}), 1)")), timeout)
+
+ def close(self):
+ self._conn.__exit__(None, None, None)
+
+
+def _http_get(url: str, token: str | None = None) -> str:
+ import urllib.request
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"} if token else {})
+ with urllib.request.urlopen(req, timeout=5) as r:
+ return r.read().decode()
+
+
+def _node_status() -> dict | None:
+ import urllib.request
+ try:
+ token = (NODE_HOME / "data" / "ui-token").read_text(encoding="utf-8").strip()
+ with urllib.request.urlopen(f"http://127.0.0.1:18000/api/status?t={token}",
+ timeout=3) as r:
+ return json.loads(r.read())
+ except (OSError, ValueError):
+ return None
+
+
+@pytest.fixture(scope="module")
+def hub(tmp_path_factory):
+ pytest.importorskip("meshbay_hub")
+ pytest.importorskip("websockets.sync.client")
+ work = tmp_path_factory.mktemp("hub")
+ port = _free_port()
+ env = {**os.environ,
+ "MESHBAY_DATABASE_URL": f"sqlite+aiosqlite:///{(work / 'hub.db').as_posix()}",
+ "MESHBAY_HUB_ID": "127.0.0.1", "MESHBAY_HUB_KEY": str(work / "hub_private.pem"),
+ "MESHBAY_HUB_HOST": "127.0.0.1", "MESHBAY_HUB_PORT": str(port)}
+ proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "meshbay_hub.app:create_app",
+ "--factory", "--host", "127.0.0.1", "--port", str(port)],
+ env=env, cwd=work, stdout=(work / "hub.log").open("w"),
+ stderr=subprocess.STDOUT)
+ url = f"http://127.0.0.1:{port}"
+ try:
+ _wait("the local hub", lambda: _try(lambda: _http_get(f"{url}/v1/hub/version")), 30)
+ yield {"url": url, "db": work / "hub.db"}
+ finally:
+ proc.kill()
+
+
+def _try(fn):
+ try:
+ return fn()
+ except Exception:
+ return None
+
+
+@pytest.fixture(scope="module")
+def app(hub):
+ exe = INSTALL / "MeshBay.exe"
+ if not exe.exists():
+ pytest.skip(f"no installed MeshBay at {INSTALL}")
+ for there in (NODE_HOME / "node.toml", NODE_HOME / "keystore.enc", APP_PROFILE):
+ if there.exists():
+ pytest.fail(f"{there} exists: this test only runs on a machine with no node "
+ "and no app profile -- move them aside, it never deletes them")
+ if MODE == "service":
+ r = subprocess.run(["schtasks", "/query", "/tn", "MeshBay Node"], capture_output=True)
+ assert r.returncode == 0, "service mode chosen at install, but there is no boot task"
+ APP_PROFILE.mkdir(parents=True)
+ (APP_PROFILE / "config.json").write_text(json.dumps({"hubBase": hub["url"]}),
+ encoding="utf-8")
+ _launch([str(exe), f"--remote-debugging-port={CDP_PORT}"])
+ page = _wait("the application window", lambda: _try(Page), 60)
+ try:
+ yield page
+ finally:
+ # What the window showed last: the first thing to read when it failed.
+ last = _try(lambda: page.eval("location.href + ' ' + document.body.innerText"))
+ print(f"\n--- the application's page at the end ---\n{last}")
+ page.close()
+ subprocess.run(["taskkill", "/F", "/IM", "MeshBay.exe"], capture_output=True)
+ subprocess.run([str(INSTALL / "resources" / "node-runtime" / "meshbay-node.exe"),
+ "autostart", "stop"], capture_output=True, timeout=120)
+
+
+def test_a_fresh_account_gets_its_node_set_up_and_can_stop_it(app, hub):
+ page = app
+ username = "e2e" + secrets.token_hex(3)
+ passphrase = "e2e-" + secrets.token_urlsafe(18)
+
+ # Register.
+ _wait("the sign-in page", lambda: "Register" in page.text(), 30)
+ page.eval("(location.hash = '#/register', 1)")
+ _wait("the registration form", lambda: page.eval(
+ "document.querySelectorAll('main input').length >= 4"), 15)
+ page.eval(f"""(() => {{ const i = document.querySelectorAll('main input');
+ fill(i[0], {json.dumps(username)}); fill(i[1], {json.dumps(username + '@example.invalid')});
+ fill(i[2], {json.dumps(passphrase)}); fill(i[3], {json.dumps(passphrase)});
+ return 1; }})()""")
+ time.sleep(0.5) # the form's state follows the input events a render later
+ page.eval(f"({SUBMIT}, 1)")
+ # Not "recovery key" alone: the registration form says it too.
+ _wait("the recovery key", lambda: "I have saved my recovery key" in page.text(), 90)
+ page.eval("(document.querySelector('input[type=checkbox]').click(), 1)")
+ page.click("Continue")
+ _wait("the verification form", lambda: "Verify" in page.text(), 30)
+ code = sqlite3.connect(hub["db"]).execute(
+ "select code from email_verifications order by rowid desc limit 1").fetchone()[0]
+ page.eval(f"(fill(document.querySelector('main input'), {json.dumps(code)}), 1)")
+ page.click("Verify")
+ _wait("the verified page", lambda: "Go to login" in page.text(), 30)
+ page.click("Go to login")
+ _wait("the login form", lambda: page.eval(
+ "document.querySelectorAll('main input').length >= 2"), 15)
+ page.eval(f"""(() => {{ const i = document.querySelectorAll('main input');
+ fill(i[0], {json.dumps(username)}); fill(i[1], {json.dumps(passphrase)});
+ return 1; }})()""")
+ time.sleep(0.5) # the form's state follows the input events a render later
+ page.eval(f"({SUBMIT}, 1)")
+
+ # Nothing else done: the node is set up, running, linked, and in the sidebar.
+ _wait("the Node section in the sidebar", lambda: "Create group" in page.sidebar(), 120)
+ status = _wait("a running node", lambda: (s := _node_status()) and
+ s.get("status") == "running" and s, 120)
+ toml = (NODE_HOME / "node.toml").read_text(encoding="utf-8")
+ assert f'username = "{username}"' in toml and hub["url"] in toml
+ token = page.eval("JSON.parse(localStorage.getItem('mb_auth')).token")
+ keys = json.loads(_http_get(f"{hub['url']}/v1/users/{username}/pubkeys", token))
+ assert keys.get("pk_node_ed25519") == status["pk_node_ed25519"]
+
+ # Stop: no process left, and only then "Stopped".
+ page.eval("(location.hash = '#/node', 1)")
+ _wait("the Node page", lambda: "Restart" in page.text(), 30)
+ page.click("Stop")
+ _wait("no meshbay-node.exe", lambda: not _node_pids(), 60)
+ _wait("the page saying Stopped", lambda: "Stopped" in page.text(), 30)
+ assert _node_status() is None
+
+ # Start, then Restart: a running node each time, a new one for Restart.
+ page.click("Start")
+ first = _wait("a node after Start", lambda: (s := _node_status()) and
+ s.get("status") == "running" and s, 120)
+ _wait("the page saying Running", lambda: "Running" in page.text(), 30)
+ page.click("Restart")
+ second = _wait("a new node after Restart", lambda: (s := _node_status()) and
+ s.get("status") == "running" and s.get("pid") != first.get("pid") and s, 120)
+ assert len(_node_pids()) == 1, _node_pids()
+ assert second["pk_node_ed25519"] == first["pk_node_ed25519"]
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index ad366d0..1fe6be8 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -824,7 +824,10 @@ def test_nothing_treats_find_node_binary_as_always_a_promise():
def test_a_stop_that_leaves_the_node_answering_says_so():
stop = _fn_body(MAIN_JS.read_text(encoding="utf-8"), "async function nodeServiceStop()")
- assert "if (await probeNode())" in stop and "throw new Error" in stop
+ # Nothing answering is not enough: the control API closes before the
+ # process exits, and the page said "Stopped" about a node still running.
+ assert "if (await probeNode() || (await winNodePids()).length)" in stop
+ assert "if (!r.ok)" in stop and "throw new Error" in stop
def test_node_start_provisions_before_it_starts_anything():
diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py
new file mode 100644
index 0000000..241f583
--- /dev/null
+++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py
@@ -0,0 +1,235 @@
+"""The node's life on a Windows desktop: set up at sign-in, stopped for real.
+
+Two defects from the first Windows beta tester, each found again live on a
+clean install (2026-10-07):
+
+- After a service-mode install the node was never set up for the account that
+ signed in, the Node section stayed out of the sidebar, and the way out was
+ `meshbay-node init` in a terminal plus the key pasted on the profile page.
+- A Stop left meshbay-node.exe running while the Node page said "Stopped": the
+ control API closes before the process exits, and the page asked nothing else.
+
+What can run for real here does (processes, the CLI's stop, the exit timer);
+the desktop application's side is pinned by reading main.js and app.js, the only
+evidence available without launching it -- tests/e2e_windows_app.py launches it.
+"""
+
+import inspect
+import os
+import subprocess
+import sys
+import textwrap
+import time
+from pathlib import Path
+
+import pytest
+from meshbay_node import platform as plat
+from meshbay_node.cli import lifecycle
+
+ROOT = Path(__file__).resolve().parents[3]
+MAIN_JS = ROOT / "packages" / "meshbay-client" / "src" / "main.js"
+STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static"
+
+
+def _js(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def _block(src: str, start: str, end: str = "\n });\n") -> str:
+ return src.split(start, 1)[1].split(end, 1)[0]
+
+
+# ── node.toml ────────────────────────────────────────────────────────────────
+
+def test_a_byte_order_mark_in_node_toml_is_read(tmp_path):
+ from meshbay_node.config import load_config
+ cfg_file = tmp_path / "node.toml"
+ cfg_file.write_bytes(b"\xef\xbb\xbf"
+ + b'[hub]\nurl = "http://127.0.0.1:1"\nusername = "someone"\n')
+ assert load_config(cfg_file).hub.username == "someone"
+
+
+def test_a_node_toml_it_cannot_read_is_said_not_swallowed(tmp_path):
+ """A service node has no console: the reason it will not start goes to
+ the log, where it used to die after "Logging to ..." in silence."""
+ cfg_file = tmp_path / "node.toml"
+ cfg_file.write_text("[hub\nusername = ", encoding="utf-8")
+ env = {**os.environ, "LOCALAPPDATA": str(tmp_path), "APPDATA": str(tmp_path),
+ "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)}
+ r = subprocess.run([sys.executable, "-c", "from meshbay_node.daemon import main; main()",
+ "--config", str(cfg_file)],
+ capture_output=True, text=True, timeout=60, env=env)
+ assert r.returncode == 1, r.stdout + r.stderr
+ assert "cannot read" in r.stdout
+ if sys.platform == "win32":
+ log = tmp_path / "meshbay" / "state" / "node.log"
+ assert "cannot read" in log.read_text(encoding="utf-8")
+
+
+# ── the daemon leaves when it has stopped ────────────────────────────────────
+
+def test_a_stopped_node_does_not_wait_for_a_busy_thread():
+ """A worker still walking a large tree kept the process alive after
+ _shutdown(), with its control API already closed."""
+ script = textwrap.dedent("""
+ import threading, time
+ from meshbay_node.daemon import exit_after
+ threading.Thread(target=time.sleep, args=(120,), name="walker").start()
+ exit_after(0.5, "the node has stopped")
+ """)
+ t0 = time.monotonic()
+ r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True,
+ timeout=60)
+ assert r.returncode == 0, r.stderr
+ assert time.monotonic() - t0 < 30
+ assert "walker" in r.stderr, "it names what it did not wait for"
+
+
+def test_only_the_real_daemon_ends_its_own_process():
+ """Tests run daemons inside pytest: the timer must be armed by main()
+ alone, both before _shutdown() (a hung shutdown) and after it."""
+ from meshbay_node import daemon
+ assert daemon.NodeDaemon.exit_process_when_stopped is False
+ assert "daemon.exit_process_when_stopped = True" in inspect.getsource(daemon.main)
+ shutdown = inspect.getsource(daemon.NodeDaemon._shutdown)
+ assert shutdown.count("if self.exit_process_when_stopped:") == 2
+ assert shutdown.index("SHUTDOWN_DEADLINE_SECS") < shutdown.index('log.info("Node stopped")') \
+ < shutdown.index("EXIT_GRACE_SECS")
+
+
+# ── the CLI's stop: by pid, then checked ─────────────────────────────────────
+
+class _Stub:
+ def __init__(self, monkeypatch, *, answering=None, graceful=False, pids=(),
+ kill_works=True):
+ self.killed: list[int] = []
+ self.pids = list(pids)
+ monkeypatch.setattr(lifecycle, "_running_status", lambda cfg: answering)
+ monkeypatch.setattr(plat, "request_graceful_stop", lambda *a, **k: graceful)
+ monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""})
+ monkeypatch.setattr(plat, "_pid_alive", lambda pid: pid in self.pids)
+ monkeypatch.setattr(plat, "autostart_end", lambda: None)
+ monkeypatch.setattr(plat, "node_pids", lambda: list(self.pids))
+
+ def kill(pid):
+ self.killed.append(pid)
+ if kill_works:
+ self.pids.remove(pid)
+ monkeypatch.setattr(plat, "kill_pid", kill)
+ clock = iter(range(0, 10_000))
+ monkeypatch.setattr(time, "monotonic", lambda: next(clock))
+ monkeypatch.setattr(time, "sleep", lambda s: None)
+
+
+class _Cfg:
+ data_dir = Path(".")
+
+ class node:
+ ui_port = 1
+
+
+def test_a_stop_the_node_did_not_finish_kills_it_by_pid(monkeypatch):
+ stub = _Stub(monkeypatch, answering={"pid": 4242}, graceful=False, pids=[4242])
+ assert lifecycle._stop_node(_Cfg) == "stopped (forced)"
+ assert stub.killed == [4242]
+
+
+def test_a_node_process_left_behind_is_never_called_stopped(monkeypatch):
+ _Stub(monkeypatch, answering=None, graceful=False, pids=[5151], kill_works=False)
+ with pytest.raises(lifecycle.NodeStillRunning, match="5151"):
+ lifecycle._stop_node(_Cfg)
+
+
+def test_a_node_answering_nothing_is_still_a_node(monkeypatch):
+ """Its API closed, its process there: not "not running"."""
+ stub = _Stub(monkeypatch, answering=None, graceful=False, pids=[6161])
+ monkeypatch.setattr(plat, "autostart_end", lambda: stub.pids.clear())
+ assert lifecycle._stop_node(_Cfg) == "stopped (forced)"
+
+
+def test_a_stop_that_fails_exits_non_zero_with_the_reason(monkeypatch, capsys):
+ def refuse(cfg):
+ raise lifecycle.NodeStillRunning("meshbay-node is still running (pid 7)")
+ monkeypatch.setattr(lifecycle, "_stop_node", refuse)
+ monkeypatch.setattr(lifecycle, "load_config", lambda p: _Cfg)
+
+ class Args:
+ config = None
+ subcommand = "stop"
+ monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""})
+ monkeypatch.setattr(sys, "platform", "win32")
+ with pytest.raises(SystemExit) as e:
+ lifecycle.autostart(Args)
+ assert e.value.code == 1
+ assert "pid 7" in capsys.readouterr().out
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes")
+def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch):
+ """Under an image name of this test's own: the real one would find, and
+ kill, the developer's node."""
+ import shutil
+ exe = tmp_path / "mbpidtest.exe"
+ shutil.copy(r"C:\Windows\System32\PING.EXE", exe)
+ proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe")
+ try:
+ assert plat.node_pids() == [proc.pid]
+ plat.kill_pid(proc.pid)
+ assert proc.wait(timeout=10) is not None
+ assert plat.node_pids() == []
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+
+
+# ── the desktop application: what it says, and when it sets the node up ──────
+
+def test_the_node_page_asks_the_node_not_only_the_task():
+ """A node started from a terminal runs while the service task reads
+ "Ready"; one on its way out answers nothing for a while. The page said
+ "Stopped" about both."""
+ main_js = _js(MAIN_JS)
+ status = main_js.split("async function nodeServiceStatus()", 1)[1].split("\n }\n", 1)[0]
+ assert "/running/i.test(svc.state)" not in status
+ assert status.count("winNodeActivity()") == 2
+ activity = main_js.split("async function winNodeActivity()", 1)[1].split("\n }\n", 1)[0]
+ assert "probeNode()" in activity and "winNodePids()" in activity
+ assert "'unknown'" in activity and "'inactive'" in activity
+ page = _js(STATIC / "node-page.js")
+ assert "const stoppable = info.activeState !== 'inactive';" in page
+ assert "disabled=${!!busy || !stoppable}" in page
+
+
+def test_signing_in_sets_up_this_machines_node():
+ main_js = _js(MAIN_JS)
+ assert "handle('node:ensure'" in main_js
+ ensure = main_js.split("async function ensureNode(opts)", 1)[1].split("\n }\n", 1)[0]
+ # Only the node this build ships, on Windows: Linux is unchanged.
+ assert "process.platform !== 'win32' || !hasBundledNode()" in ensure
+ # A node set up for someone else is left as it is...
+ assert ensure.index("return { state: 'other_account' }") < ensure.index("provisionNode(")
+ # ...and so is an account already linked to another node.
+ assert "if (linked && linked !== p.pk_node_ed25519) return { state: 'other_node' };" in ensure
+ assert ensure.index("state: 'other_node'") < ensure.index("linkNodeKeyAndAwaitRunning(")
+ # Started however this machine is set up, through the CLI like every start.
+ assert "winNodeStartVia(['autostart', 'start'])" in ensure
+ # One start at a time.
+ start = main_js.split("handle('node:start'", 1)[1][:400]
+ assert "if (nodeEnsuring) await nodeEnsuring" in start
+ preload = _js(ROOT / "packages" / "meshbay-client" / "src" / "preload.js")
+ assert "ensure: (opts) => ipcRenderer.invoke('node:ensure', opts)" in preload
+ assert "bridge.node.ensure(opts)" in _js(STATIC / "platform.js")
+
+
+def test_the_node_section_shows_for_a_node_not_yet_linked():
+ """It waited for the hub to hold a node key, which nothing gave it."""
+ app = _js(STATIC / "app.js")
+ assert "showNode=${hasNodeKey || localNode}" in app
+ assert "platform.capabilities.nodeAdmin && (hasNodeKey || localNode)" in app
+ effect = app.split("const signedInAs = user ? user.username : null;", 1)[1]
+ effect = effect.split("}, [signedInAs]);", 1)[0]
+ assert "platform.node.ensure(" in effect and "platform.node.installed()" in effect
+ # The token rotates; a renewal is not a sign-in.
+ assert "}, [signedInAs]);" in app