diff options
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_e2e_windows_app.py | 286 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_windows_node_lifecycle.py | 235 |
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 |