From 4adaab828fd763f0c5999b3cc9e39be1e99c2c3e Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 28 Sep 2026 21:13:48 -0500 Subject: [PATCH 1/6] feat(gate): a second ciris-server beside the leg's node, seeded to the chat scenario, without Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIRISServer's harness/mesh-repro/scenarios/chat.sh stands two nodes up on a compose bridge, which only the Linux leg can host: macOS runners have no Docker, Windows runners run Windows containers, and neither the Android emulator nor the iOS simulator sees a compose network. testing/gate/two_node.py is the same sequence as a native process from the binary the leg already downloaded, in stdlib Python so all three runner images can run it: - `config set net.listen_addr` / `net.bootstrap_peers` offline before the first boot (node_boot.sh) — own ports 5242/5243, own --home, unique --key-id; - `identity create` + `claim --cohort-scope self` on the peer's console with the PIN from /claim_pin; `POST /v1/federation/announce`; - the leg's node, claimed by the CLIENT's wizard, is signed in to with the same credentials and announced; its owner read off the announce bundle; - `POST /v1/federation/peering` both ways with the production self-key-record (the harness's test-blessed record and test-admit-peer are compiled only into test-anchor builds); - each owner adds the other (`POST /v1/contacts`; falls back to the contact code, then the peer NODE, and records which); both open the pair room; the peer speaks only once the room is keyed; arrival on the leg's node is waited for and recorded, and a message that did not cross is never handed to a flow. `down` kills the peer by its pidfile and deletes its home, for an always() step; TwoNodeFixture tears down on exit and on SIGTERM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/two_node.py | 732 +++++++++++++++++++++++++++++++++++++++ testing/test_two_node.py | 186 ++++++++++ 2 files changed, 918 insertions(+) create mode 100644 testing/gate/two_node.py create mode 100644 testing/test_two_node.py diff --git a/testing/gate/two_node.py b/testing/gate/two_node.py new file mode 100644 index 00000000..34fbc667 --- /dev/null +++ b/testing/gate/two_node.py @@ -0,0 +1,732 @@ +"""A second ciris-server beside the leg's node, seeded to CIRISServer's chat scenario. + + # stand a peer up next to a node the client already claimed, seed it, print the values + python3 -m testing.gate.two_node up --binary node/ciris-server \\ + --node-url http://127.0.0.1:4243 --username qaadmin --password 'QaAdmin!2345' \\ + --work "$RUNNER_TEMP/peer" --values peer.json + # tear it down — kill the process, delete its home — even after a failed run + python3 -m testing.gate.two_node down --work "$RUNNER_TEMP/peer" + +PORTED FROM CIRISServer `harness/mesh-repro/` (`scenarios/chat.sh`, +`chat_drive.py`, `node_boot.sh`), WITHOUT DOCKER. That harness runs every node in +a container on one compose bridge, which only the Linux leg can host: macOS +runners have no Docker, Windows runners run Windows containers, and neither the +Android emulator nor the iOS simulator can see a compose network. So the peer +here is a NATIVE PROCESS on the leg's host, from the same `ciris-server` binary +the leg already downloaded, and everything the server harness did in a +container's shell is done here in stdlib Python — which is the only thing all +three runner images share. + +WHAT IS THE SAME AS THE SERVER HARNESS, and why each piece is kept: + + * `config set net.listen_addr` / `net.bootstrap_peers` OFFLINE, before the + first boot (node_boot.sh). Both are boot-structural signed config rows; set + over HTTP after boot they would not be read until a restart. There is no + port flag, so this is also how the peer gets its own ports. + * `identity create` then `claim --cohort-scope self` on the node's own + console, with the one-time PIN read from `/claim_pin` — the harness + standing in for an operator at the console. The claim answers the owner + session and the owner's fed-ID, so no password round-trip is needed. + * `POST /v1/federation/announce` right after the claim. Setup-complete binds + the owner at `cohort_scope: self`, which no peer may hold; without the + announce neither node can place the other in a room's audience and every + chat row is withheld (measured 2026-09-03 in the server harness: 15 + withheld, 0 delivered). Loopback-only, which is fine: the fixture runs on + the host both nodes serve on. + * `POST /v1/federation/peering` both ways with the operator's prefix set, + handing over each node's `self-key-record`. + * The owner key crosses by replication, waited for and RECORDED; the room is + opened by both sides and the message is sent only once the room is keyed. + +WHAT IS DIFFERENT, and why: + + * No canonical. The server harness needs one as the dial target and as the + non-member its `dark` stage interrogates; this fixture seeds state for a + CLIENT to render, so the peer dials the leg's node directly. + * No `test-anchor` routes. `test-blessed-self-record` / `test-admit-peer` are + compiled only into the harness build (`#[cfg(feature = "test-anchor")]`); + the released binary a leg downloads does not serve them. Peering uses the + production `GET /v1/federation/self-key-record` instead, and a peer whose + owner key never replicates is added by its NODE key — recorded as + `contact_via: node`, never passed off as the person. + * The leg's own node is claimed by the CLIENT (session_fixture drives the + wizard). The fixture signs in to it with the same credentials + (`POST /v1/auth/login`) rather than claiming it a second time. + +REACHABILITY PER LEG. The fixture talks to both nodes from the leg's host; the +client under test talks only to ITS node, and never needs to reach the peer: + + * Linux / macOS / Windows desktop — the leg's node is the host's + 127.0.0.1:4243; the peer is 127.0.0.1:5243. + * Android emulator — the client reaches the host's node through + `adb reverse tcp:4243` (bringup.py). The emulator never dials the peer; if a + flow ever needs it to, it is `adb reverse tcp:5243` (or 10.0.2.2:5243). + * iOS simulator — the app embeds its own node on 4242/4243 of the loopback it + SHARES with the host, so from here it is still 127.0.0.1:4243, and the peer + on 5242/5243 does not collide with it. + +The two NODES talk to each other on host loopback, which is what peering needs. + +ISOLATION (the one-home rule). The peer gets its own `--home` under the work +directory, a unique `--key-id`, and ports 5242/5243. It never touches the +leg's node's home, a developer's ~/ciris, or 4242/4243/8080. `down` kills the +process by the pidfile it wrote and deletes the home, and `TwoNodeFixture` is a +context manager so the flow runner tears it down in a `finally`. + +WHAT IT DOES NOT GUARANTEE. Cross-node chat delivery depends on the server's +replication plane; the fixture waits a bounded time for the message to cross and +REPORTS whether it did (`message_arrived`). A flow that names +`${MESSAGE_ATTESTATION_ID}` when it did not cross fails naming the reason — it is +not given a value that would make a local-only row look delivered. +""" + +from __future__ import annotations + +import argparse +import atexit +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.request +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +#: The flow-level key a flow sets to ask for this fixture (`fixture: two_node`). +FIXTURE = "two_node" + +#: The peer's transport port; its read API is the next port, by the node's own +#: convention (the leg's node is 4242 / 4243). NOT 4242/4243: the leg's node, +#: and on iOS the app's embedded node, hold those. +PEER_PORT = 5242 + +#: The leg's node, as the client under test sees it on every leg (module doc). +DEFAULT_NODE_URL = "http://127.0.0.1:4243" + +#: What each node consents to replicate to the other — the server harness's +#: `PEER_PREFIXES`, verbatim. `self:delegates_to:` carries the owner-bindings, +#: without which neither side can resolve who speaks for whom beyond one hop. +PEER_PREFIXES = ["capacity:", "chat:", "self:delegates_to:", "trace:"] + +#: The message the peer sends. Distinct per run so a stale row cannot pass. +DEFAULT_MESSAGE = "two-node fixture: hello from the peer" + +HTTP_TIMEOUT = 30.0 + + +class FixtureUnavailable(RuntimeError): + """The fixture could not be stood up. Raised, never swallowed into a skip.""" + + +# ── command building (pure; unit-tested) ──────────────────────────────────── + + +def resolve_binary(path: Path) -> Path: + """The binary as given, or with `.exe` — Windows legs extract ciris-server.exe, + every other leg has no suffix (the same rule .github/actions/ciris-node uses).""" + path = Path(path) + if path.is_file(): + return path + exe = path.with_name(path.name + ".exe") + if exe.is_file(): + return exe + raise FixtureUnavailable(f"no ciris-server at {path} (or {exe.name})") + + +def unique_key_id(prefix: str = "ciris-gate-peer") -> str: + """A key id no other node on this host uses. The identity lands in + `/identity/` under this alias, so two runs never share one.""" + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def transport_of(read_url: str) -> str: + """`http://127.0.0.1:4243` -> `127.0.0.1:4242`: a node's transport is one port + below its read API. `net.bootstrap_peers` parses a SocketAddr, so this must + be an IP and a port — a hostname is skipped with a warning and dials nothing + (the server harness's docker-compose.chat.yml header).""" + m = re.match(r"^https?://([^/:]+):(\d+)/?$", read_url.strip()) + if not m: + raise ValueError(f"not a node read-API url with an explicit port: {read_url!r}") + host = "127.0.0.1" if m.group(1) == "localhost" else m.group(1) + return f"{host}:{int(m.group(2)) - 1}" + + +@dataclass +class PeerSpec: + """Everything that decides the peer's command lines.""" + + binary: Path + home: Path + key_id: str + port: int = PEER_PORT + host: str = "127.0.0.1" + bootstrap_peers: List[str] = field(default_factory=list) + + @property + def listen_addr(self) -> str: + return f"{self.host}:{self.port}" + + @property + def read_url(self) -> str: + return f"http://{self.host}:{self.port + 1}" + + def _node(self) -> List[str]: + return ["--home", str(self.home), "--key-id", self.key_id] + + def config_commands(self) -> List[List[str]]: + """The offline `config set` calls, in order. The value is JSON (the CLI + parses it), so the address is a quoted JSON string.""" + cmds = [[str(self.binary), "config", "set", "net.listen_addr", + json.dumps(self.listen_addr), *self._node()]] + if self.bootstrap_peers: + cmds.append([str(self.binary), "config", "set", "net.bootstrap_peers", + json.dumps(self.bootstrap_peers), *self._node(), + "--reason", "client gate two-node fixture"]) + return cmds + + def serve_command(self) -> List[str]: + return [str(self.binary), *self._node()] + + def identity_command(self) -> List[str]: + # NO --label: `claim` re-opens the signer under the conventional alias + # (`-user`), so a label would mint a keyset the claim cannot find. + return [str(self.binary), "identity", "create", "--backend", "software", *self._node()] + + def claim_command(self, node_code: str, claim_pin: str) -> List[str]: + return [str(self.binary), "claim", "--backend", "software", *self._node(), + "--node-code", node_code, "--claim-pin", claim_pin, + "--cohort-scope", "self", "--target-url", self.read_url] + + +def parse_claim_output(text: str) -> Tuple[str, str]: + """(access_token, owner fed-ID) from `ciris-server claim`'s stdout, which is + log lines followed by one JSON object. Raises if either is missing.""" + start = text.find("\n{") + blob = text[start + 1:] if start >= 0 else (text if text.lstrip().startswith("{") else "") + try: + body = json.loads(blob.strip()) if blob.strip() else {} + except ValueError: + body = {} + token, owner = body.get("access_token") or "", body.get("identity_key_id") or "" + if not token or not owner: + raise FixtureUnavailable(f"claim did not yield a session: {text.strip()[-300:]!r}") + return token, owner + + +# ── HTTP ──────────────────────────────────────────────────────────────────── + + +def http(method: str, url: str, token: Optional[str] = None, body: Any = None, + timeout: float = HTTP_TIMEOUT) -> Tuple[int, Any]: + """One call -> (status, parsed-or-raw body). Never raises on an HTTP status: + a refusal is data here. A transport failure is status 0.""" + data, headers = None, {} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw, status = resp.read().decode(), resp.status + except urllib.error.HTTPError as e: + raw, status = e.read().decode(errors="replace"), e.code + except Exception as e: # noqa: BLE001 — transport failure is an outcome + return 0, {"transport_error": f"{type(e).__name__}: {e}"} + try: + return status, json.loads(raw) + except ValueError: + return status, raw + + +def _ok(status: int) -> bool: + return 200 <= status < 300 + + +def wait_healthy(read_url: str, timeout: float, proc: Optional[subprocess.Popen] = None, + log: Optional[Path] = None) -> None: + """`/health` on the read API answers only once the node serves (CIRISServer#548).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + # A DEAD PROCESS IS NOT A SLOW ONE (node_fixture.py). + if proc is not None and proc.poll() is not None: + raise FixtureUnavailable(f"the peer exited with {proc.returncode} before serving; see {log}") + status, _ = http("GET", f"{read_url}/health", timeout=3) + if status == 200: + return + time.sleep(1.0) + raise FixtureUnavailable(f"the peer never served {read_url}/health within {timeout:.0f}s; see {log}") + + +# ── the peer process ──────────────────────────────────────────────────────── + + +@dataclass +class Party: + """One node as the seeding sees it: where it answers and who owns it.""" + + name: str + url: str + token: str + owner_key_id: str = "" + node_key_id: str = "" + record: Any = None + announce: Dict[str, Any] = field(default_factory=dict) + + +class PeerNode: + """The second node: configured offline, booted, claimed, announced.""" + + def __init__(self, spec: PeerSpec, work: Path) -> None: + self.spec = spec + self.work = Path(work) + self.log = self.work / "peer.log" + self.pidfile = self.work / "peer.pid" + self.proc: Optional[subprocess.Popen] = None + self._log_handle = None + + def _run(self, cmd: List[str], what: str, timeout: float = 120.0) -> str: + got = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + with self.log.open("a", encoding="utf-8") as fh: + fh.write(f"\n$ {' '.join(cmd)}\n{got.stdout}\n{got.stderr}\n") + if got.returncode: + raise FixtureUnavailable(f"{what} exited {got.returncode}: {got.stderr.strip()[-300:]}") + return got.stdout + + def start(self, timeout: float = 120.0) -> None: + self.work.mkdir(parents=True, exist_ok=True) + self.spec.home.mkdir(parents=True, exist_ok=True) + status, _ = http("GET", f"{self.spec.read_url}/health", timeout=2) + if status: + raise FixtureUnavailable( + f"something already answers on {self.spec.read_url} — refusing to start a " + f"second node on its ports") + for cmd in self.spec.config_commands(): + self._run(cmd, f"config set {cmd[3]}") + self._log_handle = self.log.open("ab") + kwargs: Dict[str, Any] = {} + if os.name == "nt": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + kwargs["start_new_session"] = True + self.proc = subprocess.Popen(self.spec.serve_command(), stdout=self._log_handle, + stderr=subprocess.STDOUT, **kwargs) + self.pidfile.write_text(f"{self.proc.pid}\n{self.spec.home}\n", encoding="utf-8") + wait_healthy(self.spec.read_url, timeout, self.proc, self.log) + + def claim(self, pin_timeout: float = 90.0) -> Party: + """The console claim, as the server harness does it on each container.""" + pin_file = self.spec.home / "claim_pin" + deadline = time.monotonic() + pin_timeout + while not pin_file.is_file() and time.monotonic() < deadline: + time.sleep(1.0) + if not pin_file.is_file(): + raise FixtureUnavailable(f"no claim PIN at {pin_file} — the peer never armed first-run setup") + pin = pin_file.read_text(encoding="utf-8").strip() + status, body = http("GET", f"{self.spec.read_url}/v1/federation/node-code") + code = body.get("code") if isinstance(body, dict) else None + if not code: + raise FixtureUnavailable(f"GET /v1/federation/node-code on the peer answered {status}: {body!r}") + self._run(self.spec.identity_command(), "identity create") + token, owner = parse_claim_output(self._run(self.spec.claim_command(code, pin), "claim")) + return Party("peer", self.spec.read_url, token, owner_key_id=owner) + + def stop(self, keep_home: bool = False) -> None: + if self.proc is not None: + _terminate(self.proc.pid, self.proc) + self.proc = None + if self._log_handle is not None: + self._log_handle.close() + self._log_handle = None + self.pidfile.unlink(missing_ok=True) + if not keep_home: + shutil.rmtree(self.spec.home, ignore_errors=True) + + +def _terminate(pid: int, proc: Optional[subprocess.Popen] = None, grace: float = 10.0) -> None: + """TERM, wait, KILL. By pid so `down` can do it from another process.""" + try: + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True) + else: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + return + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + if proc is not None: + if proc.poll() is not None: + return + elif not _alive(pid): + return + time.sleep(0.3) + try: + if os.name != "nt": + os.kill(pid, signal.SIGKILL) + if proc is not None: + proc.wait(timeout=5) + except (ProcessLookupError, OSError, subprocess.TimeoutExpired): + pass + + +def _alive(pid: int) -> bool: + if os.name == "nt": + got = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"], capture_output=True, text=True) + return str(pid) in got.stdout + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + try: # a zombie of ours still answers kill(0); reap it + done, _ = os.waitpid(pid, os.WNOHANG) + return done == 0 + except ChildProcessError: + return True + + +def down(work: Path) -> str: + """Kill the peer a previous `up` left, by its pidfile, and delete its home.""" + pidfile = Path(work) / "peer.pid" + if not pidfile.is_file(): + return "no peer pidfile — nothing running from this work dir" + lines = pidfile.read_text(encoding="utf-8").splitlines() + pid, home = int(lines[0]), (Path(lines[1]) if len(lines) > 1 else None) + _terminate(pid) + pidfile.unlink(missing_ok=True) + if home is not None: + shutil.rmtree(home, ignore_errors=True) + return f"stopped pid {pid}, removed {home}" + + +# ── seeding: the chat scenario, over HTTP ─────────────────────────────────── + + +@dataclass +class FixtureValues: + """What the seeding produced, for flows to name as `${NAME}`. + + Empty strings mean "not produced"; `notes` says why, and the substitution + quotes it when a flow names a value that is empty.""" + + peer_url: str = "" + peer_node_key_id: str = "" + peer_owner_key_id: str = "" + #: The key the leg's node holds the contact under — the person when their + #: key crossed, else the peer node (`contact_via`). The row tags are built + #: from THIS (`contacts_row_`, `btn_receipt_`). + peer_key_id: str = "" + contact_via: str = "" + peer_contact_code: str = "" + local_owner_key_id: str = "" + local_node_key_id: str = "" + room_id: str = "" + message_text: str = "" + message_attestation_id: str = "" + message_arrived: bool = False + notes: List[str] = field(default_factory=list) + + def as_vars(self) -> Dict[str, str]: + """`${NAME}` -> value. Only non-empty values: an absent key is what lets + the substitution say WHY a value is missing instead of typing ''.""" + out = { + "PEER_URL": self.peer_url, + "PEER_KEY_ID": self.peer_key_id, + "PEER_NODE_KEY_ID": self.peer_node_key_id, + "PEER_OWNER_KEY_ID": self.peer_owner_key_id, + "PEER_CONTACT_CODE": self.peer_contact_code, + "LOCAL_OWNER_KEY_ID": self.local_owner_key_id, + "LOCAL_NODE_KEY_ID": self.local_node_key_id, + "ROOM_ID": self.room_id, + "MESSAGE_TEXT": self.message_text if self.message_arrived else "", + "MESSAGE_ATTESTATION_ID": self.message_attestation_id if self.message_arrived else "", + } + return {k: v for k, v in out.items() if v} + + +Log = Callable[[str], None] + + +def _say(msg: str) -> None: + print(f" [two-node] {msg}", flush=True) + + +def login(url: str, username: str, password: str) -> str: + status, body = http("POST", f"{url}/v1/auth/login", body={"username": username, "password": password}) + token = body.get("access_token") if isinstance(body, dict) else None + if not token: + raise FixtureUnavailable(f"POST {url}/v1/auth/login answered {status}: {str(body)[:200]}") + return token + + +def announce(party: Party, log: Log = _say) -> None: + status, body = http("POST", f"{party.url}/v1/federation/announce", party.token, {}) + party.announce = {"status": status, **(body if isinstance(body, dict) else {"detail": str(body)[:200]})} + roles = [f"{r.get('role')}:{r.get('key_id')}" for r in party.announce.get("bundle") or []] + log(f"announce {party.name}: {status} discoverable={party.announce.get('federation_discoverable')} " + f"bundle={roles}") + + +def self_record(party: Party) -> None: + status, rec = http("GET", f"{party.url}/v1/federation/self-key-record") + if not _ok(status) or not isinstance(rec, dict) or "record" not in rec: + raise FixtureUnavailable(f"{party.name}: GET /v1/federation/self-key-record answered {status}: {str(rec)[:200]}") + party.record, party.node_key_id = rec, rec["record"]["key_id"] + + +def owner_of(party: Party) -> None: + """The owner's fed-ID for a node the CLIENT claimed (the fixture's own claim + already knows it): the announce bundle's `owner_key` row, else a console + ROOT's `root:` username from `/v1/auth/me`.""" + if party.owner_key_id: + return + # The announce names the bundle a peer walks: owner_key, node_key, + # owner_binding. It is the node's own statement of who owns it. + for row in party.announce.get("bundle") or []: + if row.get("role") == "owner_key" and row.get("key_id"): + party.owner_key_id = row["key_id"] + return + _, me = http("GET", f"{party.url}/v1/auth/me", party.token) + # A console-claimed ROOT is `root:` (wa id `wa-root-`). + name = str(me.get("username") or "") if isinstance(me, dict) else "" + if name.startswith("root:"): + party.owner_key_id = name.removeprefix("root:") + + +def peer(a: Party, b: Party, log: Log = _say) -> None: + status, body = http("POST", f"{a.url}/v1/federation/peering", a.token, { + "peer_key_id": b.node_key_id, "peer_key_record": b.record, + "attestation_prefixes": PEER_PREFIXES}) + if not _ok(status): + raise FixtureUnavailable(f"peering {a.name}->{b.name} answered {status}: {str(body)[:300]}") + log(f"peering {a.name}->{b.name}: fresh={body.get('freshly_emitted')}") + + +def knows(host: Party, key_id: str) -> bool: + status, _ = http("GET", f"{host.url}/v1/federation/peers/{key_id}", host.token) + return status == 200 + + +def add_contact(host: Party, guest: Party, wait: float, notes: List[str], + code: str = "", log: Log = _say) -> Tuple[str, str]: + """(key the contact is held under, how). The person if their key crossed; + their contact code if the node serves one; else their NODE, said so.""" + deadline = time.monotonic() + wait + while guest.owner_key_id and not knows(host, guest.owner_key_id) and time.monotonic() < deadline: + time.sleep(5.0) + tries: List[Tuple[str, str]] = [] + if guest.owner_key_id: + tries.append((guest.owner_key_id, "owner")) + if code: + tries.append((code, "code")) + tries.append((guest.node_key_id, "node")) + for key, via in tries: + status, body = http("POST", f"{host.url}/v1/contacts", host.token, {"key_id": key}) + if _ok(status) and isinstance(body, dict): + log(f"contact {host.name}->{guest.name} via {via}: {status} key={body.get('key_id')} " + f"reachable_nodes={body.get('reachable_nodes')} prefixes={body.get('consent_prefixes')}") + return str(body.get("key_id") or key), via + reason = body.get("reason_id") if isinstance(body, dict) else body + notes.append(f"contact {host.name}->{guest.name} via {via} refused: {status} {str(reason)[:120]}") + log(notes[-1]) + raise FixtureUnavailable(f"{host.name} could not add {guest.name} as a contact: {notes[-3:]}") + + +def room_state(party: Party, cid: str) -> Dict[str, Any]: + status, body = http("GET", f"{party.url}/v1/chat/{cid}/messages", party.token) + if not isinstance(body, dict): + return {"status": status, "ready": False, "messages": []} + msgs = body.get("messages") or [] + ready = body.get("ready") + if ready is None and status == 200: + ready = not any(m.get("kind") == "system" for m in msgs) + notes = [m.get("message_id") for m in msgs if m.get("kind") == "system"] + return {"status": status, "ready": bool(ready), "messages": msgs, + "state": notes[0] if notes else None, "reason_id": body.get("reason_id")} + + +def open_room(party: Party, with_key: str) -> str: + status, body = http("POST", f"{party.url}/v1/chat", party.token, {"key_id": with_key}) + cid = body.get("community_id") if isinstance(body, dict) else None + if not cid: + raise FixtureUnavailable(f"POST /v1/chat on {party.name} answered {status}: {str(body)[:200]}") + return cid + + +def seed(local: Party, remote: Party, *, message: str = DEFAULT_MESSAGE, + owner_wait: float = 90.0, ready_wait: float = 150.0, arrive_wait: float = 120.0, + log: Log = _say) -> FixtureValues: + """The chat scenario between the leg's node (`local`) and the peer (`remote`). + + Both announced; peered both ways; each owner adds the other; both open the + pair room; the PEER speaks once the room is keyed; the arrival on the leg's + node is waited for and recorded.""" + v = FixtureValues(peer_url=remote.url, message_text=message) + for p in (remote, local): + announce(p, log) + self_record(p) + owner_of(local) + v.local_owner_key_id, v.local_node_key_id = local.owner_key_id, local.node_key_id + v.peer_owner_key_id, v.peer_node_key_id = remote.owner_key_id, remote.node_key_id + log(f"local node={local.node_key_id} owner={local.owner_key_id or '?'}; " + f"peer node={remote.node_key_id} owner={remote.owner_key_id}") + peer(local, remote, log) + peer(remote, local, log) + + status, body = http("GET", f"{remote.url}/v1/self/contact-code", remote.token) + if _ok(status) and isinstance(body, dict): + v.peer_contact_code = str(body.get("code") or body.get("contact_code") or "") + else: + v.notes.append(f"the peer serves no contact code (GET /v1/self/contact-code: {status}) — " + f"it ships in ciris-server 0.5.218 (CIRISServer#673)") + + v.peer_key_id, v.contact_via = add_contact(local, remote, owner_wait, v.notes, v.peer_contact_code, log) + back_key, back_via = add_contact(remote, local, owner_wait, v.notes, "", log) + if v.contact_via != "owner": + v.notes.append(f"the peer's owner key never reached the leg's node within {owner_wait:.0f}s; " + f"the contact is held under the peer NODE ({v.peer_key_id})") + + # Both sides open the pair room. It is derived, not invited: each node + # computes the same id from the two keys (pair_community_key_id). + cid_remote = open_room(remote, back_key) + v.room_id = open_room(local, v.peer_key_id) + if cid_remote != v.room_id: + v.notes.append(f"the two nodes derived different rooms: peer {cid_remote}, local {v.room_id}") + log(v.notes[-1]) + return v + log(f"room {v.room_id}") + + deadline = time.monotonic() + ready_wait + hs = room_state(remote, cid_remote) + while not hs["ready"] and time.monotonic() < deadline: + time.sleep(10.0) + hs = room_state(remote, cid_remote) + if not hs["ready"]: + v.notes.append(f"the room never keyed on the peer within {ready_wait:.0f}s " + f"(state={hs['state']} reason={hs['reason_id']}) — the other side's " + f"KeyPackage did not cross") + log(v.notes[-1]) + return v + + status, body = http("POST", f"{remote.url}/v1/chat/{cid_remote}/messages", remote.token, {"body": message}) + v.message_attestation_id = str(body.get("attestation_id") or "") if isinstance(body, dict) else "" + if not v.message_attestation_id: + v.notes.append(f"the peer's send answered {status}: {str(body)[:200]}") + log(v.notes[-1]) + return v + log(f"peer sent {v.message_attestation_id}") + + deadline = time.monotonic() + arrive_wait + while time.monotonic() < deadline: + for m in room_state(local, v.room_id)["messages"]: + if m.get("attestation_id") == v.message_attestation_id and m.get("body"): + v.message_arrived = True + log("the message ARRIVED on the leg's node") + return v + time.sleep(10.0) + v.notes.append(f"the peer's message {v.message_attestation_id} did not arrive on the leg's node " + f"within {arrive_wait:.0f}s") + log(v.notes[-1]) + return v + + +# ── the fixture the runner holds ──────────────────────────────────────────── + + +class TwoNodeFixture: + """Stand the peer up beside `node_url`, seed, hand back `${NAME}` values. + + A context manager: `__exit__` kills the peer and deletes its home whatever + happened inside, which is how a failed flow still cleans up.""" + + def __init__(self, binary: Path, work: Path, *, node_url: str = DEFAULT_NODE_URL, + username: str = "qaadmin", password: str = "QaAdmin!2345", + local_token: str = "", port: int = PEER_PORT, key_id: str = "", + message: str = "", keep_home: bool = False, **waits: float) -> None: + self.work = Path(work) + self.node_url = node_url.rstrip("/") + self.username, self.password, self.local_token = username, password, local_token + self.message = message or f"{DEFAULT_MESSAGE} ({uuid.uuid4().hex[:6]})" + self.keep_home, self.waits = keep_home, waits + self.spec = PeerSpec(binary=Path(binary), home=self.work / "home", + key_id=key_id or unique_key_id(), port=port, + bootstrap_peers=[transport_of(self.node_url)]) + self.node: Optional[PeerNode] = None + self.values: Optional[FixtureValues] = None + + def up(self) -> Dict[str, str]: + self.spec.binary = resolve_binary(self.spec.binary) + self.node = PeerNode(self.spec, self.work) + # CLEAN UP EVEN WHEN KILLED. The runner's `finally` covers a failed + # flow; a job timeout or a cancelled step sends SIGTERM, which skips + # `finally` unless it is turned into an exit. The peer runs in its own + # session, so nothing else would reap it. (The workflow also runs + # `two_node down` under `if: always()` for a SIGKILL.) + atexit.register(self.down) + if threading.current_thread() is threading.main_thread() and hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) + _say(f"peer {self.spec.key_id} on {self.spec.listen_addr} (read {self.spec.read_url}), " + f"home {self.spec.home}, dialling {self.spec.bootstrap_peers}") + self.node.start() + remote = self.node.claim() + token = self.local_token or login(self.node_url, self.username, self.password) + local = Party("local", self.node_url, token) + self.values = seed(local, remote, message=self.message, **self.waits) + (self.work / "values.json").write_text(json.dumps(asdict(self.values), indent=2), encoding="utf-8") + return self.values.as_vars() + + def down(self) -> None: + if self.node is not None: + self.node.stop(keep_home=self.keep_home) + self.node = None + + def __enter__(self) -> "TwoNodeFixture": + return self + + def __exit__(self, *exc: Any) -> None: + self.down() + + +def main(argv: Optional[List[str]] = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + u = sub.add_parser("up", help="start, claim, peer and seed; leave the peer running") + u.add_argument("--binary", type=Path, required=True) + u.add_argument("--work", type=Path, required=True, help="the peer's home and log live here") + u.add_argument("--node-url", default=DEFAULT_NODE_URL) + u.add_argument("--username", default="qaadmin") + u.add_argument("--password", default="QaAdmin!2345") + u.add_argument("--token", default="", help="an owner session for the leg's node, instead of logging in") + u.add_argument("--port", type=int, default=PEER_PORT) + u.add_argument("--values", type=Path, help="write the ${NAME} values here as JSON") + d = sub.add_parser("down", help="kill the peer and delete its home") + d.add_argument("--work", type=Path, required=True) + args = ap.parse_args(argv) + + if args.cmd == "down": + print(down(args.work)) + return 0 + fx = TwoNodeFixture(args.binary, args.work, node_url=args.node_url, username=args.username, + password=args.password, local_token=args.token, port=args.port) + try: + values = fx.up() + except FixtureUnavailable as e: + print(f"::error::two-node fixture: {e}", file=sys.stderr) + fx.down() + return 1 + if args.values: + args.values.write_text(json.dumps(values, indent=2), encoding="utf-8") + print(json.dumps({"vars": values, "notes": fx.values.notes if fx.values else []}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/test_two_node.py b/testing/test_two_node.py new file mode 100644 index 00000000..52cbff21 --- /dev/null +++ b/testing/test_two_node.py @@ -0,0 +1,186 @@ +"""The two-node fixture's command building and bookkeeping — no node, no network. + +What is pinned here is what a leg cannot afford to find out at 2am: that the +peer never lands on the leg's ports or home, that the claim targets the peer +and not the leg's node, that a `.exe` is found on Windows, that a value the +seeding did not produce is not handed to a flow, and that `down` really kills +and really deletes.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from testing.gate import two_node as tn + + +def _spec(tmp_path: Path, **kw) -> tn.PeerSpec: + return tn.PeerSpec(binary=tmp_path / "ciris-server", home=tmp_path / "home", + key_id="ciris-gate-peer-abc", **kw) + + +def test_the_peer_defaults_to_its_own_ports_never_the_legs(): + """4242/4243 are the leg's node (and the iOS app's embedded one); 8080 the agent.""" + s = tn.PeerSpec(binary=Path("b"), home=Path("h"), key_id="k") + assert (s.port, s.port + 1) == (5242, 5243) + assert not {4242, 4243, 8080} & {s.port, s.port + 1} + assert s.read_url == "http://127.0.0.1:5243" + + +def test_config_sets_the_listen_addr_then_the_dial_set_offline_on_the_peers_home(tmp_path): + s = _spec(tmp_path, bootstrap_peers=["127.0.0.1:4242"]) + listen, peers = s.config_commands() + assert listen[:4] == [str(s.binary), "config", "set", "net.listen_addr"] + assert json.loads(listen[4]) == "127.0.0.1:5242", "the value is JSON: a quoted string" + assert json.loads(peers[4]) == ["127.0.0.1:4242"] + for cmd in (listen, peers): + assert cmd[cmd.index("--home") + 1] == str(tmp_path / "home") + assert cmd[cmd.index("--key-id") + 1] == "ciris-gate-peer-abc" + + +def test_no_dial_set_means_no_second_config_call(tmp_path): + assert len(_spec(tmp_path).config_commands()) == 1 + + +def test_serve_takes_home_and_key_id_as_flags_never_env(tmp_path): + """Server 0.5 reads no environment; CIRIS_HOME is the CLIENT's variable.""" + s = _spec(tmp_path) + assert s.serve_command() == [str(s.binary), "--home", str(s.home), "--key-id", s.key_id] + + +def test_identity_create_carries_no_label(tmp_path): + """`claim` re-opens the signer under `-user`; a label would mint a + keyset the claim cannot find (the server harness's note).""" + cmd = _spec(tmp_path).identity_command() + assert cmd[1:5] == ["identity", "create", "--backend", "software"] + assert "--label" not in cmd + + +def test_the_claim_targets_the_peers_read_api_at_self_scope(tmp_path): + s = _spec(tmp_path, port=6242) + cmd = s.claim_command("CIRIS-V1-CODE", "123456") + assert cmd[cmd.index("--target-url") + 1] == "http://127.0.0.1:6243" + assert cmd[cmd.index("--node-code") + 1] == "CIRIS-V1-CODE" + assert cmd[cmd.index("--claim-pin") + 1] == "123456" + assert cmd[cmd.index("--cohort-scope") + 1] == "self" + + +@pytest.mark.parametrize("url,want", [ + ("http://127.0.0.1:4243", "127.0.0.1:4242"), + ("http://127.0.0.1:5143/", "127.0.0.1:5142"), + ("http://localhost:4243", "127.0.0.1:4242"), # bootstrap_peers parses a SocketAddr +]) +def test_the_dial_target_is_one_port_below_the_read_api(url, want): + assert tn.transport_of(url) == want + + +def test_a_url_without_a_port_is_refused_not_guessed(): + with pytest.raises(ValueError): + tn.transport_of("http://127.0.0.1") + + +def test_the_windows_binary_is_found_by_its_exe(tmp_path): + (tmp_path / "ciris-server.exe").write_bytes(b"") + assert tn.resolve_binary(tmp_path / "ciris-server") == tmp_path / "ciris-server.exe" + + +def test_a_missing_binary_is_loud(tmp_path): + with pytest.raises(tn.FixtureUnavailable, match="no ciris-server"): + tn.resolve_binary(tmp_path / "ciris-server") + + +def test_key_ids_are_unique_per_run(): + assert len({tn.unique_key_id() for _ in range(50)}) == 50 + + +def test_the_claims_session_and_owner_are_read_after_its_log_lines(): + out = "INFO something\nINFO more\n" + json.dumps( + {"access_token": "tok", "identity_key_id": "peer-user-x", "other": 1}, indent=2) + assert tn.parse_claim_output(out) == ("tok", "peer-user-x") + + +def test_a_claim_with_no_session_is_loud(): + with pytest.raises(tn.FixtureUnavailable, match="did not yield a session"): + tn.parse_claim_output("ERROR claim refused: bad pin\n") + + +def test_values_hand_flows_only_what_the_seeding_produced(): + v = tn.FixtureValues(peer_key_id="p-user", peer_node_key_id="p-node", room_id="chat:pair:x", + message_attestation_id="att-1", message_text="hi") + got = v.as_vars() + assert got["PEER_KEY_ID"] == "p-user" and got["ROOM_ID"] == "chat:pair:x" + assert "PEER_CONTACT_CODE" not in got, "empty is absent, so the flow says why" + # Sent but not arrived: a local-only row must not look delivered. + assert "MESSAGE_ATTESTATION_ID" not in got and "MESSAGE_TEXT" not in got + v.message_arrived = True + assert v.as_vars()["MESSAGE_ATTESTATION_ID"] == "att-1" + + +def test_add_contact_falls_back_to_the_node_and_says_so(monkeypatch): + """The owner key never crossed: the contact is the peer NODE, recorded.""" + calls = [] + + def fake_http(method, url, token=None, body=None, timeout=0): + calls.append((method, url, body)) + if url.endswith("/v1/federation/peers/peer-user"): + return 404, {"error": "peer not found"} + if url.endswith("/v1/contacts") and body == {"key_id": "peer-user"}: + return 404, {"reason_id": "contacts.unknown_fed_id"} + if url.endswith("/v1/contacts"): + return 200, {"key_id": body["key_id"], "reachable_nodes": 0} + return 500, {} + + monkeypatch.setattr(tn, "http", fake_http) + host = tn.Party("local", "http://h", "t") + guest = tn.Party("peer", "http://g", "t", owner_key_id="peer-user", node_key_id="peer-node") + notes: list = [] + key, via = tn.add_contact(host, guest, wait=0, notes=notes, log=lambda m: None) + assert (key, via) == ("peer-node", "node") + assert any("via owner refused" in n for n in notes) + + +def test_down_kills_the_peer_by_its_pidfile_and_deletes_its_home(tmp_path): + """The `if: always()` path: another process, only the work dir to go on.""" + home = tmp_path / "home" + (home / "identity").mkdir(parents=True) + proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"], + start_new_session=True) + try: + (tmp_path / "peer.pid").write_text(f"{proc.pid}\n{home}\n", encoding="utf-8") + said = tn.down(tmp_path) + assert str(proc.pid) in said + deadline = time.monotonic() + 10 + while proc.poll() is None and time.monotonic() < deadline: + time.sleep(0.1) + assert proc.poll() is not None, "the peer is still running" + assert not home.exists(), "the peer's home was left behind" + assert not (tmp_path / "peer.pid").exists() + finally: + if proc.poll() is None: + proc.kill() + + +def test_down_with_nothing_running_is_a_no_op(tmp_path): + assert "nothing" in tn.down(tmp_path) + + +def test_the_client_claimed_owner_is_read_off_the_announce_bundle(monkeypatch): + """The wizard's user is `qaadmin`, not `root:`; the announce bundle + names the owner key whoever claimed.""" + monkeypatch.setattr(tn, "http", lambda *a, **k: (200, {"username": "qaadmin"})) + p = tn.Party("local", "http://h", "t", announce={"bundle": [ + {"role": "node_key", "key_id": "n"}, {"role": "owner_key", "key_id": "o-user"}]}) + tn.owner_of(p) + assert p.owner_key_id == "o-user" + + +def test_a_console_root_owner_is_read_off_its_username(monkeypatch): + monkeypatch.setattr(tn, "http", lambda *a, **k: (200, {"username": "root:o-user-2"})) + p = tn.Party("local", "http://h", "t") + tn.owner_of(p) + assert p.owner_key_id == "o-user-2" From 9997644bd28dd7f5330edd6f93d26717ad69219b Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 28 Sep 2026 21:13:52 -0500 Subject: [PATCH 2/6] feat(flows): `fixture: two_node` and `${NAME}` values a fixture fills in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow could not name the thing a second node creates: the receipt's hamburger is `btn_receipt_`, the peer row `peer_pick_row_`, the message `chat_msg_`, and a `click:` takes one literal tag. Every step that needed one was `optional_step` gated on a tag nothing opened, so it always skipped (CSD-006/047/091 §5 "Flow not complete"). - flow_spec: a flow-level `fixture:` key (known: two_node) and `${NAME}` in any tag, text, glob or input value. A name with no fixture to fill it is a LOAD error — it would otherwise reach the app as the literal `${NAME}` and fail as "element not found". At run time each step is resolved from the fixture's values; a value the fixture did not produce FAILS the step, optional or not, quoting the fixture's own note for why. `matches:` values are regex-escaped. - run_flows.run_all: plain flows run first whatever the file order (a fixture changes the leg's node, and people.yaml asserts the bare one); a fixture is stood up once, just before the first runnable flow that asks for it — a run with none, or whose fixture flows its floor refuses, never pays for it — and torn down in a `finally`. A fixture that cannot stand up leaves its flows cannot-start, naming why. - run_flows / run_platform: --node-binary, --node-url, --peer-port, --peer-work. - test_flows: a `${NAME}` tag is carried only if its literal head IS one of the client's interpolated prefixes. Shown red first: test_flow_fixtures.py against the previous flow_spec/run_flows fails at collection (no UnresolvedVariable, no fixture support). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/flow_spec.py | 121 ++++++++++++++++++- testing/gate/run_flows.py | 126 ++++++++++++++++++-- testing/gate/run_platform.py | 10 +- testing/test_flow_fixtures.py | 215 ++++++++++++++++++++++++++++++++++ testing/test_flows.py | 8 ++ 5 files changed, 466 insertions(+), 14 deletions(-) create mode 100644 testing/test_flow_fixtures.py diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index 90e06f77..548c2b9d 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -61,7 +61,16 @@ _ACTION_KEYS = {"click", "input", "scroll_to", "wait", "wait_ms"} #: LOCAL DELTA (VENDORED.md): `csd` names the CSD a flow tests, so the runner can #: read that CSD's `shows:` (for `relation` field ids) and `states:` (for `state:`). -_FLOW_KEYS = {"flow", "title", "description", "client", "steps", "csd"} +_FLOW_KEYS = {"flow", "title", "description", "client", "steps", "csd", "fixture"} + +#: LOCAL DELTA: fixtures a flow may ask for with `fixture:`. A flow pays for a +#: fixture only when it names one; `two_node` stands a second ciris-server up +#: beside the leg's node and seeds it (testing/gate/two_node.py). +FIXTURES = {"two_node"} + +#: LOCAL DELTA: `${NAME}` in a tag, a text or an input value is a value the +#: flow's fixture produced — a key id no flow could know when it was written. +_VAR = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}") _RELATION_OPS = {"eq", "ne", "lt", "lte", "gt", "gte", "min_of", "max_of", "sum_of"} @@ -264,6 +273,12 @@ class FlowSpec: #: LOCAL DELTA: the CSD this flow tests, when it names one (`csd: CSD-005`). csd_id: Optional[str] = None csd: Any = None # testing.gate.csd_doc.CsdDoc + #: LOCAL DELTA: the fixture this flow needs (`fixture: two_node`), or None. + fixture: Optional[str] = None + + def variables(self) -> List[str]: + """Every `${NAME}` the flow names, sorted.""" + return sorted({n for step in self.steps for n in _step_variables(step)}) @classmethod def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": @@ -295,6 +310,19 @@ def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": steps=steps, path=path, ) + fixture = raw.get("fixture") + if fixture is not None and fixture not in FIXTURES: + raise SpecError(f"{path}: `fixture: {fixture!r}` is not one of {sorted(FIXTURES)}") + spec.fixture = fixture + # A `${NAME}` with no fixture to fill it would reach the app as the + # literal text `${NAME}` and fail as "element not found" — the one + # failure that looks exactly like a broken app. Refused at load. + names = spec.variables() + if names and fixture is None: + raise SpecError( + f"{path}: names {', '.join('${' + n + '}' for n in names)} but asks for no " + f"`fixture:` that could supply {'it' if len(names) == 1 else 'them'}" + ) if "csd" in raw: spec._bind_csd(raw["csd"], csd_root) return spec @@ -365,6 +393,78 @@ def _bind_csd(self, csd_id: Any, csd_root: Optional[Path]) -> None: ) +class UnresolvedVariable(Exception): + """A `${NAME}` the fixture did not produce. The message says why, from the + fixture's own notes, so the failure names the missing value's cause.""" + + +def _strings_of(step: "Step") -> List[str]: + out: List[str] = [] + for a in step.do: + out.append(a.target) + if a.value is not None: + out.append(a.value) + for cond in (step.requires, step.expect): + out += list(cond.visible) + list(cond.absent) + for d in (cond.text, cond.matches): + out += list(d.keys()) + list(d.values()) + out += list(cond.number.keys()) + for k, v in cond.one_of.items(): + out += [k, *v] + for sel in (cond.count, cond.each): + if sel: + out.append(str(sel.get("of", ""))) + return out + + +def _step_variables(step: "Step") -> set: + return {m for text in _strings_of(step) for m in _VAR.findall(text)} + + +def substitute(text: str, values: Dict[str, str], why: Sequence[str] = (), *, + escape: bool = False) -> str: + """`${NAME}` -> values[NAME]. A name with no value raises UnresolvedVariable + quoting `why` (the fixture's notes). `escape` for a regex (`matches:`).""" + def one(m: "re.Match[str]") -> str: + name = m.group(1) + val = values.get(name) + if not val: + reason = "; ".join(why) or "the fixture produced no such value" + raise UnresolvedVariable(f"${{{name}}} was not provided by the fixture: {reason}") + return re.escape(val) if escape else val + return _VAR.sub(one, text) + + +def resolve_step(step: "Step", values: Dict[str, str], why: Sequence[str] = ()) -> "Step": + """A copy of `step` with every `${NAME}` replaced. The original is untouched, + so one loaded spec can run again against another fixture.""" + import copy # noqa: PLC0415 + + if not _step_variables(step): + return step + + def sub(t: str) -> str: + return substitute(t, values, why) + + out = copy.deepcopy(step) + for a in out.do: + a.target = sub(a.target) + if a.value is not None: + a.value = sub(a.value) + for cond in (out.requires, out.expect): + cond.visible = [sub(t) for t in cond.visible] + cond.absent = [sub(t) for t in cond.absent] + cond.text = {sub(k): sub(v) for k, v in cond.text.items()} + cond.matches = {sub(k): substitute(v, values, why, escape=True) + for k, v in cond.matches.items()} + cond.number = {sub(k): v for k, v in cond.number.items()} + cond.one_of = {sub(k): [sub(x) for x in v] for k, v in cond.one_of.items()} + for sel in (cond.count, cond.each): + if sel and "of" in sel: + sel["of"] = sub(str(sel["of"])) + return out + + def check_client_floor(floor: Optional[str], actual: Optional[str]) -> Optional[str]: """Return a refusal message if `actual` is below `floor`, else None. @@ -436,8 +536,14 @@ class FlowRunner: def __init__(self, helper, platform=None, artifacts: Optional[Path] = None, field_tags: Optional[Dict[str, str]] = None, - state_tags: Optional[Dict[str, str]] = None) -> None: + state_tags: Optional[Dict[str, str]] = None, + variables: Optional[Dict[str, str]] = None, + variable_notes: Sequence[str] = ()) -> None: self.helper = helper + #: LOCAL DELTA: `${NAME}` -> value, from the flow's fixture, and the + #: fixture's notes — quoted when a flow names a value it did not produce. + self.variables: Dict[str, str] = dict(variables or {}) + self.variable_notes: List[str] = list(variable_notes) self.platform = platform self.artifacts = Path(artifacts) if artifacts else None self.results: List[StepResult] = [] @@ -667,6 +773,17 @@ async def run(self, spec: FlowSpec) -> bool: started = time.monotonic() print(f"\n [{step.step_id}] {step.title}") + # LOCAL DELTA: fill `${NAME}` from the fixture. A value the fixture + # did not produce FAILS the step, optional or not: the flow asked + # for it, and a skip would report a two-node claim as untested + # rather than as broken. + try: + step = resolve_step(step, self.variables, self.variable_notes) + except UnresolvedVariable as exc: + print(f" [FAIL] {exc}") + self.results.append(StepResult(step.step_id, step.title, "fail", "requires", str(exc))) + return False + # BEFORE. A precondition failure is reported as one -- the difference # between "this flow cannot start here" and "this element is broken". pre = await self._check(step.requires, "requires") diff --git a/testing/gate/run_flows.py b/testing/gate/run_flows.py index ac91d795..b31c3917 100644 --- a/testing/gate/run_flows.py +++ b/testing/gate/run_flows.py @@ -49,7 +49,7 @@ import time from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, List, Optional, Sequence +from typing import Any, Callable, List, Optional, Sequence from testing.gate.flow_spec import FlowRunner, FlowSpec, SpecError, check_client_floor, discover @@ -147,7 +147,8 @@ def nav_hops(has_agent: bool) -> tuple[dict, set]: async def run_one(spec: FlowSpec, helper, *, platform=None, artifacts: Optional[Path] = None, client_version: Optional[str] = None, start_timeout: float = 30.0, - hops: Optional[dict] = None, flow_only: frozenset | set = frozenset()) -> FlowOutcome: + hops: Optional[dict] = None, flow_only: frozenset | set = frozenset(), + variables: Optional[dict] = None, variable_notes: Sequence[str] = ()) -> FlowOutcome: """Run one flow. With `hops` (nav_map's Screen -> chain), the runner first WALKS to the flow's starting screen; without, it only waits for it.""" refusal = check_client_floor(spec.client_floor, client_version) @@ -179,7 +180,8 @@ async def run_one(spec: FlowSpec, helper, *, platform=None, artifacts: Optional[ print(f"\n FLOW {spec.flow} ({spec.csd_id}) — CANNOT START\n {err}") return FlowOutcome(spec.flow, spec.csd_id, CANNOT_START, err) - runner = FlowRunner(helper, platform=platform, artifacts=artifacts) + runner = FlowRunner(helper, platform=platform, artifacts=artifacts, + variables=variables, variable_notes=variable_notes) try: ok = await runner.run(spec) except Exception as e: # noqa: BLE001 — a crash in a flow is that flow's verdict @@ -227,12 +229,30 @@ def sign_in(drv, username: str, password: str) -> str: return session_fixture.log_in(drv, username, password) +def fixture_order(specs: Sequence[FlowSpec]) -> List[FlowSpec]: + """Flows with no fixture first, in their order; then each fixture's flows. + + A fixture CHANGES the leg's node — two_node leaves it with a contact and a + room — so a flow asserting the bare node (people.yaml's "no contacts yet") + must run before any fixture does, and must not depend on file order.""" + plain = [s for s in specs if not s.fixture] + return plain + [s for s in specs if s.fixture] + + def run_all(specs: Sequence[FlowSpec], drv, *, platform=None, artifacts: Optional[Path] = None, client_version: Optional[str] = None, username: str = "qaadmin", password: str = "QaAdmin!2345", establish_session: bool = True, - helper: Any = None, navigate_to_start: bool = True) -> List[FlowOutcome]: - """Run every flow in order against one live client. Signs in once, only if - some flow will actually run.""" + helper: Any = None, navigate_to_start: bool = True, + fixtures: Optional[Callable[[str], Any]] = None) -> List[FlowOutcome]: + """Run every flow against one live client. Signs in once, only if some flow + will actually run. + + `fixtures(name)` builds the fixture a flow's `fixture:` asks for (an object + with `up() -> {NAME: value}`, `down()` and `values.notes`). It is stood up + ONCE, just before the first runnable flow that needs it, and torn down in a + `finally` — so only runs whose flows ask for it pay for it, and a failed + flow still cleans up. A fixture that cannot be stood up leaves its flows + `cannot-start`, naming why: red, because they never ran.""" from testing.gate.flow_helper import SyncFlowHelper # noqa: PLC0415 helper = helper or SyncFlowHelper(drv) @@ -253,18 +273,100 @@ def run_all(specs: Sequence[FlowSpec], drv, *, platform=None, artifacts: Optiona mode = "" hops, flow_only = nav_hops(has_agent=mode.upper() == "AGENT") + started: dict = {} # fixture name -> (object, vars, notes) or (None, None, [why]) + + def fixture_for(name: str): + if name not in started: + if fixtures is None: + started[name] = (None, None, [ + f"no `{name}` fixture was provided to this run (run_platform / run_flows " + f"need --node-binary to stand one up)"]) + else: + fx = None + try: + fx = fixtures(name) + got = fx.up() + notes = list(getattr(getattr(fx, "values", None), "notes", []) or []) + started[name] = (fx, got, notes) + print(f"\n fixture {name}: up — {sorted(got)}") + for n in notes: + print(f" note: {n}") + except Exception as e: # noqa: BLE001 — a fixture that cannot stand up is a verdict + if fx is not None: + try: + fx.down() + except Exception: # noqa: BLE001 + pass + started[name] = (None, None, [f"the `{name}` fixture could not be stood up: " + f"{type(e).__name__}: {e}"]) + print(f"\n fixture {name}: UNAVAILABLE — {started[name][2][0]}") + return started[name] + async def go() -> List[FlowOutcome]: - return [await run_one(s, helper, platform=platform, artifacts=artifacts, - client_version=client_version, hops=hops, - flow_only=flow_only) for s in specs] + out: List[FlowOutcome] = [] + for s in fixture_order(specs): + variables, notes = None, () + if s.fixture and not check_client_floor(s.client_floor, client_version): + _, variables, notes = fixture_for(s.fixture) + if variables is None: + print(f"\n FLOW {s.flow} ({s.csd_id}) — CANNOT START\n {notes[0]}") + out.append(FlowOutcome(s.flow, s.csd_id, CANNOT_START, notes[0])) + continue + out.append(await run_one(s, helper, platform=platform, artifacts=artifacts, + client_version=client_version, hops=hops, + flow_only=flow_only, variables=variables, + variable_notes=notes)) + return out - outcomes = asyncio.run(go()) + try: + outcomes = asyncio.run(go()) + finally: + for name, (fx, _, _) in started.items(): + if fx is not None: + try: + fx.down() + print(f" fixture {name}: down") + except Exception as e: # noqa: BLE001 — teardown must not hide the verdict + print(f" fixture {name}: teardown failed: {e}") print(f"\n flows: {summary(outcomes)}") for o in outcomes: print(f" [{o.status:^12}] {o.flow} ({o.csd}): {o.detail}") return outcomes +def add_fixture_args(ap: argparse.ArgumentParser) -> None: + """The flags a `fixture: two_node` flow needs. Shared by run_platform.""" + ap.add_argument("--node-binary", type=Path, + help="the ciris-server binary the leg downloaded; with it, flows that ask " + "for `fixture: two_node` get a second node (testing/gate/two_node.py)") + ap.add_argument("--node-url", default="http://127.0.0.1:4243", + help="the read API of the node the client under test uses, as seen from " + "this host (the fixture signs in to it as the client's owner)") + ap.add_argument("--peer-port", type=int, default=5242, + help="the second node's transport port; its read API is the next one") + ap.add_argument("--peer-work", type=Path, + help="where the second node's home and log go (default: a fresh temp dir)") + + +def fixture_factory(args, leg: str = "") -> Optional[Callable[[str], Any]]: + """A `fixtures(name)` callable for run_all, or None when no binary was given + (then a fixture flow is `cannot-start`, saying so).""" + binary = getattr(args, "node_binary", None) + if not binary: + return None + + def build(name: str): + from testing.gate import two_node # noqa: PLC0415 + if name != two_node.FIXTURE: + raise ValueError(f"unknown fixture {name!r}") + import tempfile # noqa: PLC0415 + work = args.peer_work or Path(tempfile.mkdtemp(prefix=f"ciris-two-node-{leg or 'leg'}-")) + return two_node.TwoNodeFixture(binary, work, node_url=args.node_url, + username=args.username, password=args.password, + port=args.peer_port) + return build + + def main(argv: Optional[List[str]] = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -280,6 +382,7 @@ def main(argv: Optional[List[str]] = None) -> int: ap.add_argument("--password", default="QaAdmin!2345") ap.add_argument("--no-sign-in", action="store_true", help="drive whatever screen the client is on; do not make a session") + add_fixture_args(ap) args = ap.parse_args(argv) try: @@ -300,7 +403,8 @@ def main(argv: Optional[List[str]] = None) -> int: drv.wait_for_server(timeout=30) outcomes = run_all(specs, drv, platform=build_platform(args), artifacts=artifacts, client_version=version, username=args.username, - password=args.password, establish_session=not args.no_sign_in) + password=args.password, establish_session=not args.no_sign_in, + fixtures=fixture_factory(args, leg=args.platform)) except (DriverError, SessionUnavailable) as e: print(f"[FAIL] {e}") return 1 diff --git a/testing/gate/run_platform.py b/testing/gate/run_platform.py index b78db601..056f40f9 100644 --- a/testing/gate/run_platform.py +++ b/testing/gate/run_platform.py @@ -254,6 +254,12 @@ def main() -> int: "repeatable) against the same app — see testing/flows/README.md") ap.add_argument("--client-version", default=None, help="the version under test, for flows' `client:` floors (default: VERSION)") + ap.add_argument("--username", default="qaadmin") + ap.add_argument("--password", default="QaAdmin!2345") + # `fixture: two_node` flows: the leg's own ciris-server binary, and where + # the node the client uses answers from this host (testing/gate/two_node.py). + from testing.gate.run_flows import add_fixture_args + add_fixture_args(ap) args = ap.parse_args() rep = Report(platform=args.platform, node_version=args.node_version) @@ -332,7 +338,9 @@ def flows(drv: TestAutomationServer, rep: Report, specs, args, platform) -> None try: outcomes = run_flows.run_all(specs, drv, platform=platform, artifacts=args.shots / f"flows-{leg}", - client_version=version) + client_version=version, username=args.username, + password=args.password, + fixtures=run_flows.fixture_factory(args, leg=leg)) except SessionUnavailable as e: rep.add("flows", False, f"no session to run them in: {e}") return diff --git a/testing/test_flow_fixtures.py b/testing/test_flow_fixtures.py new file mode 100644 index 00000000..7eaa4478 --- /dev/null +++ b/testing/test_flow_fixtures.py @@ -0,0 +1,215 @@ +"""`${NAME}` substitution and the `fixture:` key — the flow runner's side of the +two-node fixture (testing/gate/two_node.py). Stdlib + PyYAML, no app, no node.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from testing.gate import run_flows +from testing.gate.flow_spec import ( + FlowSpec, + SpecError, + UnresolvedVariable, + resolve_step, + substitute, +) +from testing.test_flows import FakeHelper, _csd_root, _flow + +FIXTURE_FLOW = """\ + flow: receipt + csd: CSD-900 + client: ">=0.5.224" + fixture: two_node + steps: + - step_id: land + title: lands + requires: + screen: Thing + do: + - click: "btn_receipt_${PEER_KEY_ID}" + expect: + visible: ["row_${PEER_KEY_ID}"] + matches: {"row_${PEER_KEY_ID}": "${PEER_KEY_ID}"} +""" + +PLAIN_FLOW = """\ + flow: plain + csd: CSD-900 + client: ">=0.5.224" + steps: + - step_id: land + title: lands + requires: + screen: Thing + expect: + visible: [thing_list] +""" + + +def _load(tmp_path, body, name="f.yaml"): + return FlowSpec.load(_flow(tmp_path, body, name), csd_root=_csd_root(tmp_path)) + + +# ── substitute ────────────────────────────────────────────────────────────── + +def test_a_known_name_is_replaced_everywhere_it_occurs(): + assert substitute("btn_receipt_${K}_${K}", {"K": "ab-1"}) == "btn_receipt_ab-1_ab-1" + + +def test_text_with_no_names_is_returned_untouched(): + assert substitute("btn_contacts_refresh", {}) == "btn_contacts_refresh" + + +def test_a_name_the_fixture_did_not_produce_raises_and_says_why(): + with pytest.raises(UnresolvedVariable, match=r"\$\{MESSAGE_ATTESTATION_ID\}.*did not arrive"): + substitute("chat_msg_${MESSAGE_ATTESTATION_ID}", {"PEER_KEY_ID": "x"}, + ["the peer's message did not arrive within 120s"]) + + +def test_an_empty_value_is_as_missing_as_an_absent_one(): + """An empty key id would turn `btn_receipt_${K}` into `btn_receipt_`, which + a glob-ish reader could mistake for a class of tags. Refused.""" + with pytest.raises(UnresolvedVariable): + substitute("btn_receipt_${K}", {"K": ""}) + + +def test_a_regex_value_is_escaped_so_a_key_id_matches_itself_only(): + assert substitute("^${K}$", {"K": "a.b+c"}, escape=True) == r"^a\.b\+c$" + + +def test_lowercase_dollar_braces_are_not_names(): + """Kotlin's `${peer.keyId}` in a flow's COMMENT-like text is not ours.""" + assert substitute("peer_pick_row_${peer.keyId}", {}) == "peer_pick_row_${peer.keyId}" + + +# ── loading ───────────────────────────────────────────────────────────────── + +def test_a_fixture_flow_loads_and_lists_its_names(tmp_path): + spec = _load(tmp_path, FIXTURE_FLOW) + assert spec.fixture == "two_node" + assert spec.variables() == ["PEER_KEY_ID"] + + +def test_a_name_with_no_fixture_to_fill_it_is_a_load_error(tmp_path): + body = FIXTURE_FLOW.replace(" fixture: two_node\n", "") + with pytest.raises(SpecError, match=r"\$\{PEER_KEY_ID\}.*no `fixture:`"): + _load(tmp_path, body) + + +def test_an_unknown_fixture_is_a_load_error(tmp_path): + with pytest.raises(SpecError, match="fixture: 'three_node'"): + _load(tmp_path, FIXTURE_FLOW.replace("two_node", "three_node")) + + +def test_resolving_a_step_leaves_the_loaded_spec_untouched(tmp_path): + spec = _load(tmp_path, FIXTURE_FLOW) + step = resolve_step(spec.steps[0], {"PEER_KEY_ID": "k1"}) + assert step.do[0].target == "btn_receipt_k1" + assert step.expect.visible == ["row_k1"] + assert step.expect.matches == {"row_k1": "k1"} + assert spec.steps[0].do[0].target == "btn_receipt_${PEER_KEY_ID}" + + +# ── running ───────────────────────────────────────────────────────────────── + +def _run(spec, helper, **kw): + return asyncio.run(run_flows.run_one(spec, helper, client_version="0.5.224", + start_timeout=0, **kw)) + + +def test_the_runner_clicks_the_tag_the_fixture_named(tmp_path): + helper = FakeHelper("Thing", {"btn_receipt_k1": "", "row_k1": "k1"}) + out = _run(_load(tmp_path, FIXTURE_FLOW), helper, variables={"PEER_KEY_ID": "k1"}) + assert out.status == run_flows.PASS, out.detail + assert "click btn_receipt_k1" in helper.calls + + +def test_a_missing_value_fails_the_step_naming_the_fixtures_reason(tmp_path): + helper = FakeHelper("Thing", {"btn_receipt_k1": ""}) + out = _run(_load(tmp_path, FIXTURE_FLOW), helper, variables={}, + variable_notes=["the peer's owner key never crossed"]) + assert out.status == run_flows.CANNOT_START # the first step's requires + assert "PEER_KEY_ID" in out.detail and "never crossed" in out.detail + assert not any(c.startswith("click") for c in helper.calls), "nothing literal was clicked" + + +def test_a_missing_value_fails_even_an_optional_step(tmp_path): + """A flow that asked for a two-node value and did not get it is broken, + not 'not applicable here'.""" + body = FIXTURE_FLOW.replace(" title: lands\n", " title: lands\n optional_step: true\n") + out = _run(_load(tmp_path, body), FakeHelper("Thing", {}), variables={}) + assert out.status != run_flows.PASS + + +# ── run_all: who pays for the fixture, and when ───────────────────────────── + +class _Fixture: + def __init__(self, log, values=None, fail=None): + self.log, self._values, self.fail = log, values, fail + self.values = type("V", (), {"notes": ["a note"]})() + + def up(self): + self.log.append("up") + if self.fail: + raise RuntimeError(self.fail) + return dict(self._values or {}) + + def down(self): + self.log.append("down") + + +def _all(specs, helper, fixtures): + return run_flows.run_all(specs, None, client_version="0.5.224", helper=helper, + establish_session=False, navigate_to_start=False, + fixtures=fixtures) + + +def test_plain_flows_run_before_any_fixture_whatever_the_file_order(tmp_path): + fx = _load(tmp_path, FIXTURE_FLOW, "a.yaml") + plain = _load(tmp_path, PLAIN_FLOW, "b.yaml") + assert [s.flow for s in run_flows.fixture_order([fx, plain])] == ["plain", "receipt"] + + +def test_a_run_with_no_fixture_flow_never_stands_one_up(tmp_path): + log: list = [] + _all([_load(tmp_path, PLAIN_FLOW)], FakeHelper("Thing", {"thing_list": ""}), + lambda name: _Fixture(log)) + assert log == [] + + +def test_the_fixture_is_up_once_and_down_after_even_when_a_flow_fails(tmp_path): + log: list = [] + specs = [_load(tmp_path, FIXTURE_FLOW, "a.yaml"), + _load(tmp_path, FIXTURE_FLOW.replace("flow: receipt", "flow: receipt2"), "b.yaml")] + outs = _all(specs, FakeHelper("Thing", {}), lambda name: _Fixture(log, {"PEER_KEY_ID": "k1"})) + assert log == ["up", "down"] + assert all(o.status == run_flows.FAIL for o in outs) + + +def test_a_fixture_that_cannot_stand_up_leaves_its_flows_cannot_start(tmp_path): + log: list = [] + specs = [_load(tmp_path, PLAIN_FLOW, "p.yaml"), _load(tmp_path, FIXTURE_FLOW, "f.yaml")] + outs = _all(specs, FakeHelper("Thing", {"thing_list": ""}), + lambda name: _Fixture(log, fail="no claim PIN")) + by = {o.flow: o for o in outs} + assert by["plain"].status == run_flows.PASS + assert by["receipt"].status == run_flows.CANNOT_START + assert "no claim PIN" in by["receipt"].detail + assert log == ["up", "down"], "a half-started fixture is still torn down" + assert not run_flows.leg_ok(outs) + + +def test_no_fixture_provider_is_cannot_start_saying_what_was_missing(tmp_path): + outs = _all([_load(tmp_path, FIXTURE_FLOW)], FakeHelper("Thing", {}), None) + assert outs[0].status == run_flows.CANNOT_START + assert "--node-binary" in outs[0].detail + + +def test_a_refused_fixture_flow_does_not_stand_the_fixture_up(tmp_path): + log: list = [] + spec = _load(tmp_path, FIXTURE_FLOW.replace('">=0.5.224"', '"unreleased"')) + outs = _all([spec], FakeHelper("Thing", {}), lambda name: _Fixture(log)) + assert outs[0].status == run_flows.REFUSED + assert log == [], "a flow its floor refuses must not cost a second node" diff --git a/testing/test_flows.py b/testing/test_flows.py index 1fad18b4..2b068322 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -248,6 +248,11 @@ def _client_tag_strings() -> tuple[set[str], set[str]]: def client_carries(tag: str, literals: set[str], prefixes: set[str]) -> bool: + # A fixture-filled tag (`btn_receipt_${PEER_KEY_ID}`): its literal head must + # BE one of the client's interpolated prefixes — the client builds exactly + # `"btn_receipt_$keyId"` — not merely start like one. + if "${" in tag: + return tag.split("${", 1)[0] in prefixes return tag in literals or any(tag.startswith(p) for p in prefixes) @@ -259,6 +264,9 @@ def client_carries(tag: str, literals: set[str], prefixes: set[str]) -> bool: ("opt_run_with_ai", True), # a whole literal still matches ("contacts_no_such_tag", False), ("btn_no_such_button", False), # a one-segment prefix vouches for nothing + ("btn_receipt_${PEER_KEY_ID}", True), # "btn_receipt_$keyId" PeopleSupport.kt + ("chat_msg_${MESSAGE_ATTESTATION_ID}", True), + ("btn_no_such_${PEER_KEY_ID}", False), # a fixture value vouches for nothing either ]) def test_the_client_tag_check_sees_interpolated_tags_and_nothing_else(tag, carried): assert client_carries(tag, *_client_tag_strings()) is carried From 8eeb4db912b2327a268b5d0da406d71e54429afe Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 28 Sep 2026 21:14:35 -0500 Subject: [PATCH 3/6] ci(live-qa): every leg can stand up the two-node peer, and tears it down under always() - Every run_platform call (Linux, Android, macOS, iOS, Windows) passes --node-binary node/ciris-server and its own --peer-work under $RUNNER_TEMP. The peer starts only if a loaded, unrefused flow says `fixture: two_node`. - Reachability: the fixture runs on the leg's host and talks to both nodes on loopback. Desktop legs use 127.0.0.1:4243; the Android emulator reaches the same host node through the existing adb reverse and never dials the peer; the iOS app's embedded node shares the host loopback, so it is 127.0.0.1:4243 from the fixture too, and the peer's 5242/5243 do not collide with it. - Linux: the Android leg shares the desktop leg's node. When the desktop fixture seeded it (values.json exists), the node is stopped and the Android leg gets a fresh one on a fresh home, so its bare-node flows are not red for the desktop's reason. - Each job runs `two_node down` for its legs under always() (the runner's `finally` and SIGTERM handler do not survive a SIGKILL), and uploads each peer's log and values.json. - test_five_platform_workflow: the flags on every call, the always() teardown per leg, and the fresh node's position between the two Linux legs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- .github/workflows/five-platform-live-qa.yml | 89 +++++++++++++++++++-- testing/test_five_platform_workflow.py | 62 ++++++++++++++ 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/.github/workflows/five-platform-live-qa.yml b/.github/workflows/five-platform-live-qa.yml index fb4f218c..b19c0c0d 100644 --- a/.github/workflows/five-platform-live-qa.yml +++ b/.github/workflows/five-platform-live-qa.yml @@ -29,6 +29,17 @@ # FSD/ONE_CLIENT_N_NODES.md describes and is why no Android node binary is # needed — CIRISServer publishes none. # +# A SECOND NODE, ONLY WHEN A FLOW ASKS. A flow with `fixture: two_node` (a +# receipt to open, a peer to pick, a room with a real message) gets a second +# ciris-server on the same runner — the binary this leg already downloaded, +# started natively on 5242/5243 with its own --home under $RUNNER_TEMP and a +# unique --key-id — claimed, announced, peered with the leg's node, and seeded +# with a contact each way and one chat message (testing/gate/two_node.py, the +# Docker-free port of CIRISServer harness/mesh-repro/scenarios/chat.sh). Every +# leg passes --node-binary; a run with no such flow never starts it. Each job +# tears it down under always(), because the flow runner's `finally` does not +# survive a SIGKILL. +# # :4243 AND NOT :8080. A bare ciris-server has no brain and never binds the # agent's port; see .github/actions/ciris-node. This comment said 8080 for the # whole life of the gate, and so did every probe and forward under it. @@ -241,7 +252,37 @@ jobs: python3 -m testing.gate.run_platform --platform desktop --xvfb \ --jar "${{ steps.art.outputs.jar }}" \ --node-version "${{ steps.node.outputs.version }}" \ - --shots shots --report reports/linux.json --flows testing/flows + --shots shots --report reports/linux.json --flows testing/flows \ + --node-binary node/ciris-server --peer-work "$RUNNER_TEMP/two-node-linux" + + # THE ANDROID LEG SHARES THIS NODE, AND THE FIXTURE CHANGED IT. A + # `fixture: two_node` flow on the desktop leg leaves the node with a + # contact and a room; the Android leg's bare-node flows ("People with no + # contacts yet") would then fail for the desktop's reason. So when the + # fixture ran (it writes values.json), the Android leg gets a fresh node + # on a fresh home, and claims it through its own wizard like any first run. + - name: Was the node seeded by the two-node fixture? + id: seeded + if: always() + run: | + python3 -m testing.gate.two_node down --work "$RUNNER_TEMP/two-node-linux" || true + if [ -f "$RUNNER_TEMP/two-node-linux/values.json" ]; then + echo "seeded=true" >> "$GITHUB_OUTPUT" + for port in 4242 4243; do + pids=$(sudo lsof -ti "tcp:$port" -sTCP:LISTEN 2>/dev/null || true) + [ -n "$pids" ] && { echo "stopping the seeded node on :$port ($pids)"; sudo kill $pids || true; } + done + sleep 2 + else + echo "seeded=false" >> "$GITHUB_OUTPUT" + fi + + - name: A fresh node for the Android leg + if: steps.seeded.outputs.seeded == 'true' + uses: ./.github/actions/ciris-node + with: + home: ${{ runner.temp }}/ciris-home-android + log: node-android.log # WITHOUT THIS THE EMULATOR RUNS IN SOFTWARE AND DIES. # @@ -301,7 +342,17 @@ jobs: # # after the emulator had booted and the whole leg had been paid for. # It reads worse on one line and it is the form that runs. - script: python3 -m testing.gate.run_platform --platform android --apk "${{ steps.art.outputs.apk }}" --node-version "${{ steps.node.outputs.version }}" --shots shots --report reports/android.json --flows testing/flows; rc=$?; adb logcat -d > logcat.txt 2>&1; exit $rc + script: python3 -m testing.gate.run_platform --platform android --apk "${{ steps.art.outputs.apk }}" --node-version "${{ steps.node.outputs.version }}" --shots shots --report reports/android.json --flows testing/flows --node-binary node/ciris-server --peer-work "$RUNNER_TEMP/two-node-android"; rc=$?; adb logcat -d > logcat.txt 2>&1; exit $rc + + # THE PEER DIES WITH THE JOB, WHATEVER KILLED IT. The runner's `finally` + # and its SIGTERM handler cover a failed flow and a cancelled step; this + # covers the rest, by the pidfile each leg's fixture wrote. + - name: Tear down the two-node peers + if: always() + run: | + for leg in linux android; do + python3 -m testing.gate.two_node down --work "$RUNNER_TEMP/two-node-$leg" || true + done # ALWAYS. A failure you cannot diagnose from the artifact costs a re-run # to learn what this run already knew. @@ -313,8 +364,11 @@ jobs: shots/ reports/ node.log + node-android.log *-app.log logcat.txt + ${{ runner.temp }}/two-node-*/peer.log + ${{ runner.temp }}/two-node-*/values.json macos-ios: name: macos desktop + ios simulator @@ -373,7 +427,8 @@ jobs: python3 -m testing.gate.run_platform --platform desktop \ --jar "$(python3 -m testing.gate.candidate_artifacts --kind desktop | tail -1)" \ --node-version "${{ steps.node.outputs.version }}" \ - --shots shots --report reports/macos.json --flows testing/flows + --shots shots --report reports/macos.json --flows testing/flows \ + --node-binary node/ciris-server --peer-work "$RUNNER_TEMP/two-node-macos" # ── THE iOS BUNDLE, MATERIALIZED THE WAY THE AGENT'S GATE DOES IT ────── # @@ -655,10 +710,17 @@ jobs: echo "simulator: $UDID" xcrun simctl boot "$UDID" || true xcrun simctl bootstatus "$UDID" -b + # The macOS leg's peer, if its runner was killed before `finally`: + # 5242/5243 must be free for this leg's own. + python3 -m testing.gate.two_node down --work "$RUNNER_TEMP/two-node-macos" || true rc=0 + # The simulator shares the runner's loopback, so the node the app + # embeds is 127.0.0.1:4243 from here too — the fixture's --node-url + # default — and the peer on 5242/5243 does not collide with it. python3 -m testing.gate.run_platform --platform ios --app "$app" --udid "$UDID" \ --node-version "${{ steps.node.outputs.version }}" \ - --shots shots --report reports/ios.json --flows testing/flows || rc=$? + --shots shots --report reports/ios.json --flows testing/flows \ + --node-binary node/ciris-server --peer-work "$RUNNER_TEMP/two-node-ios" || rc=$? # THE APP'S OWN ACCOUNT, EITHER WAY. Run 35359571538 got the iOS app # to a real screen — "Engine Failed to Start: server did not become @@ -709,6 +771,13 @@ jobs: fi exit $rc + - name: Tear down the two-node peers + if: always() + run: | + for leg in macos ios; do + python3 -m testing.gate.two_node down --work "$RUNNER_TEMP/two-node-$leg" || true + done + - if: always() uses: actions/upload-artifact@v4 with: @@ -719,6 +788,8 @@ jobs: node.log *-app.log ios-logs/ + ${{ runner.temp }}/two-node-*/peer.log + ${{ runner.temp }}/two-node-*/values.json client/iosApp/app_packages_native_sim/MANIFEST.txt client/iosApp/substrate.lock.json @@ -762,7 +833,13 @@ jobs: python3 -m testing.gate.run_platform --platform desktop \ --jar "$(python3 -m testing.gate.candidate_artifacts --kind desktop | tail -1)" \ --node-version "${{ steps.node.outputs.version }}" \ - --shots shots --report reports/windows.json --flows testing/flows + --shots shots --report reports/windows.json --flows testing/flows \ + --node-binary node/ciris-server --peer-work "$RUNNER_TEMP/two-node-windows" + + - name: Tear down the two-node peer + if: always() + shell: bash + run: python3 -m testing.gate.two_node down --work "$RUNNER_TEMP/two-node-windows" || true - if: always() uses: actions/upload-artifact@v4 @@ -773,6 +850,8 @@ jobs: reports/ node.log *-app.log + ${{ runner.temp }}/two-node-*/peer.log + ${{ runner.temp }}/two-node-*/values.json gallery: name: screenshot gallery diff --git a/testing/test_five_platform_workflow.py b/testing/test_five_platform_workflow.py index 503a67a1..0b10f3bf 100644 --- a/testing/test_five_platform_workflow.py +++ b/testing/test_five_platform_workflow.py @@ -311,3 +311,65 @@ def test_the_emulator_script_has_no_line_continuations(): assert "\\" not in script, ( "a line continuation in `script:` reaches the runner as a literal argument" ) + + +# ── the two-node fixture (testing/gate/two_node.py) ───────────────────────── + +import re # noqa: E402 + +#: Each run_platform invocation and the peer work dir it must name. +_PLATFORM_LEGS = { + "linux-android": ("two-node-linux", "two-node-android"), + "macos-ios": ("two-node-macos", "two-node-ios"), + "windows": ("two-node-windows",), +} + + +def _run_platform_calls(job: dict) -> list[str]: + """Every `run_platform` command line in a job, continuations joined.""" + calls = [] + for step in job["steps"]: + for text in (step.get("run"), (step.get("with") or {}).get("script")): + if not text: + continue + joined = re.sub(r"\\\n\s*", " ", str(text)) + calls += [ln for ln in joined.splitlines() if "testing.gate.run_platform" in ln] + return calls + + +@pytest.mark.parametrize("leg", LEGS) +def test_every_platform_can_stand_up_the_second_node(wf, leg): + """A `fixture: two_node` flow on a leg with no --node-binary is + cannot-start: red, but for the gate's reason, not the client's.""" + calls = _run_platform_calls(wf["jobs"][leg]) + assert len(calls) == len(_PLATFORM_LEGS[leg]), calls + for call, work in zip(calls, _PLATFORM_LEGS[leg]): + assert "--node-binary node/ciris-server" in call, f"{leg}: {call}" + assert f'--peer-work "$RUNNER_TEMP/{work}"' in call, f"{leg}: {call}" + + +@pytest.mark.parametrize("leg", LEGS) +def test_every_peer_is_torn_down_even_when_the_leg_failed(wf, leg): + """The runner's `finally` does not survive a SIGKILL; an always() step does.""" + teardown = [s for s in wf["jobs"][leg]["steps"] + if "testing.gate.two_node down" in str(s.get("run", "")) + and str(s.get("if", "")).strip() == "always()"] + assert teardown, f"{leg} never tears its peer down under always()" + body = " ".join(str(s["run"]) for s in teardown) + for work in _PLATFORM_LEGS[leg]: + leg_name = work.removeprefix("two-node-") + assert work in body or leg_name in body, f"{leg}: {work} is never torn down" + + +def test_the_android_leg_gets_a_fresh_node_when_the_fixture_seeded_the_shared_one(wf): + """The desktop leg's fixture leaves the shared node with a contact; the + Android leg's bare-node flows must not inherit it.""" + steps = wf["jobs"]["linux-android"]["steps"] + names = [s.get("name", "") for s in steps] + fresh = next(s for s in steps if s.get("name") == "A fresh node for the Android leg") + assert "seeded" in str(fresh.get("if", "")) + assert fresh["uses"] == "./.github/actions/ciris-node" + assert fresh["with"]["home"] != "", "a fresh node needs a fresh home" + assert names.index("A fresh node for the Android leg") < names.index("Android emulator") + assert names.index("Linux desktop") < names.index("A fresh node for the Android leg") + From cd308ed71820ea692f8844d1d170a4ad35a106fc Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 28 Sep 2026 21:33:20 -0500 Subject: [PATCH 4/6] test(flows): CSD-005/006/047/091 name the two-node fixture's values instead of skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - csd-006-receipt: refreshes People, opens `btn_receipt_${PEER_KEY_ID}` and asserts the five facts, the wire dimension and the wire rule, then closes. - csd-005-people: the populated row, its trust chip and hamburger by key id, and the receipt step now clicks the seeded contact's hamburger. - csd-047-network-content: enters from the hub's `tile_federation_content` (NetworkContent has no nav hop) and picks `peer_pick_row_${PEER_NODE_KEY_ID}`. - csd-091-user-chat: enters the room from People by `btn_contacts_chat_${PEER_KEY_ID}` and asserts the peer's message by its attestation id — a value the leg's node cannot mint for itself. Measured on the Linux desktop leg, locally, 2026-09-28 (candidate 0.5.224 run as 0.5.225, node v0.5.217, leg node on 5142/5143, peer on 5242/5243): people 3/3; csd_006 pass (5/6, grant-less step skipped as designed); csd_005 row/chip/hamburger/receipt pass, then fails at btn_scan_contact_code (the desktop shows btn_scan_contact_code_status — unrelated to the fixture); csd_091 entry/composer/refresh pass, history fails naming why; csd_047 cannot start (nav_map's hop to LayerGlobalCommons stops on CircleTab). Why 091 cannot pass on the released line is recorded in evidence/blocked_upstream.tsv: two unconferred v0.5.217 nodes admit each other ADVISORY, frames fail the SignedTransportDestination check, and the pair room never keys in 480 s. CSD §5 lines and both flow READMEs say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- FSD/CSD/CSD-005-people.md | 2 +- FSD/CSD/CSD-006-receipt.md | 2 +- FSD/CSD/CSD-047-network-content.md | 2 +- FSD/CSD/CSD-091-user-chat.md | 2 +- evidence/blocked_upstream.tsv | 1 + testing/flows/README.md | 77 ++++++++++++++++++- testing/flows/drafts/README.md | 28 ++++++- testing/flows/drafts/csd-005-people.yaml | 52 ++++++++----- testing/flows/drafts/csd-006-receipt.yaml | 60 +++++++++------ .../flows/drafts/csd-047-network-content.yaml | 44 +++++++---- testing/flows/drafts/csd-091-user-chat.yaml | 54 +++++++++---- 11 files changed, 244 insertions(+), 80 deletions(-) diff --git a/FSD/CSD/CSD-005-people.md b/FSD/CSD/CSD-005-people.md index 026e44ba..3ae17f53 100644 --- a/FSD/CSD/CSD-005-people.md +++ b/FSD/CSD/CSD-005-people.md @@ -219,7 +219,7 @@ again. On a fresh node with no contacts → `card_contacts_add` and no ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-005-people.yaml`, floor `>=0.5.225`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97). +Spec complete and flow written (`testing/flows/drafts/csd-005-people.yaml`, floor `>=0.5.225`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97) The populated list and the receipt step are no longer optional: `fixture: two_node` seeds the contact, and on the Linux desktop leg (2026-09-28) the row, its trust chip, its hamburger and the five-fact receipt all passed. The flow then failed at `the_add_card_opens_with_paste_and_scan`: the desktop add card shows `btn_scan_contact_code_status`, not `btn_scan_contact_code` — a defect in that step, unrelated to the fixture. **Platforms.** All five. The Contacts entry screen is what CIRISAgent's five-platform gate leans on; no tag it drives has changed. diff --git a/FSD/CSD/CSD-006-receipt.md b/FSD/CSD/CSD-006-receipt.md index 2fbdd0b1..4e68132d 100644 --- a/FSD/CSD/CSD-006-receipt.md +++ b/FSD/CSD/CSD-006-receipt.md @@ -143,7 +143,7 @@ Bound per surface; CSD-005 §4 is the first instance. ## 5. QA plan -**Flow not complete.** `testing/flows/drafts/csd-006-receipt.yaml` (floor `>=0.5.225`) never opens a receipt: the hamburger's tag is `btn_receipt_`, a flow `click:` takes one literal tag, and no fixture seeds a contact whose key id the flow could name. Every fact step is therefore gated on `sheet_receipt` and always skips. It is complete when a seeded contact (or a runner that can click the first match of `btn_receipt_*`) lets it open a concrete receipt; until then this card is not ready to promote. +Spec complete and flow written (`testing/flows/drafts/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`). The two-node fixture (`testing/gate/two_node.py`) seeds a contact, and the flow opens `btn_receipt_${PEER_KEY_ID}` and asserts all five facts, the wire dimension and the wire rule (`chat:`). Run locally on the Linux desktop leg 2026-09-28 (candidate 0.5.224 checked as 0.5.225, node v0.5.217): 5/6 passed, the grant-less step skipped as designed because the node sends the grant. Not yet run on the other four legs; promotes when the floor is met and it runs on the matrix. A card CSD that binds this template asserts `visible:` on all five `receipt_*` tags after clicking its `btn_receipt_`; a card whose rows are furniture diff --git a/FSD/CSD/CSD-047-network-content.md b/FSD/CSD/CSD-047-network-content.md index 50ce9c4b..4a374787 100644 --- a/FSD/CSD/CSD-047-network-content.md +++ b/FSD/CSD/CSD-047-network-content.md @@ -108,7 +108,7 @@ expect: ## 5. QA plan -**Flow not complete.** `testing/flows/drafts/csd-047-network-content.yaml` (floor `unreleased`) never reaches the digest step: that needs a peer picked by `peer_pick_row_`, a `click:` takes one literal tag, and no fixture seeds a peer whose key id the flow could name. The digest steps are gated on `input_content_id` and always skip, so the flow is green without driving the half this card is about. It is complete when a seeded peer lets it pick one. Until then this card is not ready to promote. +Spec complete and flow written (`testing/flows/drafts/csd-047-network-content.yaml`, floor `unreleased`, `fixture: two_node`): it enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. It has NOT run: besides the floor, the runner cannot reach its first screen on this build — nav_map's hop to LayerGlobalCommons (`circle_global_commons -> tab_rules -> nav_epistemic_layer_global_commons`) stops on CircleTab with the last tag never appearing (Linux desktop, 2026-09-28). A real fetch still needs a digest the peer holds, which the fixture does not seed. **Platforms.** All five, as the node's owner. A real fetch needs a second node holding a known digest; the matrix stands one up. diff --git a/FSD/CSD/CSD-091-user-chat.md b/FSD/CSD/CSD-091-user-chat.md index 6b758fdc..e65294e1 100644 --- a/FSD/CSD/CSD-091-user-chat.md +++ b/FSD/CSD/CSD-091-user-chat.md @@ -294,7 +294,7 @@ and §5 disclaims it for the matrix. ## 5. QA plan -**Flow not complete.** `testing/flows/drafts/csd-091-user-chat.yaml` (floor `>=0.5.225`) cannot go green on an ordinary matrix run as written: `a_room_with_history` is gated only on `chat_transcript`, which also renders for a room holding nothing but a system note (the single-node `awaiting_peer` room, `ChatScreen.kt`) and for a refusal over an empty room; `SystemNoteRow` carries no tag, so `count: chat_msg_* min 1` fails on the ordinary run and nothing on screen can gate it. It is complete when system notes are tagged or a two-node fixture seeds a message. Until then this card is not ready to promote. +Spec complete and flow written (`testing/flows/drafts/csd-091-user-chat.yaml`, floor `>=0.5.225`, `fixture: two_node`): it enters the room from People by `btn_contacts_chat_${PEER_KEY_ID}` and asserts the peer's message by its attestation id (`chat_msg_${MESSAGE_ATTESTATION_ID}`), not a class count a system note could satisfy. It CANNOT go green on the released line: two unconferred v0.5.217 nodes never key the pair room (peers admitted ADVISORY, frames fail the SignedTransportDestination check), so no message crosses and `a_room_with_history` fails naming why (`evidence/blocked_upstream.tsv`). Linux desktop, 2026-09-28: the entry, composer and refresh steps passed; history failed as stated. **Platforms.** All five for the transcript and the refusals; **two nodes** for anything that involves the other side, which `testing/gate/node_fixture.py` does diff --git a/evidence/blocked_upstream.tsv b/evidence/blocked_upstream.tsv index 341e23fa..eff74edd 100644 --- a/evidence/blocked_upstream.tsv +++ b/evidence/blocked_upstream.tsv @@ -30,3 +30,4 @@ issue repo scan_root glob needle files lines kind predicate 471 CIRISAgent . requirements*.txt ciris-client 0 0 absence THE MIGRATION ITSELF. Neither consumer depends on the client package yet; both carry their own ~200k-line copy. The extraction is not done when this repo has the source — it is done when this needle scores 1 in CIRISAgent AND in CIRISServer, and their client/ directories are deleted. Until then this repo is a THIRD tree, which is the cost AGENTS.md warned about and the reason it is worth paying only if it ends. Adoption issues filed 2026-08-20: CIRISServer#471 and CIRISAgent#1089. 379 CIRISServer client *.gradle.kts proguard|minifyEnabled 0 0 absence MEASURED in CIRISClient#1: the desktop uber-jar is 66.48 MiB and compresses to a 65,488,254-byte wheel - 62.5% of PyPI's 104,857,600-byte limit on its own, because ProGuard is blocked on ktor 3.x. That is why a node build and an agent build could not ship in ONE wheel — two ~63 MiB desktop bundles do not fit — and it is half the argument CIRISServer#479 settled by deleting the flavor outright: one artifact ships and narrows itself against the probed node. The localization bundles inside are the product and are never cut for size. packaging/check_wheel_size.py measures it every build. 574 CIRISServer src *.rs run_without_ai 0 0 absence A NODE INSTALLED TO RUN WITHOUT AI STILL REPORTS `agent.folded=true`. /v1/system/health carries data.agent.{folded,reachable}, and on a run-without-AI install the fold is reported present and never becomes reachable — so clientModeFrom() returns undetermined forever and the client retries for its whole StartupBudget (60s on Android, measured as 62s of nothing drivable before Login; CIRISClient#48). The client CANNOT contradict it: /v1/setup/status returns only {setup_required, has_env_file, has_admin_user}, and run_without_ai is accepted by /v1/setup/complete but never read back, so nothing on the wire distinguishes 'a brain that is slow to answer' from 'a brain that was never going to'. CIRISClient mitigated the COST (the retry no longer blocks startup routing — CIRISApp.kt commitGate) but cannot fix the SIGNAL. The obligation lands when the node either reports folded=false for a run-without-AI install or exposes run_without_ai on /v1/setup/status; either makes this needle score >0 in the server tree. Filed as CIRISServer#574; either fix closes it (report folded=false for a run-without-AI install, or expose run_without_ai on /v1/setup/status). Counts are 0/0 by inspection of the shipped SetupStatusData contract, NOT measured against a CIRISServer checkout: that tree is not present here. +- CIRISServer - - - - - untestable TWO RELEASED NODES CANNOT KEY A PAIR ROOM. Measured 2026-09-28 with testing/gate/two_node.py on v0.5.217 (the binary every live-QA leg downloads): claim, announce, peering both ways (production self-key-record), owner-key replication and POST /v1/contacts all succeed, but the room stays chat.state.awaiting_peer for 480 s — each node admits the other ADVISORY (not conferred), inbound frames fail 'no hybrid-verified SignedTransportDestination binds this (peer, dest) pair' (CIRISEdge#393 item 2), and the joiner's KeyPackage never replicates. CIRISServer's harness/mesh-repro chat ladder is green only on a test-anchor build with a test trust root (test-blessed-self-record / test-admit-peer are cfg(feature=test-anchor)). So CSD-091's 'a room with a real message' cannot pass on the matrix. Needs filing upstream: a way for two unconferred released nodes to key a pair room, or a released test-root knob for harnesses. Closes when two_node.py reports message_arrived=true against a released binary. diff --git a/testing/flows/README.md b/testing/flows/README.md index 7b97996b..ce19cb8a 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -137,6 +137,77 @@ bring-up per leg and one session. with "no nav hop for Screen.X"; a flow-only screen (pre-login, wizards, leaves) is waited for, not walked to. Hops between later steps are the flow's own `do:` clicks. -- **Only what a bare node can show.** The matrix stands up one node with no - contacts, no agent and no peers, so CSD-005's populated list and receipt sheet - are not driven here. +- **A second node only when a flow asks.** The matrix stands up one node with + no contacts, no agent and no peers. A flow that needs more says + `fixture: two_node` — see below. Everything in this directory today runs on + the bare node. +- **No cross-node message on released nodes.** The two-node fixture seeds a + contact each way and opens the room, but between two fresh, unconferred + nodes of the released line (0.5.217) the room never keys, so no message + crosses; see "Two-node flows". + +## Two-node flows: `fixture: two_node` and `${NAME}` + +```yaml +fixture: two_node +steps: + - step_id: open_the_receipt + title: The seeded contact's receipt opens + do: + - click: "btn_receipt_${PEER_KEY_ID}" # QUOTED: `${` is YAML flow syntax + expect: + visible: ["contacts_row_${PEER_KEY_ID}", sheet_receipt] +``` + +`testing/gate/two_node.py` is CIRISServer's `harness/mesh-repro/scenarios/chat.sh` +without Docker: a second `ciris-server` from the binary the leg downloaded, run +natively on 5242/5243 with its own `--home` and a unique `--key-id`, claimed on +its console, announced, peered both ways with the leg's node (which the client +claimed; the fixture signs in to it as `qaadmin`), each owner added as the +other's contact, the pair room opened on both sides, and one message sent by +the peer once the room is keyed. It runs on every leg because it runs on the +leg's HOST — the client only ever talks to its own node. + +The values a flow may name: + +| name | what | +|---|---| +| `PEER_KEY_ID` | the key the leg's node holds the contact under (the peer's owner; the peer NODE if the owner key never crossed) | +| `PEER_NODE_KEY_ID` / `PEER_OWNER_KEY_ID` | the peer's node and owner fed-IDs | +| `PEER_CONTACT_CODE` | the peer's contact code — only from a node that serves `GET /v1/self/contact-code` (0.5.218+) | +| `LOCAL_NODE_KEY_ID` / `LOCAL_OWNER_KEY_ID` | the leg's node and its owner | +| `ROOM_ID` | the pair room's community id | +| `MESSAGE_ATTESTATION_ID` / `MESSAGE_TEXT` | the peer's message — only if it ARRIVED on the leg's node | +| `PEER_URL` | the peer's read API, from the host | + +The rules, each enforced by `testing/test_flow_fixtures.py`: + +- A `${NAME}` in a flow with no `fixture:` is a load error. +- A value the fixture did not produce fails the step — optional or not — and + the failure quotes the fixture's own note for why. A message that did not + cross is not handed over, so a local-only row cannot pass for a conversation. +- Flows with no fixture run first, whatever the file order: the fixture changes + the leg's node, and `people.yaml` asserts the bare one. +- The fixture stands up once per leg, only before the first runnable flow that + asks for it, and is torn down in a `finally`; the workflow also runs + `python3 -m testing.gate.two_node down --work ` under `always()`. +- `run_platform` / `run_flows` need `--node-binary` (the workflow passes the + leg's `node/ciris-server`); without it a fixture flow is `cannot-start`. + +Locally, against the throwaway node above: + +```bash +python3 -m testing.gate.run_flows --platform desktop \ + --flows testing/flows/drafts/csd-006-receipt.yaml --client-version 0.5.225 \ + --node-binary /tmp/node/ciris-server \ + --node-url http://127.0.0.1:4243 --peer-work /tmp/flows-peer +``` + +**What it cannot do on the released line.** Measured against v0.5.217 on +2026-09-28: peering, the announce, the owner keys and the contacts all cross, +but the room never keys — the two nodes admit each other `ADVISORY — not +conferred`, frames fail the "SignedTransportDestination binds this (peer, +dest)" check, and the joiner's KeyPackage never replicates, in 480 s of +waiting. CIRISServer's own chat ladder is green only on a `test-anchor` build +with a test trust root, which a leg's released binary is not. A flow naming +`${MESSAGE_ATTESTATION_ID}` therefore fails on the matrix today, saying so. diff --git a/testing/flows/drafts/README.md b/testing/flows/drafts/README.md index c2e9f944..79e393d7 100644 --- a/testing/flows/drafts/README.md +++ b/testing/flows/drafts/README.md @@ -95,11 +95,35 @@ None moved, for one reason common to all six and one extra for `csd-036`: - **`csd-036` is still floored `client: "unreleased"`**, so it would be refused on every leg even once it loads. +## Flows that need a second node: `fixture: two_node` + +Four drafts name values only a second node can produce — a contact's key id, a +peer to pick, a message's attestation id — and say `fixture: two_node`. The +runner then stands a second `ciris-server` up beside the leg's node and seeds +it before the first of them runs (`testing/gate/two_node.py`; the file format +and the `${NAME}` values are in `testing/flows/README.md`, "Two-node flows"). +Every leg of `five-platform-live-qa.yml` passes `--node-binary`, so promoting +one of these costs nothing more than `git mv`; a run whose flows do not ask for +the fixture never starts it. + +| file | fixture values it names | where it stands (Linux desktop, locally, 2026-09-28, candidate 0.5.224 checked as 0.5.225, node v0.5.217) | +|---|---|---| +| `csd-005-people.yaml` | `PEER_KEY_ID` — the seeded contact's row, trust chip and receipt | row, chip, hamburger and five-fact receipt passed; fails later at an unrelated scan-button tag (§5) | +| `csd-006-receipt.yaml` | `PEER_KEY_ID` — opens `btn_receipt_` and asserts the five facts | **pass**, 5/6 with the grant-less step skipped as designed | +| `csd-047-network-content.yaml` | `PEER_NODE_KEY_ID` — picks `peer_pick_row_`; enters from the hub tile, since NetworkContent has no nav hop | floored `unreleased`, so refused on the matrix; a copy floored at the candidate could not start locally — nav_map's hop to LayerGlobalCommons stops on CircleTab | +| `csd-091-user-chat.yaml` | `PEER_KEY_ID` to enter the room from People; `MESSAGE_ATTESTATION_ID` / `MESSAGE_TEXT` for the row | **cannot pass on released nodes**: two unconferred v0.5.217 nodes never key the pair room, so no message crosses and `a_room_with_history` fails naming why (`evidence/blocked_upstream.tsv`) | + +The drafts are floored `>=0.5.225` while `VERSION` is `0.5.224`, so on today's +matrix they would be refused even if promoted; they were exercised locally with +`--client-version 0.5.225` against a candidate built from this tree. + ## What is here | file | CSD | screen it needs | beyond nav, what else it waits on | |---|---|---|---| -| `csd-006-receipt.yaml` | CSD-006 | Contacts + a contact | a second node to be a contact of; the matrix stands up one | +| `csd-005-people.yaml` | CSD-005 | Contacts + a contact | `fixture: two_node` seeds the contact | +| `csd-006-receipt.yaml` | CSD-006 | Contacts + a contact | `fixture: two_node` seeds the contact | +| `csd-047-network-content.yaml` | CSD-047 | LayerGlobalCommons → NetworkContent | `fixture: two_node` admits the peer; floored `unreleased` | | `csd-025-system.yaml` | CSD-025 | System | nothing — **not promoted**: `csd:` key refused on `main` (#97) | | `csd-036-network-ops.yaml` | CSD-036 | NetworkOps | **not promoted**: `csd:` key refused on `main` (#97), and floored `unreleased` | | `csd-057-wallet.yaml` | CSD-057 | Wallet | nothing — **not promoted**: `csd:` key refused on `main` (#97) | @@ -112,6 +136,6 @@ None moved, for one reason common to all six and one extra for `csd-036`: | `csd-085-claim-node.yaml` | CSD-085 | ClaimNode | the no-signer step needs the local node stopped mid-flow | | `csd-087-verify-agent.yaml` | CSD-087 | VerifyAgent | nothing — the refusal is the only state any node can produce. **Not promoted**: `csd:` key refused on `main` (#97) | | `csd-090-duty-conferral.yaml` | CSD-090 | DutyConferral | a node that knows an accord family | -| `csd-091-user-chat.yaml` | CSD-091 | UserChat | a peered contact to have a room with | +| `csd-091-user-chat.yaml` | CSD-091 | Contacts → UserChat | `fixture: two_node` seeds the contact; a crossed message needs nodes that can key a room (not the released line) | Six of the fourteen need **only** navigation. They are the ones to promote first. diff --git a/testing/flows/drafts/csd-005-people.yaml b/testing/flows/drafts/csd-005-people.yaml index ca6f568b..49f87cbb 100644 --- a/testing/flows/drafts/csd-005-people.yaml +++ b/testing/flows/drafts/csd-005-people.yaml @@ -17,6 +17,11 @@ description: >- # NOT asserted: the removal end to end and a code pasted from another node, # which need ciris-server 0.5.218 (unreleased); the camera half of the scan. client: ">=0.5.225" +# The populated list needs a contact: the two-node fixture adds the peer's owner +# (testing/gate/two_node.py), and ${PEER_KEY_ID} is the key it is held under. +# The bare-node shape — the add card INSTEAD of an empty block — is +# testing/flows/people.yaml, which the runner orders before any fixture. +fixture: two_node steps: - step_id: on_people @@ -27,28 +32,31 @@ steps: a list and the chain ends on `nav_epistemic_contacts` (CSD-005 §2). requires: screen: Contacts + do: + # Sign-in read the list before the two-node fixture added a contact; + # refresh is what a person does next. + - click: btn_contacts_refresh + - wait: contacts_list + wait_ms: 5000 expect: visible: [contacts_list, input_contacts_search] - step_id: a_row_carries_its_trust_and_its_hamburger - title: Every contact row shows a trust chip and a hamburger + title: The seeded contact's row shows a trust chip and a hamburger description: >- - Optional: it needs at least one contact, which the client fixture does not - author. Both counts over the row class, because the flow cannot know a key - id. A row without a hamburger would be furniture (CSD-006 §1); a row - without a trust chip would hide the one x_private:trust_state fact the - list carries. - optional_step: true + The fixture's contact, by its key id. A row without a hamburger would be + furniture (CSD-006 §1); a row without a trust chip would hide the one + x_private:trust_state fact the list carries. requires: screen: Contacts visible: [contacts_list] expect: state: populated - count: {of: "contacts_row_*", min: 1} + visible: ["contacts_row_${PEER_KEY_ID}", "contacts_row_trust_${PEER_KEY_ID}", + "btn_receipt_${PEER_KEY_ID}"] - step_id: every_row_has_a_trust_chip title: Every contact row shows its trust state as a chip - optional_step: true requires: visible: [contacts_list] expect: @@ -56,7 +64,6 @@ steps: - step_id: every_row_has_a_hamburger title: Every contact row carries a hamburger, because it is a claim - optional_step: true requires: visible: [contacts_list] expect: @@ -65,18 +72,27 @@ steps: - step_id: the_receipt_renders_five_facts title: The receipt opens with all five facts, the rule row off the wire description: >- - Optional (needs a contact). Five tags, not one sheet check (CSD-006 §5). - On a 0.5.217+ node the rule row is the grant's consent_prefixes, so it - contains "chat:"; on an older node it reads "did not send", and so do the - attester and the scope — the sheet says what the node did not send rather - than guessing. - optional_step: true - requires: - visible: [sheet_receipt] + Five tags, not one sheet check (CSD-006 §5). The fixture's node is + 0.5.217 or later, so the rule row is the grant's consent_prefixes and + contains "chat:"; on an older node it would read "did not send", and so + would the attester and the scope. + do: + - click: "btn_receipt_${PEER_KEY_ID}" + - wait: sheet_receipt + wait_ms: 2500 expect: visible: [sheet_receipt, receipt_subject, receipt_attester, receipt_scope, receipt_dimension, receipt_rule, btn_receipt_close] matches: {receipt_dimension: "consent:replication", receipt_rule: "chat:"} + - step_id: the_receipt_closes + title: Closing the receipt returns to the list + do: + - click: btn_receipt_close + expect: + screen: Contacts + absent: [sheet_receipt] + visible: [contacts_list] + - step_id: search_that_matches_nothing_is_empty_not_error title: A search no contact matches renders the empty state do: diff --git a/testing/flows/drafts/csd-006-receipt.yaml b/testing/flows/drafts/csd-006-receipt.yaml index 7e349d31..ff93b42d 100644 --- a/testing/flows/drafts/csd-006-receipt.yaml +++ b/testing/flows/drafts/csd-006-receipt.yaml @@ -6,43 +6,49 @@ description: >- it on the first surface that carries it (Contacts, CSD-005). The claim is that the sheet renders FIVE rows every time — never four, never blank — because a fact the node did not send is itself a fact about the node and renders as - "This node did not send this." rather than nothing. + "This node did not send this." rather than nothing. The two-node fixture + (testing/gate/two_node.py) seeds the contact whose receipt this opens: the + peer's owner, added by the leg's owner, so the grant is a real + consent:replication:v1 row authored on this node. # Floor: sheet_receipt and the five receipt_* tags are `testable*` literals in # ui/primitives/ReceiptSheet.kt at 0.5.225, and the hamburger is derived by # ItemRow from the receipt id (PeopleTags.receipt, PeopleSupport.kt). The rule # row reads the wire (Contact.grant) since PR #101; 0.5.225 is the client whose # grant-less receipt no longer guesses the scope. client: ">=0.5.225" -# INCOMPLETE: no step clicks a concrete `btn_receipt_` -- `click:` takes a -# literal tag and no fixture seeds a contact with a known key id -- so every -# step gated on `sheet_receipt` skips. CSD-006 §5 says so; this flow is not -# evidence for the five facts until it opens a receipt. +# ${PEER_KEY_ID} is the key the leg's node holds the contact under — the peer's +# owner when their key crossed, else the peer node (the fixture says which). +fixture: two_node steps: - - step_id: a_row_with_a_hamburger - title: A CEG row carries a hamburger; app chrome does not + - step_id: the_contact_row_carries_a_hamburger + title: The seeded contact's row is on People and carries a hamburger description: >- - Entry precondition: a contact must exist for there to be a claim to open. - The flow cannot know its key id, so it asserts the class of tags rather than - one instance — which is also the honest shape, since the rule in CSD-006 §1 - is about every row, not a particular one. - optional_step: true + The row and its hamburger by the contact's own key id, not by class: a + `count: btn_receipt_* min 1` would pass on any row, and this flow is about + THE grant the fixture authored. + The list People read at sign-in predates the fixture's contact, so the + step refreshes first — what a person does after someone adds them. requires: screen: Contacts - visible: [contacts_list] + do: + - click: btn_contacts_refresh + - wait: "contacts_row_${PEER_KEY_ID}" + wait_ms: 5000 expect: - count: {of: "btn_receipt_*", min: 1} + visible: ["contacts_row_${PEER_KEY_ID}", "btn_receipt_${PEER_KEY_ID}"] - step_id: five_facts_every_time - title: The sheet renders all five envelope rows + title: The hamburger opens the sheet, and the sheet renders all five envelope rows description: >- Five separate tags, not one `sheet_receipt` check. A sheet that composed its frame and three rows answers a `visible: [sheet_receipt]` assertion correctly, and "all five, always" is the entire contract this template exists to state. - optional_step: true - requires: - visible: [sheet_receipt] + do: + - click: "btn_receipt_${PEER_KEY_ID}" + - wait: sheet_receipt + wait_ms: 2500 expect: visible: - sheet_receipt @@ -58,7 +64,6 @@ steps: `consent:replication:v1` for a contact. The plain label sits ABOVE the mono string; the row asserted here is the protocol value, which is what makes the receipt auditable rather than decorative. - optional_step: true requires: visible: [sheet_receipt] expect: @@ -71,10 +76,7 @@ steps: (`grant`, built by peer.rs::grant_receipt); Contact.kt decodes it and PeopleSupport.contactReceipt renders every fact from it, so the rule row is the grant's consent_prefixes — "chat:" for any contact who can be - messaged. This step used to assert "did not send" while the client still - composed the row from rules; it flipped in the same commit that read the - field, which is the point of asserting it at all. - optional_step: true + messaged. The fixture's node is 0.5.217 or later, so this is not optional. requires: visible: [sheet_receipt] expect: @@ -89,7 +91,8 @@ steps: consent audience the person chose, peer.rs::add_contact) are facts the node did not send, and the sheet says so instead of "this node" or "Everyone". Pinned at unit level (PeopleSupportTest) and asserted here - only against an old node. + only against an old node — the fixture's node sends the grant, so on the + matrix this step skips, and says so. optional_step: true requires: visible: [sheet_receipt] @@ -97,3 +100,12 @@ steps: expect: text: {receipt_attester: "did not send", receipt_scope: "did not send"} matches: {receipt_dimension: "consent:replication"} + + - step_id: the_sheet_closes + title: The sheet closes and People is back + do: + - click: btn_receipt_close + expect: + screen: Contacts + absent: [sheet_receipt] + visible: ["contacts_row_${PEER_KEY_ID}"] diff --git a/testing/flows/drafts/csd-047-network-content.yaml b/testing/flows/drafts/csd-047-network-content.yaml index 9a24b754..160ef3bd 100644 --- a/testing/flows/drafts/csd-047-network-content.yaml +++ b/testing/flows/drafts/csd-047-network-content.yaml @@ -12,38 +12,56 @@ description: >- # (ui/screens/federation/NetworkContentScreen.kt); federation_content_peers_error / # federation_content_peers_not_on_this_node (ReadFailureBlock). client: "unreleased" +# The peer to pick is the two-node fixture's peer NODE (testing/gate/two_node.py): +# GET /v1/federation/peers lists the nodes this node has admitted, and peering +# admits ${PEER_NODE_KEY_ID}. +fixture: two_node steps: - step_id: on_content - title: The pick step composes with a drivable search field + title: The hub's Content tile opens the pick step with a drivable search field + description: >- + NetworkContent has no nav hop — it is reached from a tile on the transport + hub (LayerGlobalCommons), which does — so the flow starts on the hub and + taps the tile, as CSD-047 §4 says. requires: - screen: NetworkContent + screen: LayerGlobalCommons do: + - click: tile_federation_content + - wait: input_peer_search + wait_ms: 5000 - input: {input_peer_search: "zz-no-such-peer"} expect: + screen: NetworkContent visible: [screen_federation_content, input_peer_search] - - step_id: clearing_the_search - title: Clearing the search leaves the pick step in place - description: >- - The flow cannot pick a peer: the row tag is `peer_pick_row_`, a - `click:` takes one literal tag, and no fixture seeds a peer whose key id - the flow could name. So it clears the search and stops at the pick step; - the digest step below is gated on `input_content_id` and skips until a - seeded peer lets the flow open it (CSD-047 §5). + - step_id: clearing_the_search_lists_the_peer + title: Clearing the search lists the peer the fixture admitted do: - input: {input_peer_search: ""} + - wait: "peer_pick_row_${PEER_NODE_KEY_ID}" + wait_ms: 2500 expect: screen: NetworkContent - visible: [input_peer_search] + visible: [input_peer_search, "peer_pick_row_${PEER_NODE_KEY_ID}"] + absent: [empty_content_peers] + + - step_id: picking_the_peer_opens_the_digest_step + title: Picking the peer opens the digest step + do: + - click: "peer_pick_row_${PEER_NODE_KEY_ID}" + - wait: input_content_id + wait_ms: 2500 + expect: + visible: [input_content_id, btn_content_fetch] - step_id: a_bad_digest_keeps_fetch_shut title: A digest that is not 64 hex characters cannot be fetched description: >- The node would refuse it (INVALID_CONTENT_ID, 400); the card refuses first. Asserting the button is present and the field took input is what a flow - can do; the disabled state is the view model's validateContentId. - optional_step: true + can do; the disabled state is the view model's validateContentId. A real + fetch needs a digest the peer holds, which the fixture does not seed. requires: visible: [input_content_id] do: diff --git a/testing/flows/drafts/csd-091-user-chat.yaml b/testing/flows/drafts/csd-091-user-chat.yaml index 9cde16b2..34f2996b 100644 --- a/testing/flows/drafts/csd-091-user-chat.yaml +++ b/testing/flows/drafts/csd-091-user-chat.yaml @@ -15,21 +15,39 @@ description: >- # chat_msg_${attestationId}, chat_msg_unopened_${attestationId}, # chat_msg_duty_badge_${attestationId} and chat_note_${attestationId} are # `testable*` literals in ui/screens/ChatScreen.kt at 0.5.225. -# NOT asserted: a locked row (chat_msg_unopened_*) — the single-node fixture -# cannot stage one; its rendering is pinned by ChatEntryPresentationTest. +# NOT asserted: a locked row (chat_msg_unopened_*) — the fixture does not stage +# one; its rendering is pinned by ChatEntryPresentationTest. client: ">=0.5.225" +# The two-node fixture (testing/gate/two_node.py) is CIRISServer's chat +# scenario without Docker: both nodes claimed and announced, each owner a +# contact of the other, the pair room opened on both sides, and ONE message +# sent by the peer. ${PEER_KEY_ID} is the contact; ${MESSAGE_ATTESTATION_ID} and +# ${MESSAGE_TEXT} exist only if that message ARRIVED on the leg's node — a +# message that did not cross fails `a_room_with_history` naming why, rather +# than letting a local-only row pass for a conversation. +fixture: two_node steps: - step_id: on_the_chat - title: The room composes a transcript and a composer + title: Opening the seeded contact's chat composes a transcript and a composer description: >- - Entry precondition: UserChat is reached by picking a contact, which the nav - map cannot express as a hop. Both tags are asserted because a room that - rendered its transcript with no composer is read-only, and CSD-091 §1 is - about an exchange. + UserChat is reached by picking a contact, which the nav map cannot express + as a hop — so the flow starts on People and picks the fixture's contact by + its key id. Both tags are asserted because a room that rendered its + transcript with no composer is read-only, and CSD-091 §1 is about an + exchange. requires: - screen: UserChat + screen: Contacts + do: + # Sign-in read the list before the fixture added the contact. + - click: btn_contacts_refresh + - wait: "btn_contacts_chat_${PEER_KEY_ID}" + wait_ms: 5000 + - click: "btn_contacts_chat_${PEER_KEY_ID}" + - wait: chat_transcript + wait_ms: 5000 expect: + screen: UserChat visible: [chat_transcript, input_chat_body, btn_chat_send] - step_id: the_composer_takes_text @@ -49,20 +67,24 @@ steps: visible: [chat_transcript] - step_id: a_room_with_history - title: A room that has messages renders them as rows + title: The peer's message is in the room as a row, not only the system note description: >- - Optional: it needs a peered pair with a crossed message, which is what - CIRISServer's chat ladder builds and the client fixture does not. `count: - min 1` over the row class, because the flow cannot know an attestation id and - a transcript frame with zero rows answers `visible: [chat_transcript]` - correctly. - optional_step: true + The row by the SENDER's attestation id — a value the leg's node cannot + produce for itself, so it proves the row crossed rather than that a room + exists. A room holding nothing but a system note answers + `visible: [chat_transcript]` and `count: chat_msg_* min 1` would need a + real row either way; naming the one the fixture sent is what makes this a + conversation. requires: screen: UserChat visible: [chat_transcript] + do: + - wait: "chat_msg_${MESSAGE_ATTESTATION_ID}" + wait_ms: 5000 expect: state: populated - count: {of: "chat_msg_*", min: 1} + visible: ["chat_msg_${MESSAGE_ATTESTATION_ID}"] + text: {"chat_msg_${MESSAGE_ATTESTATION_ID}": "${MESSAGE_TEXT}"} - step_id: a_refusal_is_not_an_empty_room title: When the room cannot be served, the refusal is on screen and the empty state is not From 9af174cfa476fe2da11c59d07c8d04f8ff98b58b Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 28 Sep 2026 21:35:26 -0500 Subject: [PATCH 5/6] evidence: the pair-room blocker now cites CIRISServer#698 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- evidence/blocked_upstream.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evidence/blocked_upstream.tsv b/evidence/blocked_upstream.tsv index eff74edd..c305739f 100644 --- a/evidence/blocked_upstream.tsv +++ b/evidence/blocked_upstream.tsv @@ -30,4 +30,4 @@ issue repo scan_root glob needle files lines kind predicate 471 CIRISAgent . requirements*.txt ciris-client 0 0 absence THE MIGRATION ITSELF. Neither consumer depends on the client package yet; both carry their own ~200k-line copy. The extraction is not done when this repo has the source — it is done when this needle scores 1 in CIRISAgent AND in CIRISServer, and their client/ directories are deleted. Until then this repo is a THIRD tree, which is the cost AGENTS.md warned about and the reason it is worth paying only if it ends. Adoption issues filed 2026-08-20: CIRISServer#471 and CIRISAgent#1089. 379 CIRISServer client *.gradle.kts proguard|minifyEnabled 0 0 absence MEASURED in CIRISClient#1: the desktop uber-jar is 66.48 MiB and compresses to a 65,488,254-byte wheel - 62.5% of PyPI's 104,857,600-byte limit on its own, because ProGuard is blocked on ktor 3.x. That is why a node build and an agent build could not ship in ONE wheel — two ~63 MiB desktop bundles do not fit — and it is half the argument CIRISServer#479 settled by deleting the flavor outright: one artifact ships and narrows itself against the probed node. The localization bundles inside are the product and are never cut for size. packaging/check_wheel_size.py measures it every build. 574 CIRISServer src *.rs run_without_ai 0 0 absence A NODE INSTALLED TO RUN WITHOUT AI STILL REPORTS `agent.folded=true`. /v1/system/health carries data.agent.{folded,reachable}, and on a run-without-AI install the fold is reported present and never becomes reachable — so clientModeFrom() returns undetermined forever and the client retries for its whole StartupBudget (60s on Android, measured as 62s of nothing drivable before Login; CIRISClient#48). The client CANNOT contradict it: /v1/setup/status returns only {setup_required, has_env_file, has_admin_user}, and run_without_ai is accepted by /v1/setup/complete but never read back, so nothing on the wire distinguishes 'a brain that is slow to answer' from 'a brain that was never going to'. CIRISClient mitigated the COST (the retry no longer blocks startup routing — CIRISApp.kt commitGate) but cannot fix the SIGNAL. The obligation lands when the node either reports folded=false for a run-without-AI install or exposes run_without_ai on /v1/setup/status; either makes this needle score >0 in the server tree. Filed as CIRISServer#574; either fix closes it (report folded=false for a run-without-AI install, or expose run_without_ai on /v1/setup/status). Counts are 0/0 by inspection of the shipped SetupStatusData contract, NOT measured against a CIRISServer checkout: that tree is not present here. -- CIRISServer - - - - - untestable TWO RELEASED NODES CANNOT KEY A PAIR ROOM. Measured 2026-09-28 with testing/gate/two_node.py on v0.5.217 (the binary every live-QA leg downloads): claim, announce, peering both ways (production self-key-record), owner-key replication and POST /v1/contacts all succeed, but the room stays chat.state.awaiting_peer for 480 s — each node admits the other ADVISORY (not conferred), inbound frames fail 'no hybrid-verified SignedTransportDestination binds this (peer, dest) pair' (CIRISEdge#393 item 2), and the joiner's KeyPackage never replicates. CIRISServer's harness/mesh-repro chat ladder is green only on a test-anchor build with a test trust root (test-blessed-self-record / test-admit-peer are cfg(feature=test-anchor)). So CSD-091's 'a room with a real message' cannot pass on the matrix. Needs filing upstream: a way for two unconferred released nodes to key a pair room, or a released test-root knob for harnesses. Closes when two_node.py reports message_arrived=true against a released binary. +698 CIRISServer - - - - - untestable TWO RELEASED NODES CANNOT KEY A PAIR ROOM. Measured 2026-09-28 with testing/gate/two_node.py on v0.5.217 (the binary every live-QA leg downloads): claim, announce, peering both ways (production self-key-record), owner-key replication and POST /v1/contacts all succeed, but the room stays chat.state.awaiting_peer for 480 s — each node admits the other ADVISORY (not conferred), inbound frames fail 'no hybrid-verified SignedTransportDestination binds this (peer, dest) pair' (CIRISEdge#393 item 2), and the joiner's KeyPackage never replicates. CIRISServer's harness/mesh-repro chat ladder is green only on a test-anchor build with a test trust root (test-blessed-self-record / test-admit-peer are cfg(feature=test-anchor)). So CSD-091's 'a room with a real message' cannot pass on the matrix. Needs filing upstream: a way for two unconferred released nodes to key a pair room, or a released test-root knob for harnesses. Closes when two_node.py reports message_arrived=true against a released binary. From ecede71bf7d2ef9d92d9113380f4c97140c379d4 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Mon, 28 Sep 2026 21:47:20 -0500 Subject: [PATCH 6/6] fix(gate): a standalone two_node up leaves the peer running for two_node down The exit/SIGTERM cleanup that protects the in-process runner also fired when the standalone `up` command returned, killing the peer it was asked to leave running (Codex, PR #130). `up` now detaches after seeding; test red on the old code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/two_node.py | 12 ++++++++++++ testing/test_two_node.py | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/testing/gate/two_node.py b/testing/gate/two_node.py index 34fbc667..5fade171 100644 --- a/testing/gate/two_node.py +++ b/testing/gate/two_node.py @@ -683,6 +683,15 @@ def up(self) -> Dict[str, str]: (self.work / "values.json").write_text(json.dumps(asdict(self.values), indent=2), encoding="utf-8") return self.values.as_vars() + def detach(self) -> None: + """Hand the running peer to a later `two_node down`: drop the exit and + SIGTERM cleanup `up` installed, so a standalone `up` that returns does + not kill the peer it was asked to leave running (Codex, PR #130).""" + atexit.unregister(self.down) + if threading.current_thread() is threading.main_thread() and hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, signal.SIG_DFL) + self.node = None + def down(self) -> None: if self.node is not None: self.node.stop(keep_home=self.keep_home) @@ -725,6 +734,9 @@ def main(argv: Optional[List[str]] = None) -> int: if args.values: args.values.write_text(json.dumps(values, indent=2), encoding="utf-8") print(json.dumps({"vars": values, "notes": fx.values.notes if fx.values else []}, indent=2)) + # `up` means "leave the peer running": the in-process runner keeps its + # cleanup, the standalone command hands the peer to `two_node down`. + fx.detach() return 0 diff --git a/testing/test_two_node.py b/testing/test_two_node.py index 52cbff21..7b9f9029 100644 --- a/testing/test_two_node.py +++ b/testing/test_two_node.py @@ -184,3 +184,30 @@ def test_a_console_root_owner_is_read_off_its_username(monkeypatch): p = tn.Party("local", "http://h", "t") tn.owner_of(p) assert p.owner_key_id == "o-user-2" + + +def test_standalone_up_leaves_the_peer_running(monkeypatch, tmp_path): + """`python -m testing.gate.two_node up` must not kill the peer on exit (Codex, #130).""" + import atexit + from testing.gate import two_node + + stopped = [] + + class FakeNode: + def __init__(self, spec, work): pass + def start(self): pass + def claim(self): return object() + def stop(self, keep_home=False): stopped.append(True) + + registered = [] + monkeypatch.setattr(two_node, "PeerNode", FakeNode) + monkeypatch.setattr(two_node, "resolve_binary", lambda b: b) + monkeypatch.setattr(two_node, "login", lambda *a, **k: "tok") + monkeypatch.setattr(two_node, "seed", lambda *a, **k: two_node.FixtureValues(peer_key_id="p")) + monkeypatch.setattr(atexit, "register", lambda f: registered.append(f)) + monkeypatch.setattr(atexit, "unregister", lambda f: registered.remove(f)) + rc = two_node.main(["up", "--binary", str(tmp_path / "ciris-server"), "--work", str(tmp_path)]) + assert rc == 0 + assert registered == [], "the exit cleanup is still armed after a standalone up" + for f in registered: f() + assert stopped == []