From a0cc88f31224cf6c427efd3f3edababdd85f507a Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 10:13:14 -0500 Subject: [PATCH 01/27] =?UTF-8?q?feat(flows):=20promotion=20run=20for=200.?= =?UTF-8?q?5.225=20=E2=80=94=20eight=20drafts=20move=20to=20testing/flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Of the 21 CSDs carrying "Spec complete and flow written", eight drafts move up to run on the five-platform matrix: csd-005, 006, 008, 047, 057, 068, 092, 101. Floors that were `unreleased` flip to `>=0.5.225` only where every tag the flow drives is found in client/shared/src by test_flows' rule (008, 047, 092, 101; also 033, 046, 048, 049, which stay for navigation). Each first screen has a nav_map hop or is Contacts; each loads with run_flows.load_flows and passes the seeded-flow tag test. Three drafts were fixed so an ordinary run can go green: csd-005's scan button is gated on there being a camera (desktop renders `${tag}_status` and no button), csd-092's close step no longer expects `contacts_list` on a bare node, csd-101's first step accepts the roster's empty shape. Held, with the reason in each CSD's §5 line and the drafts README: - floor left `unreleased`, tags built by interpolation with an interpolated head (`${tagPrefix}_not_on_this_node`, ConfirmSheet's `sheet_$tagPrefix` / `${tagPrefix}_fact_$i` / `btn_${tagPrefix}_*`), invisible to test_flows' grep: 032, 036, 040, 045, 100; - first screen flow-only (no hop; cannot-start on every leg): 033, 046, 048, 049, 069, 081, 090; - known red on released nodes (CIRISServer#698): 091. testing/flows/README.md gains a table of what runs; drafts/README.md now states the per-file reason and the tag-check blind spot in place of the stale "#97 not on main" sections. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- FSD/CSD/CSD-005-people.md | 4 +- FSD/CSD/CSD-006-receipt.md | 4 +- FSD/CSD/CSD-008-notes-to-self.md | 4 +- FSD/CSD/CSD-032-network-identity.md | 2 +- FSD/CSD/CSD-033-network-peers.md | 4 +- FSD/CSD/CSD-036-network-ops.md | 2 +- FSD/CSD/CSD-040-storage.md | 2 +- FSD/CSD/CSD-045-node-self-standing.md | 2 +- FSD/CSD/CSD-046-network-trust-graph.md | 4 +- FSD/CSD/CSD-047-network-content.md | 4 +- FSD/CSD/CSD-048-network-interfaces.md | 4 +- FSD/CSD/CSD-049-network-queue.md | 4 +- FSD/CSD/CSD-057-wallet.md | 6 +- FSD/CSD/CSD-068-provision-accord-holder.md | 6 +- FSD/CSD/CSD-069-accord-ceremony.md | 2 +- FSD/CSD/CSD-081-login.md | 2 +- FSD/CSD/CSD-090-duty-conferral.md | 2 +- FSD/CSD/CSD-091-user-chat.md | 2 +- FSD/CSD/CSD-092-share-contact-code.md | 4 +- FSD/CSD/CSD-100-household.md | 2 +- FSD/CSD/CSD-101-household-members.md | 6 +- testing/flows/README.md | 31 ++- .../flows/{drafts => }/csd-005-people.yaml | 24 +- .../flows/{drafts => }/csd-006-receipt.yaml | 0 .../{drafts => }/csd-008-notes-to-self.yaml | 4 +- .../{drafts => }/csd-047-network-content.yaml | 2 +- .../flows/{drafts => }/csd-057-wallet.yaml | 0 .../csd-068-provision-accord-holder.yaml | 0 .../csd-092-share-contact-code.yaml | 12 +- .../csd-101-household-members.yaml | 21 +- testing/flows/drafts/README.md | 247 +++++++++--------- .../flows/drafts/csd-033-network-peers.yaml | 3 +- .../drafts/csd-046-network-trust-graph.yaml | 3 +- .../drafts/csd-048-network-interfaces.yaml | 3 +- .../flows/drafts/csd-049-network-queue.yaml | 3 +- 35 files changed, 243 insertions(+), 182 deletions(-) rename testing/flows/{drafts => }/csd-005-people.yaml (85%) rename testing/flows/{drafts => }/csd-006-receipt.yaml (100%) rename testing/flows/{drafts => }/csd-008-notes-to-self.yaml (95%) rename testing/flows/{drafts => }/csd-047-network-content.yaml (99%) rename testing/flows/{drafts => }/csd-057-wallet.yaml (100%) rename testing/flows/{drafts => }/csd-068-provision-accord-holder.yaml (100%) rename testing/flows/{drafts => }/csd-092-share-contact-code.yaml (88%) rename testing/flows/{drafts => }/csd-101-household-members.yaml (71%) diff --git a/FSD/CSD/CSD-005-people.md b/FSD/CSD/CSD-005-people.md index 3ae17f53..45403080 100644 --- a/FSD/CSD/CSD-005-people.md +++ b/FSD/CSD/CSD-005-people.md @@ -1,7 +1,7 @@ # CSD-005 — People (the Contacts surface, rebuilt on the primitives) **CSD**: CSD-005 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the Locked Spec, wave 0 -**Flow**: `testing/flows/drafts/csd-005-people.yaml` (floor `>=0.5.225`) +**Flow**: `testing/flows/csd-005-people.yaml` (floor `>=0.5.225`) **Reads with**: CSD-006 (the receipt it opens), CSD-091 (the chat a row opens), CSD-092 (the code card in its header), CSD-104 (the key check a row will offer once CIRISServer#683 lands) ```yaml csd:stage @@ -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) 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. +Spec complete and flow written (`testing/flows/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. The two-node fixture seeds the contact; on the Linux desktop leg (2026-09-28) the row, its trust chip, its hamburger and the five-fact receipt passed, and the step that then failed (`btn_scan_contact_code` is a button only where there is a camera) is now gated on the button. **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 4e68132d..abc7e688 100644 --- a/FSD/CSD/CSD-006-receipt.md +++ b/FSD/CSD/CSD-006-receipt.md @@ -1,7 +1,7 @@ # CSD-006 — The receipt (the template every CEG item asserts) **CSD**: CSD-006 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the Locked Spec §2 -**Flow**: `testing/flows/drafts/csd-006-receipt.yaml` (floor `>=0.5.225`) — driven on the first surface that binds the template (Contacts, CSD-005) +**Flow**: `testing/flows/csd-006-receipt.yaml` (floor `>=0.5.225`) — driven on the first surface that binds the template (Contacts, CSD-005) ```yaml csd:stage stage: building @@ -143,7 +143,7 @@ Bound per surface; CSD-005 §4 is the first instance. ## 5. QA plan -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. +Spec complete and flow written (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. 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:`). Linux desktop, 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. Not yet run on the other four legs. 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-008-notes-to-self.md b/FSD/CSD/CSD-008-notes-to-self.md index 1f4f1e0f..fc3d1024 100644 --- a/FSD/CSD/CSD-008-notes-to-self.md +++ b/FSD/CSD/CSD-008-notes-to-self.md @@ -2,7 +2,7 @@ **CSD**: CSD-008 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the Locked Spec, B3 ("Files holds files") and B4 (Just me) **Pairs with**: CSD-007 (Files: the drive plane these notes are rows of) · CSD-010 (Interact: the other card in Just me › Chats, when an agent is attached) -**Flow**: `testing/flows/drafts/csd-008-notes-to-self.yaml` (staged; floor `unreleased`) +**Flow**: `testing/flows/csd-008-notes-to-self.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -87,7 +87,7 @@ The new note is **not** listed under Files (CSD-007: `DriveEntry.isNote`). ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-008-notes-to-self.yaml`, floor `unreleased`); 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/csd-008-notes-to-self.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. **Verified live** (desktop, scratch ciris-server 0.5.215, 2026-09-24): writing a note from the UI and reading it back from `/v1/notes`; a readable note diff --git a/FSD/CSD/CSD-032-network-identity.md b/FSD/CSD/CSD-032-network-identity.md index a8c3d975..662d610a 100644 --- a/FSD/CSD/CSD-032-network-identity.md +++ b/FSD/CSD/CSD-032-network-identity.md @@ -121,7 +121,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-032-network-identity.yaml`, floor `unreleased`); 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-032-network-identity.yaml`, floor `unreleased`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97). **Floor left `unreleased` on the 0.5.225 run (2026-09-29):** the flow names `federation_id_card_not_on_this_node` (ReadFailureBlock builds `${tagPrefix}_not_on_this_node`). Real in 0.5.225, but built by interpolation, which the flow tag check (`testing/test_flows.py::_client_tag_strings`: whole literals and `$`-headed prefixes only) cannot see; a promoted flow naming them goes red at the keyboard. The fix is in that check, not the flow. **Platforms.** All five; the bare-node variant on desktop and Android. diff --git a/FSD/CSD/CSD-033-network-peers.md b/FSD/CSD/CSD-033-network-peers.md index 579b565e..96199353 100644 --- a/FSD/CSD/CSD-033-network-peers.md +++ b/FSD/CSD/CSD-033-network-peers.md @@ -3,7 +3,7 @@ **CSD**: CSD-033 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the route map (PR #111): a screen with routes and no CSD **Covers**: `Screen.NetworkPeers` (`ui/screens/federation/NetworkPeersScreen.kt` + `viewmodels/NetworkPeersViewModel.kt`) **Reads with**: **CSD-104** (a peer row opens `Screen.NetworkPeerDetail`: trust, appearance and the short-code ceremony live there, not here), CSD-046 (the same peer set drawn as a graph), CSD-051 (the hub) -**Flow**: `testing/flows/drafts/csd-033-network-peers.yaml` (floor `unreleased`) +**Flow**: `testing/flows/drafts/csd-033-network-peers.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -116,7 +116,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-033-network-peers.yaml`, floor `unreleased`); 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-033-network-peers.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.NetworkPeers` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: start on LayerGlobalCommons (which has a hop) and tap `tile_federation_peers`, as csd-047 does. **Platforms.** All five. The add-by-code path needs an agent; the bare-node leg asserts the sheet's "not on this node" copy instead. diff --git a/FSD/CSD/CSD-036-network-ops.md b/FSD/CSD/CSD-036-network-ops.md index 15c34749..78dc8a0e 100644 --- a/FSD/CSD/CSD-036-network-ops.md +++ b/FSD/CSD/CSD-036-network-ops.md @@ -152,7 +152,7 @@ That second block was written before the fix and failed; it now passes. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-036-network-ops.yaml`, floor `unreleased`); 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-036-network-ops.yaml`, floor `unreleased`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97). **Floor left `unreleased` on the 0.5.225 run (2026-09-29):** the flow names `netops_not_on_this_node` (ReadFailureBlock builds `${tagPrefix}_not_on_this_node`). Real in 0.5.225, but built by interpolation, which the flow tag check (`testing/test_flows.py::_client_tag_strings`: whole literals and `$`-headed prefixes only) cannot see; a promoted flow naming them goes red at the keyboard. The fix is in that check, not the flow. **Platforms.** All five. The node-only variant needs the run-without-AI build, which is exactly where the defect showed. diff --git a/FSD/CSD/CSD-040-storage.md b/FSD/CSD/CSD-040-storage.md index be9ca422..e4db0aa4 100644 --- a/FSD/CSD/CSD-040-storage.md +++ b/FSD/CSD/CSD-040-storage.md @@ -179,7 +179,7 @@ itself; the fix landed (2026-09-28) and the block now asserts the sentence. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-040-storage.yaml`, floor `unreleased`); 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-040-storage.yaml`, floor `unreleased`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97). **Floor left `unreleased` on the 0.5.225 run (2026-09-29):** the flow names `storage_disk_not_on_this_node` (ReadFailureBlock builds `${tagPrefix}_not_on_this_node`). Real in 0.5.225, but built by interpolation, which the flow tag check (`testing/test_flows.py::_client_tag_strings`: whole literals and `$`-headed prefixes only) cannot see; a promoted flow naming them goes red at the keyboard. The fix is in that check, not the flow. **Platforms.** All five. The Postgres-only variant is a server-side fixture, not a client platform, and belongs in CIRISServer's matrix; this flow only needs the diff --git a/FSD/CSD/CSD-045-node-self-standing.md b/FSD/CSD/CSD-045-node-self-standing.md index be070997..6baf50c7 100644 --- a/FSD/CSD/CSD-045-node-self-standing.md +++ b/FSD/CSD/CSD-045-node-self-standing.md @@ -195,7 +195,7 @@ arrives anyway. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-045-node-self-standing.yaml`, floor `unreleased`); 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-045-node-self-standing.yaml`, floor `unreleased`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97). **Floor left `unreleased` on the 0.5.225 run (2026-09-29):** the flow names the ConfirmSheet's `sheet_self_act`, `self_act_fact_1..3`, `btn_self_act_confirm` / `_cancel` and OwnerDelegationPicker's `input_self_delegation_id`, `opt_self_owner_delegation_0`, `text_self_no_owner_delegation` (every one built from `tagPrefix`). Real in 0.5.225, but built by interpolation, which the flow tag check (`testing/test_flows.py::_client_tag_strings`: whole literals and `$`-headed prefixes only) cannot see; a promoted flow naming them goes red at the keyboard. The fix is in that check, not the flow. **Platforms.** All five, against a claimed node with an owner session. The with-AI legs exercise the node URL, not the agent port. The delegation-supplied diff --git a/FSD/CSD/CSD-046-network-trust-graph.md b/FSD/CSD/CSD-046-network-trust-graph.md index 355167ab..ccdea67d 100644 --- a/FSD/CSD/CSD-046-network-trust-graph.md +++ b/FSD/CSD/CSD-046-network-trust-graph.md @@ -3,7 +3,7 @@ **CSD**: CSD-046 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the route map (PR #111): two screens with routes and no CSD **Covers**: `Screen.NetworkTrustGraph` (`ui/screens/federation/NetworkTrustGraphScreen.kt` + `viewmodels/federation/NetworkTrustGraphViewModel.kt`). **`Screen.NetworkMap` is retired into it** (below). **Reads with**: CSD-033 (the same peers as a list), CSD-104 (tapping a node opens the peer detail), CSD-051 (the hub) -**Flow**: `testing/flows/drafts/csd-046-network-trust-graph.yaml` (floor `unreleased`) +**Flow**: `testing/flows/drafts/csd-046-network-trust-graph.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -93,7 +93,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-046-network-trust-graph.yaml`, floor `unreleased`); 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-046-network-trust-graph.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.NetworkTrustGraph` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: start on LayerGlobalCommons and tap its trust-graph tile, as csd-047 does. **Platforms.** All five; the canvas has no per-vertex tags, so the populated assertion is the canvas and the count is not assertable (the list, CSD-033, diff --git a/FSD/CSD/CSD-047-network-content.md b/FSD/CSD/CSD-047-network-content.md index 4a374787..c15e7a53 100644 --- a/FSD/CSD/CSD-047-network-content.md +++ b/FSD/CSD/CSD-047-network-content.md @@ -3,7 +3,7 @@ **CSD**: CSD-047 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the route map (PR #111): a screen with routes and no CSD **Covers**: `Screen.NetworkContent` (`ui/screens/federation/NetworkContentScreen.kt` + `viewmodels/federation/NetworkContentViewModel.kt`) **Reads with**: CSD-051 (the hub), CSD-033 (the peer list it picks from), `PENDING-CSD-007` (Files, where a directory of what can be fetched would live; CIRISServer#651) -**Flow**: `testing/flows/drafts/csd-047-network-content.yaml` (floor `unreleased`) +**Flow**: `testing/flows/csd-047-network-content.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -108,7 +108,7 @@ expect: ## 5. QA plan -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. +Spec complete and flow written (`testing/flows/csd-047-network-content.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. It enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. Its hop to LayerGlobalCommons stopped on CircleTab when last walked (2026-09-28, before `navigate` learned to open a one-card tab); if it still does, the leg reports `cannot-start` naming the hop. 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-048-network-interfaces.md b/FSD/CSD/CSD-048-network-interfaces.md index 76da8489..bf5c02fc 100644 --- a/FSD/CSD/CSD-048-network-interfaces.md +++ b/FSD/CSD/CSD-048-network-interfaces.md @@ -3,7 +3,7 @@ **CSD**: CSD-048 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the route map (PR #111): a screen with routes and no CSD **Covers**: `Screen.NetworkInterfaces` (`ui/screens/federation/NetworkInterfacesScreen.kt` + `viewmodels/federation/NetworkInterfacesViewModel.kt`) **Reads with**: **CSD-049** (the Queue tile: the same `GET /v1/federation/metrics` snapshot, projected per plane instead of per medium — two doors, see §6), CSD-051 (the hub) -**Flow**: `testing/flows/drafts/csd-048-network-interfaces.yaml` (floor `unreleased`) +**Flow**: `testing/flows/drafts/csd-048-network-interfaces.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -96,7 +96,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-048-network-interfaces.yaml`, floor `unreleased`); 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-048-network-interfaces.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.NetworkInterfaces` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: start on LayerGlobalCommons and tap its interfaces tile, as csd-047 does. **Platforms.** All five. A LoRa or Bluetooth row needs hardware; the matrix asserts tcp. diff --git a/FSD/CSD/CSD-049-network-queue.md b/FSD/CSD/CSD-049-network-queue.md index 8d6a3216..b6d2886b 100644 --- a/FSD/CSD/CSD-049-network-queue.md +++ b/FSD/CSD/CSD-049-network-queue.md @@ -3,7 +3,7 @@ **CSD**: CSD-049 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the route map (PR #111): a screen with routes and no CSD **Covers**: `Screen.NetworkQueue` (`ui/screens/federation/NetworkQueueScreen.kt` + `viewmodels/federation/NetworkQueueViewModel.kt`) **Reads with**: **CSD-048** (the Interfaces tile: the same snapshot per medium — two doors, CSD-048 §6), CSD-051 (the hub) -**Flow**: `testing/flows/drafts/csd-049-network-queue.yaml` (floor `unreleased`) +**Flow**: `testing/flows/drafts/csd-049-network-queue.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -133,7 +133,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-049-network-queue.yaml`, floor `unreleased`); 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-049-network-queue.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.NetworkQueue` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: start on LayerGlobalCommons and tap its queue tile, as csd-047 does. **Platforms.** All five. diff --git a/FSD/CSD/CSD-057-wallet.md b/FSD/CSD/CSD-057-wallet.md index be295ab4..0cb994dd 100644 --- a/FSD/CSD/CSD-057-wallet.md +++ b/FSD/CSD/CSD-057-wallet.md @@ -1,7 +1,7 @@ # CSD-057 — Wallet (real money, in a circle, bound to a family that does not exist) **CSD**: CSD-057 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the Locked Spec, Rules tab -**Flow**: `testing/flows/drafts/csd-057-wallet.yaml` (floor `>=0.5.224`) +**Flow**: `testing/flows/csd-057-wallet.yaml` (floor `>=0.5.224`) ```yaml csd:stage stage: building @@ -130,7 +130,7 @@ missing `error` state, and a person on a node sees an empty wallet rather than ## 4. Flow (how) -Written: `testing/flows/drafts/csd-057-wallet.yaml` (floor `>=0.5.224`), +Written: `testing/flows/csd-057-wallet.yaml` (floor `>=0.5.224`), read-only, and it stops before the send. In order: 1. **On the wallet** — `card_wallet_experimental` and `card_wallet_balance`, @@ -158,7 +158,7 @@ warning quietly disappear would be testing the wrong half. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-057-wallet.yaml`, floor `>=0.5.224`); 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/csd-057-wallet.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. **Platforms.** All five, agent build. Plus a node build for the `wallet_unsupported` state in §2 once it exists. diff --git a/FSD/CSD/CSD-068-provision-accord-holder.md b/FSD/CSD/CSD-068-provision-accord-holder.md index 0c15739a..cb01939c 100644 --- a/FSD/CSD/CSD-068-provision-accord-holder.md +++ b/FSD/CSD/CSD-068-provision-accord-holder.md @@ -1,7 +1,7 @@ # CSD-068 — Provision Accord Holder (the custody floor, in three steps) **CSD**: CSD-068 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the Locked Spec, wave 1 -**Flow**: `testing/flows/drafts/csd-068-provision-accord-holder.yaml` (floor `>=0.5.224`) +**Flow**: `testing/flows/csd-068-provision-accord-holder.yaml` (floor `>=0.5.224`) ```yaml csd:stage stage: building @@ -254,7 +254,7 @@ touch, which no platform runner has; §5 says so rather than mocking it. **Stage.** Every tag is real and nothing in §3 is `unconfirmed`, so `check_csd_v3.py` would admit `testable`. The flow's floor is already off -`unreleased` — `testing/flows/drafts/csd-068-provision-accord-holder.yaml` is +`unreleased` — `testing/flows/csd-068-provision-accord-holder.yaml` is `client: ">=0.5.224"`, and every tag it drives is a literal at v0.5.224 (the custody row, copy button and token banner that came later are not in it). The one remaining condition is that the flow runs on the matrix (#97); the card @@ -262,7 +262,7 @@ stays at `building` until it does. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); 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/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. **Platforms.** Desktop and Android in practice — the flow needs a USB path and a physical token, and the iOS/browser corners have neither. The screen composes on diff --git a/FSD/CSD/CSD-069-accord-ceremony.md b/FSD/CSD/CSD-069-accord-ceremony.md index 1679308a..a65f9bc2 100644 --- a/FSD/CSD/CSD-069-accord-ceremony.md +++ b/FSD/CSD/CSD-069-accord-ceremony.md @@ -305,7 +305,7 @@ screen draws no tagged family line, so `accord:family` above is `proposed:`. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-069-accord-ceremony.yaml`, floor `>=0.5.224`); 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-069-accord-ceremony.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.AccordCeremony` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: start on Accord (which has a hop) and open the ceremony from there. **Platforms.** Desktop in practice. Six FIPS YubiKeys and six USB volumes, each re-inserted, are not a thing any platform runner has; the screen composes on all diff --git a/FSD/CSD/CSD-081-login.md b/FSD/CSD/CSD-081-login.md index 6ee436b0..f3f12f8d 100644 --- a/FSD/CSD/CSD-081-login.md +++ b/FSD/CSD/CSD-081-login.md @@ -219,7 +219,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-081-login.yaml`, floor `>=0.5.224`); 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-081-login.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.Login` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: the runner signs in before any flow, so Login is gone; it needs `--no-sign-in` or a sign-out step of its own. **Platforms.** All five. `btn_local_login` / `input_username` / `input_password` / `btn_login_submit` are exactly the tags CIRISAgent's five-platform gate sends diff --git a/FSD/CSD/CSD-090-duty-conferral.md b/FSD/CSD/CSD-090-duty-conferral.md index 869b64ea..a8bcadf5 100644 --- a/FSD/CSD/CSD-090-duty-conferral.md +++ b/FSD/CSD/CSD-090-duty-conferral.md @@ -340,7 +340,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-090-duty-conferral.yaml`, floor `>=0.5.224`); 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-090-duty-conferral.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `Screen.DutyConferral` is flow-only — `nav_map` derives no hop to it, so the runner only waits for it after sign-in lands on Contacts, and the flow would be `cannot-start` (red) on every leg. To move it: start on the screen that confers the duty and open the conferral from there. **Platforms.** Desktop only, and not end to end. The ceremony needs two accord holders' YubiKeys, two humans and two PIV PINs; `testing/gate/node_fixture.py` diff --git a/FSD/CSD/CSD-091-user-chat.md b/FSD/CSD/CSD-091-user-chat.md index e65294e1..b00dbe3b 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 -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. +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. **Not moved to `testing/flows/` on the 0.5.225 run (2026-09-29):** `a_room_with_history` fails on every leg for the upstream reason above (CIRISServer#698), so promoting it would redden the matrix for a defect the client does not have. It moves when the leg's node can key a pair room. **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/FSD/CSD/CSD-092-share-contact-code.md b/FSD/CSD/CSD-092-share-contact-code.md index 1e6ce3b7..8e5600cb 100644 --- a/FSD/CSD/CSD-092-share-contact-code.md +++ b/FSD/CSD/CSD-092-share-contact-code.md @@ -2,7 +2,7 @@ **CSD**: CSD-092 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: CIRISServer 0.5.218 client brief (CIRISServer#673; the route is readable on `origin/integ/0.5.218`, `src/self_devices.rs:562-847`) **Pairs with**: CSD-005 (People: the other half, where a code is pasted or scanned in) -**Flow**: `testing/flows/drafts/csd-092-share-contact-code.yaml` (floor `unreleased` — the route ships with ciris-server 0.5.218) +**Flow**: `testing/flows/csd-092-share-contact-code.yaml` (floor `>=0.5.225`; the route ships with ciris-server 0.5.218) **Card**: built, PR #113 — `ContactsScreen.kt` (the card), `ContactCodeState.kt` + `ContactsViewModel` (the states), `ContactCodeResponse` (the wire), `ContactCodeViewModelTest` / `ContactCodeWireTest` ```yaml csd:stage @@ -199,7 +199,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-092-share-contact-code.yaml`, floor `unreleased`); 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/csd-092-share-contact-code.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The tags are the client's at 0.5.225; the route is ciris-server 0.5.218's, so on an older node the flow drives the version fact and skips the populated, empty and refusal states. **Platforms.** All five for the card. The copy → paste → contact round trip needs two nodes and runs on desktop. diff --git a/FSD/CSD/CSD-100-household.md b/FSD/CSD/CSD-100-household.md index d92c611c..6a0138e6 100644 --- a/FSD/CSD/CSD-100-household.md +++ b/FSD/CSD/CSD-100-household.md @@ -297,7 +297,7 @@ facts → confirm → the household is gone from the switcher. ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-100-household.yaml`, floor `unreleased`); 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-100-household.yaml`, floor `unreleased`); promotes to `testable` when the floor is no longer `unreleased` and the flow runs on the matrix (#97). **Floor left `unreleased` on the 0.5.225 run (2026-09-29):** the flow names the ConfirmSheet's `sheet_confirm_household`, `confirm_household_fact_1..3`, `btn_confirm_household_confirm` / `_cancel` (built from `tagPrefix`). Real in 0.5.225, but built by interpolation, which the flow tag check (`testing/test_flows.py::_client_tag_strings`: whole literals and `$`-headed prefixes only) cannot see; a promoted flow naming them goes red at the keyboard. The fix is in that check, not the flow. **Platforms.** All five; the card calls only the node. diff --git a/FSD/CSD/CSD-101-household-members.md b/FSD/CSD/CSD-101-household-members.md index 021f88b5..74d116d5 100644 --- a/FSD/CSD/CSD-101-household-members.md +++ b/FSD/CSD/CSD-101-household-members.md @@ -2,7 +2,7 @@ **CSD**: CSD-101 · **Standard**: CSD/3 (`CSD.md`) · **Origin**: the plan's B5 Family; CIRISServer 0.5.216 (`/v1/families/{id}/members`) **Pairs with**: CSD-100 (the household itself, in the Family hub on Family › Rules) · CSD-005 (People: where a person becomes a contact first) -**Flow**: `testing/flows/drafts/csd-101-household-members.yaml` (staged; floor `unreleased`) +**Flow**: `testing/flows/csd-101-household-members.yaml` (floor `>=0.5.225`) ```yaml csd:stage stage: building @@ -150,7 +150,7 @@ the hub) and stays recorded as that decision. ## 4. Flow (how) -`testing/flows/drafts/csd-101-household-members.yaml`. Sign in as the owner of a +`testing/flows/csd-101-household-members.yaml`. Sign in as the owner of a node ≥ 0.5.216 who has formed a household (CSD-100's flow leaves one); open Family › People › Household. @@ -167,7 +167,7 @@ with either a `btn_household_member_pick_*` per contact or ## 5. QA plan -Spec complete and flow written (`testing/flows/drafts/csd-101-household-members.yaml`, floor `unreleased`); 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/csd-101-household-members.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The matrix's node has no household, so the first step accepts the roster's empty shape and the populated roster is gated on `household_members_list`. **Platforms.** All five. diff --git a/testing/flows/README.md b/testing/flows/README.md index ce19cb8a..119021d5 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -66,6 +66,30 @@ Each of these is a **load error**, found before any app is started: `testing/test_flows.py` also checks that every literal tag a flow here names is a string in the client's `commonMain` source. +## What is here + +Every file in this directory runs on every leg. Promoted from +`testing/flows/drafts/` on the 0.5.225 run (2026-09-29); what still waits +there, and why, is in `drafts/README.md`. + +| file | CSD | first screen | fixture | floor | +|---|---|---|---|---| +| `people.yaml` | CSD-005 | Contacts (bare node: the add card instead of an empty block) | — | `>=0.5.224` | +| `csd-005-people.yaml` | CSD-005 | Contacts (a seeded contact: row, chip, hamburger, receipt) | `two_node` | `>=0.5.225` | +| `csd-006-receipt.yaml` | CSD-006 | Contacts (the five facts, off the wire) | `two_node` | `>=0.5.225` | +| `csd-008-notes-to-self.yaml` | CSD-008 | Notes | — | `>=0.5.225` | +| `csd-047-network-content.yaml` | CSD-047 | LayerGlobalCommons → `tile_federation_content` | `two_node` | `>=0.5.225` | +| `csd-057-wallet.yaml` | CSD-057 | Wallet (read-only; never presses send) | — | `>=0.5.224` | +| `csd-068-provision-accord-holder.yaml` | CSD-068 | ProvisionAccordHolder (the no-token refusal) | — | `>=0.5.224` | +| `csd-092-share-contact-code.yaml` | CSD-092 | Contacts → the contact-code card | — | `>=0.5.225` | +| `csd-101-household-members.yaml` | CSD-101 | HouseholdMembers (the bare node's empty shape; the roster is gated) | — | `>=0.5.225` | + +A flow's floor is the client that carries every tag it names — checked at the +keyboard by `testing/test_flows.py`. `csd-005` and `csd-006` were run locally on +the Linux desktop leg before promotion (their CSDs' §5 say what passed); the +other six have not run anywhere yet, which is what their CSDs' `stage:` +(`building`) says. + ## Verdicts | verdict | when | leg | @@ -139,8 +163,9 @@ bring-up per leg and one session. own `do:` clicks. - **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. + `fixture: two_node` — see below. Three flows here ask for it + (`csd-005-people`, `csd-006-receipt`, `csd-047-network-content`); the rest + run 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 @@ -198,7 +223,7 @@ 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 \ + --flows testing/flows/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 ``` diff --git a/testing/flows/drafts/csd-005-people.yaml b/testing/flows/csd-005-people.yaml similarity index 85% rename from testing/flows/drafts/csd-005-people.yaml rename to testing/flows/csd-005-people.yaml index 49f87cbb..52e3f4b9 100644 --- a/testing/flows/drafts/csd-005-people.yaml +++ b/testing/flows/csd-005-people.yaml @@ -15,7 +15,9 @@ description: >- # ui/screens/PeopleSupport.kt (PeopleTags) at 0.5.225; sheet_receipt and the # five receipt_* tags are in ui/primitives/ReceiptSheet.kt. # 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. +# which need ciris-server 0.5.218 (unreleased); the camera half of the scan; +# the desktop "paste instead" sentence (`btn_scan_contact_code_status`, built as +# `${tag}_status`, invisible to testing/test_flows.py's literal grep). 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. @@ -111,12 +113,26 @@ steps: visible: [contacts_list] absent: [contacts_empty] - - step_id: the_add_card_opens_with_paste_and_scan - title: Add a contact takes a pasted code, and offers a scan that never submits + - step_id: the_add_card_opens_for_a_pasted_code + title: Add a contact takes a pasted code do: - click: btn_contacts_add_open expect: - visible: [card_contacts_add, input_contacts_add_key, btn_contacts_add_submit, btn_scan_contact_code] + visible: [card_contacts_add, input_contacts_add_key, btn_contacts_add_submit] + + - step_id: a_scan_is_offered_where_there_is_a_camera + title: Where the device has a camera, the card offers a scan that never submits + description: >- + Android and iOS render `btn_scan_contact_code`; desktop and web render one + sentence saying to paste instead and no button (ui/primitives/QrScanAction.kt). + Gated on the button, so the three desktop legs skip it rather than fail + (Linux desktop, 2026-09-28). The desktop sentence is `${tag}_status`, built + by interpolation, which the flow tag check cannot see, so it is not asserted. + optional_step: true + requires: + visible: [btn_scan_contact_code] + expect: + visible: [btn_scan_contact_code, card_contacts_add] - step_id: a_node_code_is_refused_by_name title: Pasting a node code is refused as "not a person's code" diff --git a/testing/flows/drafts/csd-006-receipt.yaml b/testing/flows/csd-006-receipt.yaml similarity index 100% rename from testing/flows/drafts/csd-006-receipt.yaml rename to testing/flows/csd-006-receipt.yaml diff --git a/testing/flows/drafts/csd-008-notes-to-self.yaml b/testing/flows/csd-008-notes-to-self.yaml similarity index 95% rename from testing/flows/drafts/csd-008-notes-to-self.yaml rename to testing/flows/csd-008-notes-to-self.yaml index 9b9d5745..d91e5f8b 100644 --- a/testing/flows/drafts/csd-008-notes-to-self.yaml +++ b/testing/flows/csd-008-notes-to-self.yaml @@ -6,8 +6,8 @@ description: >- self room (CIRISServer src/drive.rs:3020); GET /v1/notes reads it back with the drive's byte-state words. Both calls go to the node URL. # Floor: every tag below is a string literal in ui/screens/files/NotesScreen.kt -# (NotesTags); no release carries them yet. -client: "unreleased" +# (NotesTags); all literals at 0.5.225. +client: ">=0.5.225" steps: - step_id: notes_is_in_just_me_chats diff --git a/testing/flows/drafts/csd-047-network-content.yaml b/testing/flows/csd-047-network-content.yaml similarity index 99% rename from testing/flows/drafts/csd-047-network-content.yaml rename to testing/flows/csd-047-network-content.yaml index 160ef3bd..9ff6387c 100644 --- a/testing/flows/drafts/csd-047-network-content.yaml +++ b/testing/flows/csd-047-network-content.yaml @@ -11,7 +11,7 @@ description: >- # text_content_fetch_error, card_content_result # (ui/screens/federation/NetworkContentScreen.kt); federation_content_peers_error / # federation_content_peers_not_on_this_node (ReadFailureBlock). -client: "unreleased" +client: ">=0.5.225" # 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}. diff --git a/testing/flows/drafts/csd-057-wallet.yaml b/testing/flows/csd-057-wallet.yaml similarity index 100% rename from testing/flows/drafts/csd-057-wallet.yaml rename to testing/flows/csd-057-wallet.yaml diff --git a/testing/flows/drafts/csd-068-provision-accord-holder.yaml b/testing/flows/csd-068-provision-accord-holder.yaml similarity index 100% rename from testing/flows/drafts/csd-068-provision-accord-holder.yaml rename to testing/flows/csd-068-provision-accord-holder.yaml diff --git a/testing/flows/drafts/csd-092-share-contact-code.yaml b/testing/flows/csd-092-share-contact-code.yaml similarity index 88% rename from testing/flows/drafts/csd-092-share-contact-code.yaml rename to testing/flows/csd-092-share-contact-code.yaml index 1d668615..104e680d 100644 --- a/testing/flows/drafts/csd-092-share-contact-code.yaml +++ b/testing/flows/csd-092-share-contact-code.yaml @@ -8,14 +8,15 @@ description: >- runs on the matrix today is the version fact; the populated, empty and refusal states are asserted for the day 0.5.218 ships and are optional until then. Every call goes to the NODE URL (contactsNodeUrl), never the agent's. -# Floor: `unreleased` — the route ships with ciris-server 0.5.218. The tags +# Floor: `>=0.5.225` — the client that carries the card. The route ships with +# ciris-server 0.5.218; on an older node the card shows its version fact. The tags # (card_contact_code, qr_contact_code, text_contact_code, btn_contact_code_copy, # opt_contact_code_nodes_*, row_contact_code_node_*, text_contact_code_included, # text_contact_code_private_note, contact_code_refusal, # contact_code_unreachable, btn_contact_code_make_reachable, # text_contact_code_reachable_status, contact_code_loading, contact_code_error, # btn_contact_code_close) are literals in ui/screens/PeopleSupport.kt at 0.5.225. -client: unreleased +client: ">=0.5.225" steps: - step_id: open_the_card @@ -93,8 +94,13 @@ steps: - step_id: close_the_card title: The card closes and People is unchanged beneath it + description: >- + Beneath the card a bare node shows the add card, not `contacts_list` + (people.yaml), so what is asserted is that the card is gone and the header + button that opened it is still there. do: - click: btn_contact_code_close expect: screen: Contacts - visible: [contacts_list] + visible: [btn_contact_code_open] + absent: [card_contact_code, contact_code_error, contact_code_unreachable] diff --git a/testing/flows/drafts/csd-101-household-members.yaml b/testing/flows/csd-101-household-members.yaml similarity index 71% rename from testing/flows/drafts/csd-101-household-members.yaml rename to testing/flows/csd-101-household-members.yaml index 9bd3756d..16ac0ca3 100644 --- a/testing/flows/drafts/csd-101-household-members.yaml +++ b/testing/flows/csd-101-household-members.yaml @@ -7,18 +7,29 @@ description: >- only, because the node admits only a registered identity (family.unknown_member_key, CIRISServer src/family_api.rs:925-947). # Floor: every tag below is a string literal in ui/screens/HouseholdsSupport.kt -# (HouseholdTags), added with this card; no release carries them yet. +# (HouseholdTags); all literals at 0.5.225. # NOT asserted: adding a real member. It needs a second identity registered on # the node, which the fixture does not stand up. -client: "unreleased" - +client: ">=0.5.225" steps: - step_id: on_the_roster - title: The roster names who is in the household you are looking at + title: The roster composes in one of its states, never a blank description: >- Entry: circle_family -> tab_people -> nav_epistemic_household_members - (derived by nav_map). Needs a household (CSD-100's flow forms one). A + (derived by nav_map). The matrix's node has no household, so this step + accepts the empty shape; the populated one below is gated on the list. + requires: + screen: HouseholdMembers + expect: + count: {of: "household_members_*", min: 1} + absent: [household_members_error] + + - step_id: the_roster_names_who_is_in_the_household + title: With a household, the roster names who is in it + description: >- + Needs a household (CSD-100's flow forms one; the bare node has none). A household always holds at least its founder, so min 1. + optional_step: true requires: screen: HouseholdMembers visible: [household_members_list] diff --git a/testing/flows/drafts/README.md b/testing/flows/drafts/README.md index 79e393d7..fcf16500 100644 --- a/testing/flows/drafts/README.md +++ b/testing/flows/drafts/README.md @@ -1,141 +1,148 @@ -# Staged CSD flows — complete, load-clean, and waiting on one thing - -Every file here is a finished flow for a CSD at `building`. Each one: - -- names its CSD with `csd: CSD-NNN` and loads clean against the binder in - `testing/gate/flow_spec.py` — no `proposed:` tag in any `requires`, `expect` or - `do`, and no `state:` the CSD gives a proposed tag; -- carries `client: ">=0.5.224"`, because every literal tag in it was grepped out - of the `v0.5.224` tree rather than assumed; -- follows its CSD's §4, with `requires` stated rather than assumed. - -**They are here and not one directory up for exactly one reason: the runner does -not navigate.** `testing/flows/README.md` says so plainly — *"Every flow today -starts where sign-in lands (`Contacts`). A flow for another surface needs -`nav_map` to drive the hop."* — and `cannot-start` is **red on purpose**, so that -a flow which never reached its first screen cannot be mistaken for one that -passed. - -Sign-in lands on `Contacts`. Every flow here starts somewhere else. Putting them -in `testing/flows/` would turn all five matrix legs red for a reason that has -nothing to do with the client. - -`flow_spec.discover` globs `p.glob("*.yaml")` — **not** `rglob` — so this -subdirectory is staged and never run. That is the whole mechanism. - -## One thing to fix in #97 before four of these can be promoted - -`testing/test_flows.py::test_every_tag_a_seeded_flow_names_exists_in_the_client` -builds its set with - -```python -re.findall(r'"([a-z][a-z0-9_]+)"', kt.read_text(...)) -``` - -There is no `$` in that character class, so a tag the client builds by -interpolation is invisible to it. Seven such tags are named by four flows here, -and every one is real at run time: - -| tag named by a flow | how the client writes it | where | +# Staged CSD flows — written, load-clean, and each waiting on one named thing + +Every file here names its CSD with `csd: CSD-NNN` and loads through +`testing.gate.run_flows.load_flows` — bound to its CSD's `shows:` and `states:` +blocks, no `proposed:` tag anywhere, `requires` stated rather than assumed. +`flow_spec.discover` globs `p.glob("*.yaml")`, not `rglob`, so nothing in this +directory runs on the matrix. That is the whole mechanism. + +Until #97 the one reason for staging was that the runner did not navigate. It +does now — before a flow's first step it walks the hop `testing/gate/nav_map.py` +derives for the flow's first `requires: screen:` — so on the 0.5.225 run +(2026-09-29) eight drafts moved up to `testing/flows/`. What holds each +remaining file back is per-file, in the table at the end. + +## The 0.5.225 run (2026-09-29) + +**Moved to `testing/flows/` (8):** `csd-005-people`, `csd-006-receipt`, +`csd-008-notes-to-self`, `csd-047-network-content`, `csd-057-wallet`, +`csd-068-provision-accord-holder`, `csd-092-share-contact-code`, +`csd-101-household-members`. Each floor that was `unreleased` flipped to +`>=0.5.225` after every tag the flow drives was found in `client/shared/src` by +`testing/test_flows.py`'s rule; each first screen has a hop (or is Contacts, +where sign-in lands). Three drafts were edited on the way so an ordinary run +can go green: `csd-005`'s scan button is gated on there being a camera, +`csd-092`'s close step no longer expects `contacts_list` on a bare node, and +`csd-101`'s first step accepts the roster's empty shape. + +**Not moved — floor left `unreleased` (5):** `csd-032`, `csd-036`, `csd-040`, +`csd-045`, `csd-100`. Each names a tag the client builds by interpolation with +an interpolated *head* — `"${tagPrefix}_not_on_this_node"` (`ReadFailureBlock`), +`"sheet_$tagPrefix"` / `"${tagPrefix}_fact_$i"` / `"btn_${tagPrefix}_confirm"` +(`ConfirmSheet`), `"input_${tagPrefix}_delegation_id"` (`OwnerDelegationPicker`). +They are real in 0.5.225 and the flow tag check cannot see them (below), so a +promoted flow naming them goes red at the keyboard. The CSD's §5 line names the +tags. + +**Not moved — first screen is flow-only (7):** `csd-033`, `csd-046`, `csd-048`, +`csd-049` (the transport-hub leaves), `csd-069` (AccordCeremony), `csd-081` +(Login), `csd-090` (DutyConferral). `nav_map` derives no hop to these screens; +the runner only waits for one after sign-in lands on Contacts, so each would be +`cannot-start` — red — on every leg. The fix is in the flow's entry: start on a +screen that has a hop and tap into the leaf, as `csd-047` does from +LayerGlobalCommons. `csd-081` is different: the runner signs in before any flow, +so Login is gone; it needs `--no-sign-in` or a sign-out step of its own. + +**Not moved — known red upstream (1):** `csd-091-user-chat`. Two released nodes +never key a pair room (CIRISServer#698, `evidence/blocked_upstream.tsv`), so +`a_room_with_history` fails on every leg for a defect the client does not have. + +**Not on the list (16 files):** their CSDs are not yet "spec complete and flow +written" — a `proposed:` tag or an `unconfirmed` §3 field still holds the card +at `building` for the checker's reasons, or the flow is incomplete (`csd-082`). + +## The blind spot in the flow tag check + +`testing/test_flows.py::_client_tag_strings` reads `commonMain` for whole +literals (`"opt_run_with_ai"`) and for `$`-headed prefixes with two segments +(`"age_band_$token"` vouches for `age_band_adult`). It cannot see a tag whose +head is itself interpolated: + +| how the client writes it | where | a draft that names the result | |---|---|---| -| `age_band_adult` | `"age_band_$token"` | `SetupScreen.kt:2661` | -| `trace_consent_yes` / `_no` | `"trace_consent_$token"` | `SetupScreen.kt:1095` | -| `radio_cohort_self` | `"radio_cohort_$value"` | `ClaimNodeScreen.kt:383` | -| `chk_duty_box_moderate` / `_review` / `_consent_revocation` | `"chk_duty_box_$verb"` | `DutyConferralScreen.kt:301` | - -Promoting `csd-082`, `csd-083`, `csd-085` or `csd-090` as written would turn that -test red against a client that carries every tag. The fix belongs in the test, -not in the flows: collect the literals that contain `$`, keep the part before the -first `$` as a prefix, and accept a named tag that starts with one. `opt_run_with_ai` -shows the distinction — it is written as a whole literal -(`SetupScreen.kt:2567`) and is seen today. - -The same blind spot is worth knowing about generally: a plain string-literal grep -cannot tell a test tag from a localization key either. `graph_simulating` and -`graph_updated` look like tags and are `localizedString(...)` keys -(`GraphMemoryScreen.kt:219`, `:539`), which is why CSD-028 correctly still marks -them `proposed:`. +| `"${tagPrefix}_not_on_this_node"` | `ReadFailureBlock` | `csd-032`, `csd-036`, `csd-040` | +| `"sheet_$tagPrefix"`, `"${tagPrefix}_fact_$i"`, `"btn_${tagPrefix}_confirm"` / `_cancel` | `ui/primitives/ConfirmSheet.kt` | `csd-045`, `csd-100` | +| `"input_${tagPrefix}_delegation_id"`, `"opt_${tagPrefix}_owner_delegation_$i"` | `SelfReaderOpsSection.kt` | `csd-045` | +| `"${tag}_status"` | `ui/primitives/QrScanAction.kt` | none — `csd-005` deliberately does not assert the desktop sentence | + +`testing/test_csd_state_tags.py::source_tags` already resolves a `tagPrefix` +parameter slot to the callers' literals and sees all of these except +`${tagPrefix}_fact_$i`. The fix belongs in `test_flows.py`, not in the flows: +teach `_client_tag_strings` the same rule. Until then a flow naming one of these +tags waits here with its floor left `unreleased`, which is the convention this +directory has always used: a floor states what the grep can prove. ## Promoting one -When the runner can walk a hop, a flow here is promoted by `git mv` and nothing -else. The hop itself is never written into the flow: the CSD names the surface -and `testing/gate/nav_map.py` derives the chain (`CSD.md` §2.0). +A flow here is promoted by `git mv` and nothing else. The hop is never written +into the flow: the CSD names the surface and `nav_map` derives the chain +(`CSD.md` §2.0). Check first, in this order: -1. `python3 -m testing.gate.run_flows --flows testing/flows/drafts/` loads it - (that module and the `csd:` binder arrive with #97; on `main` today - `FlowSpec.load` refuses the key — see below); -2. its CSD's §5 declares the platforms it should be green on; -3. the CSD's `stage:` follows `CSD.md` §1, and is edited by hand, never inferred: +1. `python3 -c "from testing.gate import run_flows; run_flows.load_flows(['testing/flows/drafts/'])"` + loads it, bound to its CSD; +2. every tag it drives passes `testing/test_flows.py::client_carries` — a + promoted flow is under `test_every_tag_a_seeded_flow_names_exists_in_the_client`; +3. its first `requires: screen:` is in `run_flows.nav_hops(has_agent=False)[0]` + (a hop exists) or is Contacts — a flow-only screen is `cannot-start`; +4. its CSD's §5 declares the platforms it should be green on; +5. the CSD's `stage:` follows `CSD.md` §1, and is edited by hand, never inferred: - `testable` needs the flow's `client:` floor to be no longer `unreleased` (any `>=X` / `>X` form) **and** the flow to run on the matrix; - `verified` needs that run green on every platform the CSD's §5 declares; - `shipped` is the stage whose floor names a published version. - A green run is evidence for the edit, never the edit itself. A card whose spec - is complete and whose flow is written, but which has not met that bar, stays - at `building` and says so in one line at the top of its §5, so the promotion - is a mechanical flip once it does. - -## Status on `main` (2026-09-28): none of the six nav-only flows promotes yet - -Tried for `csd-025`, `csd-036`, `csd-057`, `csd-066`, `csd-068` and `csd-087`. -None moved, for one reason common to all six and one extra for `csd-036`: - -- **They do not load with the loader on `main`.** `testing/gate/run_flows.py` - does not exist on `main`, and `FlowSpec.load` in `testing/gate/flow_spec.py` - refuses the `csd:` key every draft carries (`unknown key(s) ['csd']; allowed: - ['client', 'description', 'flow', 'steps', 'title']`). The binder that reads - `csd:` — and the runner that walks a hop — are in #97, still open. With `csd:` - removed each of the six parses, so the key is the only thing refused; removing - it would unbind the flow from its CSD, which is the wrong fix. They promote - when #97 lands. -- **`csd-036` is still floored `client: "unreleased"`**, so it would be refused - on every leg even once it loads. + A green run is evidence for the edit, never the edit itself. A card whose + flow is written but has not met that bar stays at `building` and says so in + one line at the top of its §5, so the promotion is a mechanical flip. ## 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. +A draft that names a value only a second node can produce — a contact's key +id, a peer to pick, a message's attestation id — says `fixture: two_node`, and +the runner stands a second `ciris-server` up beside the leg's node before the +first of them runs (`testing/gate/two_node.py`; the `${NAME}` values are in +`testing/flows/README.md`, "Two-node flows"). Every leg passes `--node-binary`, +so a fixture flow costs nothing more than `git mv`. -| 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`) | +`csd-005-people`, `csd-006-receipt` and `csd-047-network-content` moved on the +0.5.225 run. One stays: -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. +| file | fixture values it names | why it stays | +|---|---|---| +| `csd-091-user-chat.yaml` | `PEER_KEY_ID` to enter the room from People; `MESSAGE_ATTESTATION_ID` / `MESSAGE_TEXT` for the row | two unconferred v0.5.217 nodes never key the pair room, so no message crosses and `a_room_with_history` fails naming why (CIRISServer#698) | ## What is here -| file | CSD | screen it needs | beyond nav, what else it waits on | +| file | CSD | first screen | why it is still here | |---|---|---|---| -| `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) | -| `csd-066-child-safety.yaml` | CSD-066 | ChildSafety | nothing — **not promoted**: `csd:` key refused on `main` (#97) | -| `csd-068-provision-accord-holder.yaml` | CSD-068 | ProvisionAccordHolder | nothing — **not promoted**: `csd:` key refused on `main` (#97) | -| `csd-069-accord-ceremony.yaml` | CSD-069 | AccordCeremony | its last step needs six FIPS tokens; it is `optional_step` | -| `csd-081-login.yaml` | CSD-081 | Login | the observer step needs a second, non-owner account | -| `csd-082-setup-with-ai.yaml` | CSD-082 | Setup | a node with no owner — the fixture claims one during sign-in | -| `csd-083-setup-without-ai.yaml` | CSD-083 | Setup | same | -| `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 | 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. +| `csd-007-files.yaml` | CSD-007 | Files | CSD at `building`: `holds_bytes:sha256:{prefix}` unconfirmed | +| `csd-020-adapter-connectors.yaml` | CSD-020 | Adapters | CSD at `building`: proposed tags and unconfirmed fields; agent-only surface | +| `csd-025-system.yaml` | CSD-025 | System | CSD at `building`: `health:liveness:{version}`, `x_private:queue_depth` proposed | +| `csd-032-network-identity.yaml` | CSD-032 | NetworkIdentity | `federation_id_card_not_on_this_node` is interpolation-built (above); also flow-only first screen | +| `csd-033-network-peers.yaml` | CSD-033 | NetworkPeers | flow-only first screen; floor `>=0.5.225`; enter from the hub tile, then move | +| `csd-036-network-ops.yaml` | CSD-036 | NetworkOps | `netops_not_on_this_node` is interpolation-built (above) | +| `csd-039-data-erasure.yaml` | CSD-039 | DataManagement | CSD at `building`: `consent:{kind}` proposed, several fields unconfirmed | +| `csd-040-storage.yaml` | CSD-040 | Storage | `storage_disk_not_on_this_node` is interpolation-built (above) | +| `csd-045-node-self-standing.yaml` | CSD-045 | NodeSelfStanding | the ConfirmSheet's and the delegation picker's tags are interpolation-built (above) | +| `csd-046-network-trust-graph.yaml` | CSD-046 | NetworkTrustGraph | flow-only first screen; floor `>=0.5.225`; enter from the hub tile, then move | +| `csd-048-network-interfaces.yaml` | CSD-048 | NetworkInterfaces | flow-only first screen; floor `>=0.5.225`; enter from the hub tile, then move | +| `csd-049-network-queue.yaml` | CSD-049 | NetworkQueue | flow-only first screen; floor `>=0.5.225`; enter from the hub tile, then move | +| `csd-053-manage-consent.yaml` | CSD-053 | ManageConsent | CSD at `building`: `x_private:for_key_id`, `x_private:attesting_key_id` proposed and unconfirmed | +| `csd-054-partnership-queue.yaml` | CSD-054 | Consent | CSD at `building`: `consent:{kind}` proposed, `x_private:partnership_signed_by` unconfirmed | +| `csd-066-child-safety.yaml` | CSD-066 | ChildSafety | CSD at `building`: `hard_case:{kind}` proposed | +| `csd-066-child-safety-states.yaml` | CSD-066 | ChildSafety | same; and step `a_refresh_is_never_nothing` has a `do:` entry with no verb, so it does not load | +| `csd-069-accord-ceremony.yaml` | CSD-069 | AccordCeremony | flow-only first screen; floor `>=0.5.224`; enter from Accord, then move | +| `csd-081-login.yaml` | CSD-081 | Login | the runner signs in before any flow; needs `--no-sign-in` or its own sign-out | +| `csd-082-setup-with-ai.yaml` | CSD-082 | Setup | flow incomplete: the Finish step cannot be gated (CSD-082 §5) | +| `csd-083-setup-without-ai.yaml` | CSD-083 | Setup | CSD at `building`: `x_private:backend_endpoint` proposed; needs an unclaimed node | +| `csd-085-claim-node.yaml` | CSD-085 | ClaimNode | CSD at `building`: proposed tags; text fields not drivable | +| `csd-087-verify-agent.yaml` | CSD-087 | VerifyAgent | CSD at `building`: proposed tags; `input_verify_hash` not drivable | +| `csd-090-duty-conferral.yaml` | CSD-090 | DutyConferral | flow-only first screen; floor `>=0.5.224`; enter from the conferring screen, then move | +| `csd-091-user-chat.yaml` | CSD-091 | Contacts → UserChat | known red on released nodes (CIRISServer#698) | +| `csd-100-household.yaml` | CSD-100 | LayerFamily | the ConfirmSheet's tags are interpolation-built (above) | +| `csd-102-communities.yaml` | CSD-102 | LayerLocalCommunity | CSD at `building`: `x_private:affiliations_declared_record` unconfirmed | +| `csd-103-community-roster.yaml` | CSD-103 | CommunityRoster | CSD at `building`: two fields unconfirmed | +| `csd-104-key-verification.yaml` | CSD-104 | NetworkPeerDetail | CSD at `building`: SAS fields proposed and unconfirmed | +| `csd-105-trust-root.yaml` | CSD-105 | TrustRoot | CSD at `building`: `x_private:witnessed_head`, `x_private:seed_fingerprint` proposed and unconfirmed | diff --git a/testing/flows/drafts/csd-033-network-peers.yaml b/testing/flows/drafts/csd-033-network-peers.yaml index c4e0ab02..4772ce35 100644 --- a/testing/flows/drafts/csd-033-network-peers.yaml +++ b/testing/flows/drafts/csd-033-network-peers.yaml @@ -13,8 +13,7 @@ description: >- # text_add_peer_error, btn_federation_peers_refresh, btn_network_peers_back # (ui/screens/federation/NetworkPeersScreen.kt); federation_peers_error / # federation_peers_not_on_this_node (ReadFailureBlock). -client: "unreleased" - +client: ">=0.5.225" steps: - step_id: on_peers title: The peer list composes with its filter and its add door diff --git a/testing/flows/drafts/csd-046-network-trust-graph.yaml b/testing/flows/drafts/csd-046-network-trust-graph.yaml index dfdcbf6c..455f8a45 100644 --- a/testing/flows/drafts/csd-046-network-trust-graph.yaml +++ b/testing/flows/drafts/csd-046-network-trust-graph.yaml @@ -11,8 +11,7 @@ description: >- # btn_trust_graph_refresh (ui/screens/federation/NetworkTrustGraphScreen.kt); # federation_trust_graph_error / federation_trust_graph_not_on_this_node # (ReadFailureBlock). -client: "unreleased" - +client: ">=0.5.225" steps: - step_id: on_trust_graph title: The canvas composes diff --git a/testing/flows/drafts/csd-048-network-interfaces.yaml b/testing/flows/drafts/csd-048-network-interfaces.yaml index b437ca6f..69043205 100644 --- a/testing/flows/drafts/csd-048-network-interfaces.yaml +++ b/testing/flows/drafts/csd-048-network-interfaces.yaml @@ -10,8 +10,7 @@ description: >- # empty_interfaces, card_transport_${row.id} # (ui/screens/federation/NetworkInterfacesScreen.kt); federation_interfaces_error / # federation_interfaces_not_on_this_node (ReadFailureBlock). -client: "unreleased" - +client: ">=0.5.225" steps: - step_id: on_interfaces title: The card composes diff --git a/testing/flows/drafts/csd-049-network-queue.yaml b/testing/flows/drafts/csd-049-network-queue.yaml index 70b78780..ea32e650 100644 --- a/testing/flows/drafts/csd-049-network-queue.yaml +++ b/testing/flows/drafts/csd-049-network-queue.yaml @@ -14,8 +14,7 @@ description: >- # text_carriage_standing, text_receive_standing, text_replication_served, # text_replication_applied (ui/screens/federation/NetworkQueueScreen.kt); # federation_queue_error / federation_queue_not_on_this_node (ReadFailureBlock). -client: "unreleased" - +client: ">=0.5.225" steps: - step_id: on_queue title: The counters and the replication card compose From 7d4e63d2edf1019684629d0cbc5e34eeba4bf3a6 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:30 -0500 Subject: [PATCH 02/27] fix(flows): verify every circle and tab hop landed before the next click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cannot-start on the 0.5.225 matrix run (36588619656) — wallet, provision-accord-holder, household-members, network-content on both desktop legs, and notes-to-self on macOS — was one race: `CIRISApp.openTab` runs with the `circleNow` the last composition captured, so a tab clicked before the frame after the circle click has recomposed opens the OLD circle's tab. Just me's Rules tab has no Wallet row, its Safety tab has one card and opens ChildSafety directly, its People tab opens Contacts directly — each reported as "hop tag 'nav_epistemic_X' never appeared … on 'CircleTab'". A node client signs in under Neighbours (`defaultCircle`), which is why macOS lost `circle_agent -> tab_chats` to Rooms as well. The client now publishes the circle and tab the shell stands in on `/state` (`circle`, `tab`; the rail's selected state is only a background colour, so nothing in `/tree` could say). The runner waits for the shell to say a circle or tab hop landed before clicking the next one, always walks the hop even when the screen is already showing (Contacts sits in every circle's People tab, and the previous flow left the shell wherever it left it), and lists what was on screen when a hop tag never appears — the evidence that named this cause was missing from every cannot-start line. An older client without the fields is walked unverified. Local Linux desktop leg, node v0.5.217, after this: all nine flows reach their first screen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../desktop/testing/TestAutomationServer.kt | 4 +- .../kotlin/ai/ciris/mobile/shared/CIRISApp.kt | 9 ++ .../shared/testing/TestAutomationHandler.kt | 2 + .../shared/testing/TestAutomationState.kt | 15 +++ .../mobile/shared/testing/TestServerModels.kt | 5 +- .../testing/MobileAutomationSurfaceTest.kt | 16 ++++ testing/flows/README.md | 26 ++++- testing/gate/flow_helper.py | 8 ++ testing/gate/run_flows.py | 95 ++++++++++++++++--- testing/test_flows.py | 35 ++++++- 11 files changed, 190 insertions(+), 27 deletions(-) diff --git a/client/VENDORING.md b/client/VENDORING.md index 08a3dc81..95ccfa32 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `1ab9a22b7e23dea8b1e2dc3aac8a8c6e3cc95c5ca7d6783a4d73bf40321f2bbd` +**state digest:** `ad4dfe03a780835a88ca4604b6b114c1c15ae937bfc331e3350908768efa47e9` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt index add24bb1..4d3240e3 100644 --- a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt +++ b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt @@ -360,7 +360,9 @@ class TestAutomationServer( screen = currentScreen, testMode = true, clientMode = ai.ciris.mobile.shared.testing.TestAutomationState.clientMode, - nodeUrl = ai.ciris.mobile.shared.testing.TestAutomationState.nodeUrl + nodeUrl = ai.ciris.mobile.shared.testing.TestAutomationState.nodeUrl, + circle = ai.ciris.mobile.shared.testing.TestAutomationState.circle, + tab = ai.ciris.mobile.shared.testing.TestAutomationState.tab, )) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index 41698f07..1fc46d5a 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt @@ -5134,6 +5134,15 @@ fun CIRISApp( is Screen.CircleTab -> ai.ciris.mobile.shared.ui.nav.Tab.entries.firstOrNull { it.id == sc.tabId } else -> activeSurface?.let { ai.ciris.mobile.shared.ui.nav.CirclesNav.tabOf(it) } } + // Publish where the shell stands to test automation (`/state`), so + // a harness can see a circle hop LAND before it clicks the tab: + // `onTab` below runs with the `circleNow` of the composition that + // made it, and a tab clicked before the next frame opens the old + // circle's tab (the 2026-09-29 matrix run, every desktop leg). + LaunchedEffect(circleNow, tabNow) { + ai.ciris.mobile.shared.testing.TestAutomationState.circle = circleNow.id + ai.ciris.mobile.shared.testing.TestAutomationState.tab = tabNow?.id ?: "" + } fun openTab(c: ai.ciris.mobile.shared.ui.nav.CohortScope, t: ai.ciris.mobile.shared.ui.nav.Tab) { val cards = ai.ciris.mobile.shared.ui.nav.CirclesNav.cards(c, t, hasAgentNow) currentCircle = c diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt index 7dab5d2f..44d07676 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt @@ -125,6 +125,8 @@ object TestAutomationHandler { testMode = true, clientMode = TestAutomationState.clientMode, nodeUrl = TestAutomationState.nodeUrl, + circle = TestAutomationState.circle, + tab = TestAutomationState.tab, ) fun handleScreen(): ScreenResponse { diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt index 1dbf31e2..54b2391f 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationState.kt @@ -31,6 +31,21 @@ object TestAutomationState { /** The node URL the app settled on -- local default, or a remote override. */ var nodeUrl: String = "" + /** + * The circle and tab the shell stands in (`CohortScope.id`, `Tab.id`; "" + * outside the shell). Written by `CIRISApp` beside `CirclesShell`. + * + * A harness walking `circle_x -> tab_y` needs to know the circle CHANGED + * before it clicks the tab: `openTab` runs with the `circleNow` the last + * composition captured, so a tab clicked in the same frame as the circle + * opens the old circle's tab. The 2026-09-29 five-platform run lost four + * flows to that race on every desktop leg, each reported as a row that + * "never appeared". Nothing in `/tree` says which circle is selected + * (the rail's selected state is a background colour), so `/state` says. + */ + var circle: String = "" + var tab: String = "" + // Window position offset (desktop only, for converting to screen coords) var windowX: Int = 0 var windowY: Int = 0 diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt index 18d9b85a..3f95dedd 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestServerModels.kt @@ -87,7 +87,10 @@ data class StateResponse( val screen: String, val testMode: Boolean, val clientMode: String, - val nodeUrl: String + val nodeUrl: String, + /** The circle and tab the shell stands in — see `TestAutomationState.circle`. */ + val circle: String = "", + val tab: String = "", ) /** diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt index 377769a4..308c93e7 100644 --- a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt @@ -280,4 +280,20 @@ class MobileAutomationSurfaceTest { assertEquals("Setup", s.screen) assertTrue(s.testMode) } + + @Test + fun state_reports_the_circle_and_tab_the_shell_stands_in() { + // A circle click and the tab click after it race on the composition + // that captured `circleNow` (CIRISApp.openTab): a tab clicked before + // the frame after the circle click recomposes opens the OLD circle's + // tab. The five-platform run of 2026-09-29 lost four flows to that on + // every desktop leg. The flow runner now verifies the circle changed + // before it clicks the tab, and THIS is what it reads — the shell's + // own state, not a localized label. + TestAutomationState.circle = "global-communities" + TestAutomationState.tab = "rules" + val s = TestAutomationHandler.handleState() + assertEquals("global-communities", s.circle) + assertEquals("rules", s.tab) + } } diff --git a/testing/flows/README.md b/testing/flows/README.md index 119021d5..7dfee344 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -157,10 +157,28 @@ bring-up per leg and one session. the hop `testing/gate/nav_map.py` derives for the flow's first `requires: screen:` on this build (node or agent tree, from `/state`), waiting for each tag before clicking it. A missing hop tag is `cannot-start` - naming the tag; a screen with no hop that is not flow-only is `cannot-start` - 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. + naming the tag and listing what was on screen; a screen with no hop that is + not flow-only is `cannot-start` 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. +- **The hop is always walked, and every circle and tab hop is verified.** Being + on the screen already says nothing about which circle it is shown in + (Contacts sits in every circle's People tab), and the last flow left the + shell wherever it left it, so the runner re-selects the hop's circle and tab + every time. After each `circle_*` / `tab_*` click it reads `/state` (`circle`, + `tab`, from client 0.5.226) until the shell says it stands there; a click + that succeeded is not a hop that took — `CIRISApp.openTab` runs with the + circle the last composition captured, and a tab clicked in the same frame + as the circle opens the OLD circle's tab. That race cost the 2026-09-29 run + four flows on every desktop leg, each reported as a row that "never + appeared". On a client without those `/state` fields the hop is walked + unverified. +- **A flow closes what it opened, whatever its verdict.** `cleanup:` is a list + of actions run after the flow — pass, fail or crash. A flow stops at its + first failed step, and a card that step left open (the contact-code card + replaces People's body and its open state lives in the view model) is the + next flow's failure. A cleanup that fails is reported in the outcome's + detail and the per-flow JSON; it never changes the verdict. - **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. Three flows here ask for it diff --git a/testing/gate/flow_helper.py b/testing/gate/flow_helper.py index 7b5b12f1..95bc5c0f 100644 --- a/testing/gate/flow_helper.py +++ b/testing/gate/flow_helper.py @@ -96,6 +96,14 @@ async def get_screen(self) -> str: except DriverError: return "unknown" + async def get_state(self) -> dict: + """`/state`: the gate, the node, and — from 0.5.226 — the circle and + tab the shell stands in, which is how `navigate` sees a hop land.""" + try: + return self._drv.state() + except DriverError: + return {} + async def is_element_visible(self, tag: str) -> bool: e = await self.get_element(tag) if e is None: diff --git a/testing/gate/run_flows.py b/testing/gate/run_flows.py index 8b30e3cd..c0d4f9a8 100644 --- a/testing/gate/run_flows.py +++ b/testing/gate/run_flows.py @@ -117,22 +117,81 @@ async def _settle_on(helper, screen: str, timeout: float, poll: float = 1.0) -> return cur +async def _on_screen(helper) -> List[str]: + """Tags on screen now, by the runner's own rule (FlowRunner._drivable).""" + try: + elements = await helper.get_elements() + except Exception: # noqa: BLE001 — diagnosis must never raise + return [] + out = [] + for e in elements: + vis = getattr(e, "visible", None) + shown = vis if vis is not None else (getattr(e, "width", 1) > 0 and getattr(e, "height", 1) > 0) + if shown: + out.append(e.test_tag) + return sorted(out) + + +async def _hop_landed(helper, tag: str, timeout: float, poll: float = 0.25) -> Optional[str]: + """After a circle or tab hop is clicked, wait for the shell to SAY it stands + there (`/state`'s `circle` / `tab`). None once it does, or on a client that + serves neither (an older client: the hop is walked unverified); else why. + + THE CLICK IS NOT THE HOP. `CIRISApp.openTab` runs with the `circleNow` the + last composition captured, so a tab clicked before the frame after the + circle click has recomposed opens the OLD circle's tab. Every desktop leg + of the 2026-09-29 run lost four flows to that: Just me's Rules tab has no + Wallet row, its Safety tab has one card and opens ChildSafety directly, its + People tab opens Contacts directly — each reported as a row that "never + appeared", and on macOS, where a node client signs in under Neighbours, + even `circle_agent -> tab_chats` landed on Rooms. + """ + if tag.startswith("circle_"): + key, want = "circle", tag[len("circle_"):].replace("_", "-") + elif tag.startswith("tab_"): + key, want = "tab", tag[len("tab_"):] + else: + return None + read = getattr(helper, "get_state", None) + if read is None: + return None + deadline = time.monotonic() + timeout + while True: + state = await read() + if not isinstance(state, dict) or key not in state: + return None + got = state.get(key) + if got == want: + return None + if time.monotonic() >= deadline: + return (f"{tag!r} was clicked, but the shell still stands in {key} {got!r} " + f"after {timeout:.0f}s — the hop did not take") + await asyncio.sleep(poll) + + async def navigate(helper, screen: str, chain: Sequence[str], *, hop_timeout: float = 20.0, arrive_timeout: float = 20.0) -> Optional[str]: """Walk `chain` (nav_map's derived hop) to `screen`. None on arrival, else the reason — naming the hop tag that was missing, because "could not reach - Screen.X" alone sends the reader to the wrong end of the chain. + Screen.X" alone sends the reader to the wrong end of the chain, and listing + what WAS on screen, because that is what names the cause. Each tag is WAITED for before it is clicked: a circle's tabs compose after the circle is chosen, and clicking before they exist is a race, not a test. + And each circle or tab hop is VERIFIED to have landed before the next is + clicked (`_hop_landed`): a click that succeeded is not a hop that took. """ for i, tag in enumerate(chain, 1): where = f"hop {i} of {len(chain)} ({' -> '.join(chain)})" if not await helper.wait_for_element(tag, timeout=int(hop_timeout * 1000)): return (f"navigation to Screen.{screen}: hop tag {tag!r} never appeared, {where}; " - f"on {await helper.get_screen()!r}") + f"on {await helper.get_screen()!r}; on screen and drivable now: " + f"{await _on_screen(helper)}") if not await helper.click(tag, timeout=int(hop_timeout * 1000)): return f"navigation to Screen.{screen}: clicking hop tag {tag!r} failed, {where}" + landed = await _hop_landed(helper, tag, hop_timeout) + if landed: + return f"navigation to Screen.{screen}: {landed}, {where}" got = await _settle_on(helper, screen, arrive_timeout) if got == "CircleTab" and screen != "CircleTab": # A tab with ONE card opens it directly in the wide layout (nav_map @@ -179,20 +238,26 @@ async def run_one(spec: FlowSpec, helper, *, platform=None, artifacts: Optional[ if start and hops is None: await _settle_on(helper, start, start_timeout) elif start: - # A landing still composing is not a flow on the wrong screen: give the - # client a moment before deciding to walk anywhere. - cur = await _settle_on(helper, start, min(start_timeout, 5.0)) err = None - if cur != start: - if start in hops: - print(f"\n FLOW {spec.flow} — walking to Screen.{start}: {' -> '.join(hops[start])}") - err = await navigate(helper, start, hops[start], - hop_timeout=start_timeout, arrive_timeout=start_timeout) - elif start in flow_only: - # Pre-login, wizards, leaves: nothing in the shell leads there, - # so the flow must already be on it. Wait, then let `requires` judge. - await _settle_on(helper, start, start_timeout) - else: + if start in hops: + # ALWAYS WALKED, even when the client already shows the screen. + # Contacts sits in every circle's People tab, and the last flow + # left the shell wherever it left it; being on the screen says + # nothing about the circle it is shown in. Re-selecting the hop's + # circle and tab — and verifying each landed — is what makes every + # flow start from a known place rather than the previous flow's. + print(f"\n FLOW {spec.flow} — walking to Screen.{start}: {' -> '.join(hops[start])}") + err = await navigate(helper, start, hops[start], + hop_timeout=start_timeout, arrive_timeout=start_timeout) + elif start in flow_only: + # Pre-login, wizards, leaves: nothing in the shell leads there, + # so the flow must already be on it. Wait, then let `requires` judge. + await _settle_on(helper, start, start_timeout) + else: + # A landing still composing is not a flow on the wrong screen: give + # the client a moment before deciding it is elsewhere. + cur = await _settle_on(helper, start, min(start_timeout, 5.0)) + if cur != start: err = (f"no nav hop for Screen.{start} on this build, and it is not a " f"flow-only screen (on {cur!r})") if err: diff --git a/testing/test_flows.py b/testing/test_flows.py index 1be8ba70..93756312 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -309,6 +309,17 @@ def __init__(self, screen: str, tags: Dict[str, str]): self.els = {t: _El(t, txt) for t, txt in tags.items()} self.calls: list[str] = [] self.leads: dict = {} + # The shell's own account of where it stands (`/state`): a circle click + # lands at once here; `_RacyShell` below is the one that lands late. + self.circle, self.tab = "", "" + # Tags that are on screen but whose click the app refuses, and the + # reason the last refusal gave (what the real helper keeps). + self.refuse: set = set() + self.last_error = "" + + async def get_state(self): + self.calls.append("state") + return {"screen": self.screen, "circle": self.circle, "tab": self.tab} async def get_elements(self): self.calls.append("tree") @@ -330,8 +341,14 @@ async def scroll_into_view(self, tag): async def click(self, tag, timeout=2000): self.calls.append(f"click {tag}") - if tag not in self.els: + if tag not in self.els or tag in self.refuse: + self.last_error = (f"no such element {tag!r}" if tag not in self.els + else f"HTTP 404: No click handler for {tag!r}") return False + if tag.startswith("circle_"): + self.circle = tag[len("circle_"):].replace("_", "-") + elif tag.startswith("tab_"): + self.tab = tag[len("tab_"):] # A click can move the app: `leads` maps a tag to (screen, tags now shown). if tag in self.leads: self.screen, shown = self.leads[tag] @@ -538,10 +555,18 @@ def test_a_flow_only_screen_is_waited_for_not_walked_to(tmp_path): assert "no nav hop" not in out.detail, "flow-only is not a missing hop" -def test_already_on_the_first_screen_walks_nothing(tmp_path): - h = FakeHelper("Thing", {"thing_list": ""}) - assert _nav_run(_spec(tmp_path), h).status == run_flows.PASS - assert not [c for c in h.calls if c.startswith("click")] +def test_already_on_the_first_screen_still_walks_its_hop(tmp_path): + """Being on the screen says nothing about WHICH circle it is shown in — + Contacts sits in every circle's People tab — and the last flow left the + shell wherever it left it. The hop is re-walked, and verified, so every + flow starts from a known circle and tab, not from the previous flow's.""" + h = _walkable() + h.screen = "Thing" + h.els["thing_list"] = _El("thing_list", "") + out = _nav_run(_spec(tmp_path), h) + assert out.status == run_flows.PASS, out.detail + assert [c for c in h.calls if c.startswith("click")] == [ + "click circle_x", "click tab_y", "click nav_thing"] def test_the_real_nav_map_reaches_the_seeded_flows_first_screens(): From 1e0e74a4d424cef57fa7956e04ce0757f0140f85 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:30 -0500 Subject: [PATCH 03/27] fix(flows): a flow closes what it opened, whatever its verdict (`cleanup:`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `people`, `csd_005_people` and `csd_006_receipt` failed on both desktop legs of run 36588619656 with `card_contacts_add` / `contacts_list` / `contacts_row_` "never appeared", and every one of their on-screen lists carried `btn_contact_code_close` and `contact_code_error`: csd_092 had opened the contact-code card — which replaces People's body while open, and whose open state lives in the view model — and stopped at its failed second step with the card still up. The ordering (session → fixture-free flows → two_node → fixture flows) was already right; the leak was inside the screen. A flow may now declare `cleanup:` — actions run after its steps, pass, fail or crash. Only the flow knows what it opened; the runner guarantees the closing runs. A cleanup that fails is recorded in the outcome's detail and the per-flow JSON, never as the verdict; a target already gone is nothing to close. csd-092 and csd-005 (whose last step opens the same card) close it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/flows/README.md | 2 + testing/flows/csd-005-people.yaml | 4 ++ testing/flows/csd-092-share-contact-code.yaml | 6 ++ testing/gate/VENDORED.md | 1 + testing/gate/flow_spec.py | 61 ++++++++++++++++++- testing/gate/run_flows.py | 4 ++ testing/test_flows.py | 2 + 7 files changed, 78 insertions(+), 2 deletions(-) diff --git a/testing/flows/README.md b/testing/flows/README.md index 7dfee344..19510f33 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -17,6 +17,8 @@ title: People on a node with no contacts yet description: >- # optional; say what is NOT driven and why … client: ">=0.5.224" # the client that carries every tag it names +cleanup: # optional; run AFTER the flow, pass or fail + - click: btn_contact_code_close steps: - step_id: landing diff --git a/testing/flows/csd-005-people.yaml b/testing/flows/csd-005-people.yaml index 52e3f4b9..4e54cbf7 100644 --- a/testing/flows/csd-005-people.yaml +++ b/testing/flows/csd-005-people.yaml @@ -24,6 +24,10 @@ client: ">=0.5.225" # 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 +# The last step opens the contact-code card, which replaces People's body +# until closed (csd-092-share-contact-code.yaml); closed here whatever the verdict. +cleanup: + - click: btn_contact_code_close steps: - step_id: on_people diff --git a/testing/flows/csd-092-share-contact-code.yaml b/testing/flows/csd-092-share-contact-code.yaml index 104e680d..f19180b0 100644 --- a/testing/flows/csd-092-share-contact-code.yaml +++ b/testing/flows/csd-092-share-contact-code.yaml @@ -17,6 +17,12 @@ description: >- # text_contact_code_reachable_status, contact_code_loading, contact_code_error, # btn_contact_code_close) are literals in ui/screens/PeopleSupport.kt at 0.5.225. client: ">=0.5.225" +# The card replaces People's body while it is open (ContactsScreen: "Open, it +# IS the body"), and its open state lives in the view model, so a flow that +# stops with it open leaves the next flow on People with no list and no add +# card. Closed whatever this flow's verdict. +cleanup: + - click: btn_contact_code_close steps: - step_id: open_the_card diff --git a/testing/gate/VENDORED.md b/testing/gate/VENDORED.md index a4b58050..64bf56ae 100644 --- a/testing/gate/VENDORED.md +++ b/testing/gate/VENDORED.md @@ -80,6 +80,7 @@ Added so CSD flows can run on this repo's matrix (`testing/flows/`, | `state:` is now CHECKED: that state's tag on screen, every other state's tag not; with no `states:` map it fails | upstream's `state:` body was `pass` — the one predicate CSD/3 makes mandatory asserted nothing, a vacuous green. Upstream flows do not use `state:`, so none of them changes behaviour | | `FlowRunner(state_tags=…)`; `run()` fills both maps from `spec.csd` when the caller did not | one source for the maps: the CSD | | `write_report` records `csd` | a result that cannot say what it tested cannot be acted on | +| `cleanup` added to `_FLOW_KEYS`; `FlowSpec.cleanup` (a list of actions); `FlowRunner.run()` runs them in a `finally`, pass, fail or crash, and records what could not be done (`cleanup_failures`, in the report and the outcome's detail) | a flow stops at its first failed step, and whatever that step left open is the NEXT flow's failure. On 2026-09-29 `csd_092` opened the contact-code card (which replaces People's body, and whose open state lives in the view model), failed on step two, and `people`, `csd_005` and `csd_006` failed for its reason on every desktop leg. Only the flow knows what it opened; the runner guarantees the closing runs. A cleanup that fails is said, never the verdict — the verdict is the flow's | A flow without `csd:` still loads and runs exactly as before; `run_flows.py` is what requires the key for flows in this repo. Upstream needs the same key diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index 548c2b9d..35372482 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -61,7 +61,7 @@ _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", "fixture"} +_FLOW_KEYS = {"flow", "title", "description", "client", "steps", "csd", "fixture", "cleanup"} #: 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 @@ -275,10 +275,20 @@ class FlowSpec: csd: Any = None # testing.gate.csd_doc.CsdDoc #: LOCAL DELTA: the fixture this flow needs (`fixture: two_node`), or None. fixture: Optional[str] = None + #: LOCAL DELTA: actions run AFTER the flow, pass or fail — closing what it + #: opened. A flow stops at its first failed step, and a card that step left + #: open is the next flow's failure: csd_092 opened the contact-code card, + #: failed on its second step, and `people`, `csd_005` and `csd_006` then + #: failed for its reason on every desktop leg (2026-09-29). Only the flow + #: knows what it opened; the runner guarantees the closing runs. + cleanup: List[Action] = field(default_factory=list) 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)}) + names = {n for step in self.steps for n in _step_variables(step)} + for a in self.cleanup: + names |= set(_VAR.findall(a.target)) | set(_VAR.findall(a.value or "")) + return sorted(names) @classmethod def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": @@ -314,6 +324,10 @@ def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": 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 + cleanup_raw = raw.get("cleanup") or [] + if not isinstance(cleanup_raw, list): + raise SpecError(f"{path}: `cleanup` is a list of actions (click / input / scroll_to / wait)") + spec.cleanup = [Action.parse(a, f"{path}: cleanup[{i}]") for i, a in enumerate(cleanup_raw)] # 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. @@ -391,6 +405,12 @@ def _bind_csd(self, csd_id: Any, csd_root: Optional[Path]) -> None: f"{at}.do drives {action.target!r}, which {doc.csd_id} still " f"marks `proposed:`" ) + for action in self.cleanup: + if action.target in doc.proposed: + raise SpecError( + f"{where}: cleanup drives {action.target!r}, which {doc.csd_id} still " + f"marks `proposed:`" + ) class UnresolvedVariable(Exception): @@ -547,6 +567,8 @@ def __init__(self, helper, platform=None, artifacts: Optional[Path] = None, self.platform = platform self.artifacts = Path(artifacts) if artifacts else None self.results: List[StepResult] = [] + #: LOCAL DELTA: what the flow's `cleanup:` could not do, one line each. + self.cleanup_failures: List[str] = [] #: CSD `ceg:` field id -> the tag carrying it, from the CSD's `shows:` #: block. `relation` operands are field ids, so without this a flow #: could only relate boxes rather than constitutional values. @@ -760,6 +782,40 @@ def _shot(self, spec: FlowSpec, step: Step) -> Optional[str]: return str(got) if got else None async def run(self, spec: FlowSpec) -> bool: + """The steps, then — pass, fail or crash — the flow's `cleanup:`.""" + try: + return await self._steps(spec) + finally: + await self._cleanup(spec) + + async def _cleanup(self, spec: FlowSpec) -> None: + """LOCAL DELTA: close what the flow opened, whatever its verdict. A + failure here is recorded, never raised: it is not this flow's verdict, + and hiding the verdict behind it would help nobody.""" + for action in spec.cleanup: + try: + action = Action(action.kind, + substitute(action.target, self.variables, self.variable_notes), + substitute(action.value, self.variables, self.variable_notes) + if action.value is not None else None, + action.wait_ms) + except UnresolvedVariable as exc: + self.cleanup_failures.append(str(exc)) + print(f" cleanup: {exc}") + continue + # Already gone is nothing to close: a flow whose own last step shut + # the card it opened must not then report its cleanup as a failure. + if action.kind in ("click", "input") and await self.helper.get_element(action.target) is None: + print(f" cleanup: nothing to close \u2014 {action.target!r} is not on screen") + continue + err = await self._do(action) + if err: + self.cleanup_failures.append(err) + print(f" cleanup: {err}") + else: + print(f" cleanup: {action.describe()}") + + async def _steps(self, spec: FlowSpec) -> bool: if spec.csd is not None: # LOCAL DELTA: a flow that names its CSD carries its own maps. self.field_tags = self.field_tags or dict(spec.csd.field_tags) @@ -857,6 +913,7 @@ def write_report(self, spec: FlowSpec) -> Optional[Path]: "csd": spec.csd_id, "client_floor": spec.client_floor, "passed": all(r.status != "fail" for r in self.results), + "cleanup_failures": list(self.cleanup_failures), "steps": [ { "step_id": r.step_id, "title": r.title, "status": r.status, diff --git a/testing/gate/run_flows.py b/testing/gate/run_flows.py index c0d4f9a8..2d4d3733 100644 --- a/testing/gate/run_flows.py +++ b/testing/gate/run_flows.py @@ -285,6 +285,10 @@ async def run_one(spec: FlowSpec, helper, *, platform=None, artifacts: Optional[ else: status = FAIL detail = f"step {bad.step_id!r} ({bad.phase}): {bad.detail}" if bad else "failed" + if runner.cleanup_failures: + # Said, not judged: the verdict is the flow's; a cleanup that did not + # run is what the NEXT flow will fail for, so it is on the record here. + detail += "; cleanup: " + "; ".join(runner.cleanup_failures) return FlowOutcome(spec.flow, spec.csd_id, status, detail, steps, str(report) if report else None) diff --git a/testing/test_flows.py b/testing/test_flows.py index 93756312..8ad5c04a 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -278,6 +278,8 @@ def test_every_tag_a_seeded_flow_names_exists_in_the_client(): literals, prefixes = _client_tag_strings() missing = [] for spec in run_flows.load_flows([FLOWS]): + missing += [f"{spec.flow}/cleanup: {a.target}" for a in spec.cleanup + if not client_carries(a.target, literals, prefixes)] for step in spec.steps: tags = [a.target for a in step.do] for cond in (step.requires, step.expect): From 8045b016f6bd5e12b974b6d29d8f0138add32a72 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:30 -0500 Subject: [PATCH 04/27] fix(client): a state block's tree text carries its body and detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit csd_092 failed on both desktop legs: `contact_code_error` text "Could not get your contact code." does not contain "0.5.218 or newer". The client was right — a bare 404 maps to `ContactCodeState.NodeTooOld` and the card draws "This node can't make a contact code yet. It needs ciris-server 0.5.218 or newer." as the error's BODY, which is CSD-092's `error:` contract — but `StateBlock` registered its tag with the title alone, so the tree could not show the words the person reads. `ListState.automationText` now joins message, body and detail, one per line, and the block registers that. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../mobile/shared/ui/primitives/StateBlock.kt | 25 +++++++++- .../StateBlockAutomationTextTest.kt | 46 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlockAutomationTextTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index 95ccfa32..6b549328 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `ad4dfe03a780835a88ca4604b6b114c1c15ae937bfc331e3350908768efa47e9` +**state digest:** `a1f488c422e756a6fa6f354061079495b65c872e3cfceced2f402b49ea86a5b5` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlock.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlock.kt index 68a276ee..8dec9c3d 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlock.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlock.kt @@ -68,6 +68,29 @@ fun ListState.style(): StateStyle = when (this) { /** The glyph tint for a state — never `ok` for an error or an unreviewed state; the test pins it. */ fun ListState.tint(t: CirisTokens) = t.tone(style().tone) +/** + * What `/tree` carries for a state block's tag: EVERYTHING THE BLOCK DRAWS — + * the message (or the label when there is none), then an error's body and + * detail, one per line. A flow's `text:` is a claim about the sentence a + * person reads; CSD-092 puts its version fact in the error's BODY ("It needs + * ciris-server 0.5.218 or newer."), and with the title alone registered the + * client rendered the right words while the tree could not show them + * (every desktop leg, 2026-09-29). + */ +fun ListState.automationText(label: String?): String? { + val message = when (this) { + is ListState.Empty -> message + is ListState.Error -> title + is ListState.FileGone -> message + is ListState.HiddenByRules -> message + is ListState.Unreviewed -> message + ListState.Loading, ListState.Populated -> null + } + val error = this as? ListState.Error + val parts = listOfNotNull(message ?: label, error?.body, error?.detail) + return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") +} + @Composable fun StateBlock( state: ListState, @@ -99,7 +122,7 @@ fun StateBlock( } val frame = modifier .fillMaxWidth() - .testable(tag, message ?: label) + .testable(tag, state.automationText(label)) .let { m -> if (style.bordered) { m.clip(CirisShape.card) diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlockAutomationTextTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlockAutomationTextTest.kt new file mode 100644 index 00000000..9ae83bac --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/StateBlockAutomationTextTest.kt @@ -0,0 +1,46 @@ +package ai.ciris.mobile.shared.ui.primitives + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * WHAT `/tree` CARRIES FOR A STATE BLOCK IS WHAT THE BLOCK DRAWS. + * + * `StateBlock` registered its tag with the title alone. CSD-092's error state + * puts the fact that matters in the BODY — "It needs ciris-server 0.5.218 or + * newer." — and its flow asserts that sentence, so on every desktop leg of the + * 2026-09-29 run the client rendered the right words and the tree reported + * "Could not get your contact code." only. A flow's `text:` is a claim about + * what a person reads; the tree has to carry all of it. + */ +class StateBlockAutomationTextTest { + + @Test + fun anErrorsBodyAndDetailAreInItsAutomationText() { + val state = ListState.Error( + title = "Could not get your contact code.", + body = "This node can't make a contact code yet. It needs ciris-server 0.5.218 or newer.", + detail = "404", + ) + assertEquals( + "Could not get your contact code.\n" + + "This node can't make a contact code yet. It needs ciris-server 0.5.218 or newer.\n404", + state.automationText(label = "ERROR"), + ) + } + + @Test + fun anErrorWithOnlyATitleReadsAsBefore() { + assertEquals("Nope.", ListState.Error(title = "Nope.").automationText(label = "ERROR")) + } + + @Test + fun theOtherStatesReadTheirMessageThenTheirLabel() { + assertEquals("Nobody here yet", ListState.Empty("Nobody here yet").automationText(label = null)) + assertEquals("Gone", ListState.FileGone("Gone").automationText(label = "GONE")) + assertEquals("LOADING", ListState.Loading.automationText(label = "LOADING")) + assertNull(ListState.Loading.automationText(label = null)) + assertNull(ListState.Populated.automationText(label = null)) + } +} From e2c27a8613a33e824dc986bd916b03c87ffdaa3b Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:30 -0500 Subject: [PATCH 05/27] =?UTF-8?q?fix(two-node):=20wait=20for=20the=20owner?= =?UTF-8?q?=E2=86=92node=20binding=20before=20opening=20the=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 36588619656 logged `contact … reachable_nodes=0` on both sides and then `awaiting_peer` for the whole 150 s on every leg. The fixture waited for the peer's owner KEY to be known, which lands before the owner→node BINDING; CIRISServer#699 measured that a contact added while `reachable_nodes=0` keys a pair room whose bodies read `not_granted` for good, and that re-asking until it is >= 1 before the room fixes it (3/3 reproduced, 2/2 fixed). CIRISServer `FSD/TOPOLOGY.md` (chore/adopt-edge-v33) §2.5 defines the relation: `reachable(A, q) >= n` is "POST /v1/contacts on A for q reports reachable_nodes >= n (q's owner→node binding held on A at federation scope)"; §3 rule 5 needs one of q's nodes announced, which the fixture's announce is; §2.3 keeps scoped content to directly-attached peers (CC 5.4.6), which the peer dialling the leg's node satisfies. No read route answers the predicate, so `add_contact` re-asks the idempotent POST every 5 s, bounded (`reachable_wait`, 120 s), and records what it waited on and for how long in `values.notes`. Cited in the module docstring. Local Linux leg, node v0.5.217: reachable_nodes=1 after 10 s (3 asks) on the leg's node, at once on the peer. The room still does not key on the released line (awaiting_peer after 150 s) — the KeyPackage does not cross between two unconferred 0.5.217 nodes, as the README already records; the contact, its row and its five-fact receipt are seeded, and csd_005 / csd_006 pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/flows/README.md | 18 ++++++++-- testing/gate/two_node.py | 74 +++++++++++++++++++++++++++++++++++----- testing/test_two_node.py | 58 +++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 12 deletions(-) diff --git a/testing/flows/README.md b/testing/flows/README.md index 19510f33..2abdece1 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -209,9 +209,21 @@ 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. +other's contact **and waited for until the other is reachable from it**, 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 reachability wait is CIRISServer `FSD/TOPOLOGY.md` §2.5's `reachable(A, q)` +relation: `POST /v1/contacts` on A for q reporting `reachable_nodes >= 1`, +which means q's owner→node BINDING is held on A at federation scope — a later +fact than q's owner KEY being known, which is all the fixture used to wait for. +A contact added while it is 0 keys a pair room whose bodies read `not_granted` +for good (CIRISServer#699); the 2026-09-29 run logged `reachable_nodes=0` on +both sides and then `awaiting_peer` for the whole wait. The POST is the +predicate (no read route answers it), so the fixture re-asks it every 5 s, up +to `reachable_wait` (120 s), and writes what it waited on and for how long into +`values.notes`. The values a flow may name: diff --git a/testing/gate/two_node.py b/testing/gate/two_node.py index 5fade171..149b029a 100644 --- a/testing/gate/two_node.py +++ b/testing/gate/two_node.py @@ -37,6 +37,22 @@ 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. + * The owner→node BINDING is waited for too, and it is a different, later + thing than the key. CIRISServer `FSD/TOPOLOGY.md` (chore/adopt-edge-v33) + §2.5 defines the relation this fixture must realise before the room: + `reachable(A, q) >= n` — "`POST /v1/contacts` on A for q reports + `reachable_nodes >= n` (q's owner→node binding held on A at federation + scope); the gate CIRISServer#699 needs" — and §3 rule 5 says it needs one + of q's nodes `announced: true`, which the announce above is. A contact + added while `reachable_nodes=0` keys a pair room whose bodies read + `not_granted` for good (#699: reproduced 3/3, fixed-by-ordering 2/2); the + 2026-09-29 matrix logged exactly `reachable_nodes=0` on both sides, then + `awaiting_peer` for the whole 150 s. So [add_contact] re-asks the POST — + the route TOPOLOGY names as the predicate; no read route answers it — + until the count is >= 1, bounded, and records what it waited on and for + how long. The two direct peers also satisfy §2.3: scoped content (chat + bodies) reaches DIRECTLY-ATTACHED peers only (CC 5.4.6), and the peer + dials the leg's node. WHAT IS DIFFERENT, and why: @@ -515,10 +531,25 @@ def knows(host: Party, key_id: str) -> bool: return status == 200 +def _reachable(body: Any) -> int: + try: + return int((body or {}).get("reachable_nodes") or 0) + except (TypeError, ValueError, AttributeError): + return 0 + + def add_contact(host: Party, guest: Party, wait: float, notes: List[str], - code: str = "", log: Log = _say) -> Tuple[str, str]: + code: str = "", log: Log = _say, reachable_wait: float = 120.0) -> 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.""" + their contact code if the node serves one; else their NODE, said so. + + Then the BINDING: the add answers `reachable_nodes`, and 0 means the + guest's owner→node binding is not yet held on `host` at federation scope + (module doc, TOPOLOGY §2.5). The POST is idempotent (a standing grant is + `freshly_emitted: false`), so it is re-asked every 5 s for up to + `reachable_wait` until the count is >= 1; what was waited on, and for how + long, goes in `notes`. Unreachable is still not a refusal: the contact + stands, and the note says the room may key without a grant (#699).""" 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) @@ -533,7 +564,29 @@ def add_contact(host: Party, guest: Party, wait: float, notes: List[str], 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 + held = str(body.get("key_id") or key) + reachable = _reachable(body) + if reachable == 0 and reachable_wait > 0: + started, asks = time.monotonic(), 1 + while reachable == 0 and time.monotonic() - started < reachable_wait: + time.sleep(5.0) + again, body = http("POST", f"{host.url}/v1/contacts", host.token, {"key_id": key}) + asks += 1 + reachable = _reachable(body) if _ok(again) and isinstance(body, dict) else 0 + waited = time.monotonic() - started + if reachable >= 1: + notes.append( + f"waited {waited:.0f}s (POST /v1/contacts asked {asks}x) for {guest.name}'s " + f"owner\u2192node binding to be held on {host.name}: reachable_nodes={reachable} " + f"(FSD/TOPOLOGY.md \u00a72.5 `reachable`; a room opened before it keys with " + f"bodies `not_granted`, CIRISServer#699)") + else: + notes.append( + f"{guest.name} is still reachable_nodes=0 on {host.name} after {waited:.0f}s " + f"({asks} asks) \u2014 their owner\u2192node binding never crossed; the pair room " + f"opened next may key with bodies `not_granted` (CIRISServer#699)") + log(notes[-1]) + return held, 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]) @@ -563,12 +616,13 @@ def open_room(party: Party, with_key: str) -> str: 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: + reachable_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.""" + Both announced; peered both ways; each owner adds the other and WAITS for + the other to be reachable from it (TOPOLOGY §2.5 `reachable`, #699 — see + the module doc); 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) @@ -588,8 +642,10 @@ def seed(local: Party, remote: Party, *, message: str = DEFAULT_MESSAGE, 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) + v.peer_key_id, v.contact_via = add_contact(local, remote, owner_wait, v.notes, v.peer_contact_code, log, + reachable_wait=reachable_wait) + back_key, back_via = add_contact(remote, local, owner_wait, v.notes, "", log, + reachable_wait=reachable_wait) 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})") diff --git a/testing/test_two_node.py b/testing/test_two_node.py index 7b9f9029..4fc9f3d3 100644 --- a/testing/test_two_node.py +++ b/testing/test_two_node.py @@ -136,6 +136,9 @@ def fake_http(method, url, token=None, body=None, timeout=0): return 500, {} monkeypatch.setattr(tn, "http", fake_http) + # The node contact answers reachable_nodes=0 too; the bounded reachability + # wait below runs on a fake clock, so this stays a millisecond test. + monkeypatch.setattr(tn, "time", _Clock()) 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 = [] @@ -144,6 +147,61 @@ def fake_http(method, url, token=None, body=None, timeout=0): assert any("via owner refused" in n for n in notes) +class _Clock: + def __init__(self): + self.t = 0.0 + + def monotonic(self): + return self.t + + def sleep(self, s): + self.t += s + + +def _contacts_http(answers, posts): + def fake_http(method, url, token=None, body=None, timeout=0): + if url.endswith("/v1/federation/peers/peer-user"): + return 200, {"key_id": "peer-user"} + if url.endswith("/v1/contacts"): + posts.append(body) + n = answers.pop(0) if answers else 1 + return 200, {"key_id": body["key_id"], "reachable_nodes": n, "consent_prefixes": ["chat:"]} + return 500, {} + return fake_http + + +def test_add_contact_waits_for_the_binding_to_land_before_calling_it_reachable(monkeypatch): + """CIRISServer#699: a contact added while `reachable_nodes=0` keys a room + whose bodies read `not_granted` for good. The owner KEY crossing (what the + fixture waited for) is earlier than the owner->node BINDING (what + `reachable_nodes` counts); TOPOLOGY §2.5 defines `reachable(A, q)` by the + POST itself, so the fixture re-asks until it is >= 1, bounded, and says + how long it waited.""" + posts, notes, clock = [], [], _Clock() + monkeypatch.setattr(tn, "http", _contacts_http([0, 0, 1], posts)) + monkeypatch.setattr(tn, "time", clock) + host = tn.Party("local", "http://h", "t") + guest = tn.Party("peer", "http://g", "t", owner_key_id="peer-user", node_key_id="peer-node") + key, via = tn.add_contact(host, guest, wait=0, notes=notes, log=lambda m: None, + reachable_wait=60) + assert (key, via) == ("peer-user", "owner") + assert len(posts) == 3, "re-asked until the binding was held" + assert any("reachable_nodes" in n and "TOPOLOGY" in n and "waited" in n for n in notes), notes + + +def test_add_contact_says_when_the_binding_never_lands(monkeypatch): + posts, notes, clock = [], [], _Clock() + monkeypatch.setattr(tn, "http", _contacts_http([0] * 50, posts)) + monkeypatch.setattr(tn, "time", clock) + host = tn.Party("local", "http://h", "t") + guest = tn.Party("peer", "http://g", "t", owner_key_id="peer-user", node_key_id="peer-node") + key, via = tn.add_contact(host, guest, wait=0, notes=notes, log=lambda m: None, + reachable_wait=20) + assert (key, via) == ("peer-user", "owner"), "unreachable is not a refusal" + assert clock.t <= 30 and 2 <= len(posts) <= 8, "bounded" + assert any("reachable_nodes=0" in n and "#699" in n for n in notes), 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" From ecaeffc79796fa2096f846266a3f109af8c19933 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:31 -0500 Subject: [PATCH 06/27] fix(session): wait for a wizard step to advance, bounded, instead of sleeping 2 s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows, run 36588619656: `wizard did not advance past 'you'; on screen: ['btn_next', 'setup_step_indicators', 'step_indicator_join_federation', 'step_indicator_you']`. The app log shows Next clicked with canProceed=true, the fed-ID auto-minted on Next (`POST /v1/self/identity`), and the node still minting when the fixture judged — two seconds after the click, with the form already torn down for the next step. The wizard was working; the fixture's clock was fixed. `_advanced` now polls (screen, active step) for up to ADVANCE_TIMEOUT (90 s); a step that truly stalls is still reported by name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/session_fixture.py | 32 +++++++-- testing/test_session_fixture.py | 118 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 testing/test_session_fixture.py diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index 078428b1..d511f2d9 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -129,6 +129,26 @@ def _field_report(drv: TestAutomationServer) -> str: return "; ".join(sorted(parts)) +#: How long a wizard step may take to show the next one after Next. Windows +#: (run 36588619656) went blank for more than the 2 s the fixture used to +#: sleep — the fed-ID mint on Next and the next step's first composition on a +#: cold JVM — and the fixture called a working wizard stuck. A step that has +#: not moved in this long has stalled, and is reported by name. +ADVANCE_TIMEOUT = 90.0 + + +def _advanced(drv: TestAutomationServer, before: tuple, timeout: float, poll: float = 1.0) -> bool: + """True once the wizard is past `before` (screen, active step) or the claim + has taken over; False when it is still there after `timeout`.""" + deadline = time.monotonic() + timeout + while True: + if (drv.screen(), _active_step(drv)) != before or "setup_ownership_claimed" in _tags(drv): + return True + if time.monotonic() >= deadline: + return False + time.sleep(poll) + + def _settle(drv: TestAutomationServer, want: str, timeout: float = 90.0) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -241,20 +261,22 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, f"wizard step {before[1]!r}: {nxt} stayed disabled for 30s; " f"fields: {_field_report(drv)}" ) - time.sleep(2.0) - if (drv.screen(), _active_step(drv)) == before and "setup_ownership_claimed" not in _tags(drv): + # WAITED FOR, NOT SLEPT AT. The step advances when the app is ready, + # not two seconds after the click (see ADVANCE_TIMEOUT). + if not _advanced(drv, before, ADVANCE_TIMEOUT): # One retry when the step's question is still on screen: the answer # may not have landed before Next was clicked. if "trace_consent_yes" in _tags(drv): drv.click("trace_consent_yes") time.sleep(2.0) drv.click(nxt) - time.sleep(2.0) - if (drv.screen(), _active_step(drv)) == before and "setup_ownership_claimed" not in _tags(drv): + if _advanced(drv, before, 30.0): + continue # Say what was on screen: a required field the fixture doesn't fill # (the with-AI wizard asks for more than the node one) shows up here. raise SessionUnavailable( - f"wizard did not advance past {before[1]!r}; on screen: {sorted(_tags(drv))}" + f"wizard did not advance past {before[1]!r} within {ADVANCE_TIMEOUT:.0f}s; " + f"on screen: {sorted(_tags(drv))}" ) # The claim has no button; it completes and the app returns to Login. diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py new file mode 100644 index 00000000..b05b15ec --- /dev/null +++ b/testing/test_session_fixture.py @@ -0,0 +1,118 @@ +"""The session fixture waits for the wizard; it does not sleep through it. + +Windows, run 36588619656 (2026-09-29): Next on step `you` was clicked, the +form went blank while the next step composed (the on-screen list two seconds +later was `btn_next` and the step indicators, nothing else — the indicator +still said `you`), and the fixture, which slept a fixed 2 s and then judged, +raised "wizard did not advance past 'you'". The wizard was not stuck; the +fixture's clock was wrong. Driven here against a fake wizard on a fake clock, +so the red path (a slow step) and the honest path (a step that truly stalls) +both run in milliseconds. +""" + +from __future__ import annotations + +import pytest + +from testing.driver import DriverError, Element +from testing.gate import session_fixture as sf + + +class _Clock: + def __init__(self): + self.t = 0.0 + + def monotonic(self): + return self.t + + def sleep(self, s): + self.t += s + + +def _el(tag, text=None, can_click=True): + return Element(test_tag=tag, x=0, y=0, width=10, height=10, text=text, can_click=can_click) + + +class _SlowWizard: + """A desktop first run. Login -> (btn_local_login) -> Setup step `you`; + Next takes `advance_after` seconds to show `join_federation`, with a BLANK + body meanwhile — the shape the Windows leg showed. The consent step + answers, Next again claims, and the claim returns the app to Login.""" + + def __init__(self, clock: _Clock, advance_after: float): + self.clock, self.advance_after = clock, advance_after + self.on = "Login" + self.step = "you" + self.next_at: float | None = None + self.consented = False + self.claimed_at: float | None = None + self.inputs: dict[str, str] = {} + self.clicks: list[str] = [] + + def _tick(self): + if self.step == "you" and self.next_at is not None and self.clock.t - self.next_at >= self.advance_after: + self.step, self.next_at = "join_federation", None + + def screen(self): + if self.claimed_at is not None and self.clock.t - self.claimed_at >= 1.0: + return "Login" + return self.on + + def tree(self): + self._tick() + if self.screen() == "Login": + return [_el("btn_local_login")] + if self.step == "claimed": + return [_el("setup_ownership_claimed")] + indicators = [_el("setup_step_indicators"), + _el("step_indicator_you", "active" if self.step == "you" else ""), + _el("step_indicator_join_federation", "active" if self.step == "join_federation" else "")] + if self.step == "you" and self.next_at is not None: + return [_el("btn_next")] + indicators # blank body: the next step is composing + if self.step == "you": + return [_el(t) for t in ("input_username", "input_password", "input_password_confirm", + "input_device_name", "age_band_adult", "btn_next")] + indicators + return [_el("trace_consent_yes"), _el("btn_next")] + indicators + + def click(self, tag): + self.clicks.append(tag) + if tag not in {e.test_tag for e in self.tree()}: + raise DriverError(f"POST /click -> HTTP 404: No click handler for {tag!r}") + if tag == "btn_local_login": + self.on = "Setup" + elif tag == "trace_consent_yes": + self.consented = True + elif tag == "btn_next": + if self.step == "you": + self.next_at = self.clock.t + elif self.step == "join_federation" and self.consented: + self.step, self.claimed_at = "claimed", self.clock.t + + def input(self, tag, text): + self.inputs[tag] = text + + def scroll_to(self, tag, direction="down", amount=300): + return {"error": "NO overflow"} + + +def _drive(monkeypatch, advance_after: float): + clock = _Clock() + monkeypatch.setattr(sf, "time", clock) + w = _SlowWizard(clock, advance_after) + return clock, w + + +def test_a_slow_step_is_waited_for_not_slept_through(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=6.0) + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.step == "claimed" and w.screen() == "Login" + assert w.inputs["input_username"] == "qaadmin" + assert w.clicks.count("btn_next") == 2, "Next once per step, not hammered while the step composed" + + +def test_a_step_that_truly_stalls_is_still_reported_by_name(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=10 ** 6) + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "'you'" in str(e.value) + assert clock.t < 300, "bounded: a stalled wizard is reported in minutes, not hours" From 5d3d2d69ada3023e2affcc34d6bd5cb0919e6903 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:31 -0500 Subject: [PATCH 07/27] fix(flows): scroll into view on an off-screen refusal, and keep the driver's reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the hop verified, csd_047 reached LayerGlobalCommons for the first time and failed `click 'tile_federation_content' did not succeed`: the Content tile is the last row of the hub's grid, composed below the fold, and `/click` refuses a composed-but-off-screen element (CIRISClient#33). The helper answered a refusal with False and threw the reason away; the remedy the client ships for exactly that (`/scroll`) went unused. The session fixture's wizard had learned this rule (`_reach`); the flow helper had not. `SyncFlowHelper` now scrolls the target into view on an off-screen refusal — down to the bottom, then up, bounded — and keeps the driver's last reason, which the runner appends to "did not succeed". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/flow_helper.py | 80 ++++++++++++++++++++++++++-------- testing/gate/flow_spec.py | 13 +++++- testing/test_flow_helper.py | 86 +++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 testing/test_flow_helper.py diff --git a/testing/gate/flow_helper.py b/testing/gate/flow_helper.py index 95bc5c0f..2d0195bc 100644 --- a/testing/gate/flow_helper.py +++ b/testing/gate/flow_helper.py @@ -42,6 +42,12 @@ from testing.driver import DriverError, TestAutomationServer +#: How many 300px steps to try in each direction before saying a target stays +#: off screen. The wizard's `_reach` (session_fixture) uses the same budget. +_SCROLL_STEPS = 24 +#: The answers `/scroll` gives when there is nowhere further to go. +_SCROLL_END = ("already at the", "NO overflow", "can scroll") + @dataclass class _Element: @@ -65,6 +71,48 @@ class SyncFlowHelper: def __init__(self, drv: TestAutomationServer) -> None: self._drv = drv + #: Why the last click / input was refused, verbatim from the driver. + #: The runner used to report "did not succeed" and nothing else; on + #: csd_047 the reason was "composed but off screen", which names both + #: the cause and the remedy. + self.last_error = "" + + # ---- reaching --------------------------------------------------------- + + def _step(self, tag: str, direction: str) -> Optional[str]: + """One `/scroll`; the app's reason when it did not move, else None.""" + try: + r = self._drv.scroll_to(tag, direction=direction, amount=300) + except AttributeError: + return "this driver has no /scroll" + except DriverError as e: + return str(e)[-160:] + return (r or {}).get("error") if isinstance(r, dict) else None + + def _reach(self, tag: str, act) -> bool: + """Run `act()`, scrolling `tag` into view when the app refuses it as + composed but off screen (CIRISClient#33): down until the bottom, then + up until the top, bounded. Any other refusal is final and kept.""" + notes: list = [] + for direction in ("down", "up"): + for _ in range(_SCROLL_STEPS): + try: + act() + return True + except DriverError as e: + if "off screen" not in str(e): + self.last_error = str(e) + return False + msg = self._step(tag, direction) + notes.append(f"{direction}: {msg or 'moved'}") + if msg and any(word in msg for word in _SCROLL_END): + break + try: + act() + return True + except DriverError as e: + self.last_error = f"{e} | scrolls: {'; '.join(dict.fromkeys(notes))}" + return False # ---- reads -------------------------------------------------------------- @@ -115,27 +163,25 @@ async def is_element_visible(self, tag: str) -> bool: # ---- actions ------------------------------------------------------------ async def click(self, tag: str, timeout: int = 2000) -> bool: - try: - self._drv.click(tag) - return True - except DriverError: - return False + return self._reach(tag, lambda: self._drv.click(tag)) async def input_text(self, tag: str, text: str) -> bool: - try: - self._drv.input(tag, text) - return True - except DriverError: - return False + return self._reach(tag, lambda: self._drv.input(tag, text)) async def scroll_into_view(self, tag: str) -> bool: - try: - self._drv.scroll_to(tag) - return True - except (DriverError, AttributeError): - # A client without /scroll is not a failed scroll: the runner will - # re-ask `is_element_visible` and report honestly either way. - return False + """Bring `tag` on screen: step down until it has size, then up, bounded. + An element below the fold is composed with a clipped, zero-size + `boundsInWindow`, so "visible" here is what the scroll changes. A client + without /scroll is not a failed scroll: the runner re-asks + `is_element_visible` and reports honestly either way.""" + for direction in ("down", "up"): + for _ in range(_SCROLL_STEPS): + if await self.is_element_visible(tag): + return True + msg = self._step(tag, direction) + if msg and ("no /scroll" in msg or any(word in msg for word in _SCROLL_END)): + break + return await self.is_element_visible(tag) async def wait_for_element(self, tag: str, timeout: int = 2000) -> bool: try: diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index 35372482..d6eb99d8 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -752,14 +752,23 @@ async def _relation(self, rel: Dict[str, Any], label: str) -> Optional[str]: "lte": left <= right, "gt": left > right, "gte": left >= right}[op] return None if ok else f"{label}: {left} {op} {right} is false" + def _why(self) -> str: + """LOCAL DELTA: the driver's own reason for a refusal, when the helper + kept one (`last_error`) — "did not succeed" alone sent csd_047's reader + to the wrong place.""" + why = getattr(self.helper, "last_error", "") + return f" ({why})" if why else "" + async def _do(self, action: Action) -> Optional[str]: + if hasattr(self.helper, "last_error"): + self.helper.last_error = "" try: if action.kind == "click": ok = await self.helper.click(action.target, timeout=action.wait_ms * 4) - return None if ok else f"click {action.target!r} did not succeed" + return None if ok else f"click {action.target!r} did not succeed{self._why()}" if action.kind == "input": ok = await self.helper.input_text(action.target, action.value or "") - return None if ok else f"input into {action.target!r} did not succeed" + return None if ok else f"input into {action.target!r} did not succeed{self._why()}" if action.kind == "scroll_to": ok = await self.helper.scroll_into_view(action.target) return None if ok else f"could not bring {action.target!r} on screen" diff --git a/testing/test_flow_helper.py b/testing/test_flow_helper.py new file mode 100644 index 00000000..2d3c8ae6 --- /dev/null +++ b/testing/test_flow_helper.py @@ -0,0 +1,86 @@ +"""`SyncFlowHelper` over a fake driver: a refusal's reason is KEPT, and an +off-screen refusal is answered by scrolling (CIRISClient#33), not by giving up. + +Local Linux leg, 2026-09-29: the transport hub's Content tile is composed below +the fold, `/click` refused it as "composed but off screen", and the runner +reported `click 'tile_federation_content' did not succeed` — the reason gone, +and the remedy the client ships for exactly that (`/scroll`) unused. The +session fixture had learned this rule for the wizard (`_reach`); the flow +helper had not. +""" + +from __future__ import annotations + +import asyncio + +from testing.driver import DriverError +from testing.gate.flow_helper import SyncFlowHelper + + +class _Drv: + """A driver whose target sits `off_screen_until` scrolls below the fold.""" + + def __init__(self, off_screen_until: int = 0, refuse: str = "", bottom_after: int = 99): + self.scrolls: list = [] + self.clicks: list = [] + self.inputs: list = [] + self.off_screen_until, self.refuse, self.bottom_after = off_screen_until, refuse, bottom_after + + def _gate(self, verb, tag): + if self.refuse: + raise DriverError(self.refuse) + if len(self.scrolls) < self.off_screen_until: + raise DriverError(f"POST /{verb} -> HTTP 422: {tag} is composed but off screen " + f"(inside a closed drawer or sheet?)") + + def click(self, tag): + self.clicks.append(tag) + self._gate("click", tag) + + def input(self, tag, text): + self.inputs.append((tag, text)) + self._gate("input", tag) + + def scroll_to(self, tag, direction="down", amount=300): + self.scrolls.append((tag, direction)) + if len(self.scrolls) > self.bottom_after: + return {"success": False, "error": "already at the bottom (900 of 900)"} + return {"success": True, "text": f"{direction}:{amount} moved 0→300 of 900"} + + def tree(self): + return [] + + def screen(self): + return "LayerGlobalCommons" + + def state(self): + return {} + + +def test_an_off_screen_click_is_scrolled_into_view_and_retried(): + d = _Drv(off_screen_until=2) + assert asyncio.run(SyncFlowHelper(d).click("tile_federation_content")) is True + assert len(d.scrolls) == 2, "scrolled exactly until the click landed" + assert d.clicks.count("tile_federation_content") == 3 + + +def test_an_off_screen_input_is_scrolled_into_view_and_retried(): + d = _Drv(off_screen_until=1) + assert asyncio.run(SyncFlowHelper(d).input_text("input_provision_holder_pin", "000000")) is True + assert d.scrolls and d.inputs[-1] == ("input_provision_holder_pin", "000000") + + +def test_a_refusals_reason_is_kept_for_the_verdict(): + d = _Drv(refuse="POST /click -> HTTP 404: No click handler for 'btn_x'") + h = SyncFlowHelper(d) + assert asyncio.run(h.click("btn_x")) is False + assert "No click handler" in h.last_error + assert not d.scrolls, "a refusal that is not about the fold is not answered by scrolling" + + +def test_scrolling_is_bounded_and_the_bottom_is_reported(): + d = _Drv(off_screen_until=10 ** 6, bottom_after=3) + h = SyncFlowHelper(d) + assert asyncio.run(h.click("tile_far_away")) is False + assert len(d.scrolls) < 40, "bounded" + assert "off screen" in h.last_error and "already at the bottom" in h.last_error From 7d52bc2ae9bf2b7df5b058e2c4369733e87f0fa1 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:31 -0500 Subject: [PATCH 08/27] fix(flows): an expect after an action settles on the frame, bounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/click` returns before the frame that applies it, so an `expect` read in the same instant sees the screen the click is leaving. On the local Linux leg (2026-09-29, run 2) three flows failed exactly there — csd_057's back (`card_wallet_balance` still on screen), csd_092's close (`contact_code_error` still on screen), csd_006's sheet close (`sheet_receipt` still on screen) — after their clicks had succeeded. The runner now re-reads the expect every quarter second until it holds or EXPECT_SETTLE_S (2.5 s) runs out; a condition that never holds still fails, in that long. The same rule `navigate` already states for hops: an assertion made in the instant of a click is a race, not a test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/flow_spec.py | 23 ++++- testing/test_flows.py | 181 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index d6eb99d8..44e55d4f 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -74,6 +74,14 @@ _RELATION_OPS = {"eq", "ne", "lt", "lte", "gt", "gte", "min_of", "max_of", "sum_of"} + +#: LOCAL DELTA: how long an `expect` may take to hold after the step's actions. +#: `/click` returns before the frame that applies it, so an assertion read in +#: the same instant sees the screen the click is leaving: csd_057's back +#: "succeeded" and `absent: [card_wallet_balance]` failed on the local Linux +#: leg (2026-09-29). The expect is re-read every quarter second until it holds +#: or this runs out; a condition that never holds still fails, in this long. +EXPECT_SETTLE_S = 2.5 _STATES = {"populated", "empty", "loading", "error"} @@ -705,6 +713,19 @@ async def _check(self, cond: Condition, label: str) -> Optional[str]: return err return None + async def _settled(self, cond: Condition, label: str, budget: float = EXPECT_SETTLE_S) -> Optional[str]: + """LOCAL DELTA: `_check`, re-read until it holds or `budget` runs out — + an assertion made in the instant of a click is a race, not a test (the + rule `run_flows.navigate` already states for hops).""" + import asyncio # noqa: PLC0415 + + deadline = time.monotonic() + budget + while True: + err = await self._check(cond, label) + if err is None or time.monotonic() >= deadline: + return err + await asyncio.sleep(0.25) + async def _number(self, tag: str) -> Optional[float]: """The element's text as a number, or None if it is not one.""" elem = await self.helper.get_element(tag) @@ -889,7 +910,7 @@ async def _steps(self, spec: FlowSpec) -> bool: return False print(f" did: {action.describe()}") - post = await self._check(step.expect, "expect") + post = await self._settled(step.expect, "expect") drivable = await self._drivable() shot = self._shot(spec, step) if post: diff --git a/testing/test_flows.py b/testing/test_flows.py index 8ad5c04a..4a255c21 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -379,6 +379,111 @@ def test_a_flow_whose_expects_hold_passes(tmp_path): assert run_flows.leg_ok([out]) +CLEANUP = GOOD + """\ + cleanup: + - click: btn_close +""" + + +def test_a_flows_cleanup_runs_even_after_a_failed_step(tmp_path): + """csd_092 opened the contact-code card, failed on its second step, and + left the card open; `people`, `csd_005` and `csd_006` then failed for its + reason on every desktop leg (2026-09-29). Only the flow knows what it + opened; the runner guarantees the closing runs.""" + h = FakeHelper("Thing", {"btn_close": ""}) # thing_list absent: the step fails + out = _run(_spec(tmp_path, CLEANUP), h) + assert out.status == run_flows.FAIL + assert "click btn_close" in h.calls + + +def test_a_flows_cleanup_runs_after_a_pass_too(tmp_path): + h = FakeHelper("Thing", {"thing_list": "", "btn_close": ""}) + out = _run(_spec(tmp_path, CLEANUP), h) + assert out.status == run_flows.PASS + assert h.calls[-1] == "click btn_close" + + +def test_a_cleanup_that_fails_is_said_and_is_not_the_verdict(tmp_path): + h = FakeHelper("Thing", {"thing_list": "", "btn_close": ""}) + h.refuse = {"btn_close"} # on screen, and the app refuses the click + out = _run(_spec(tmp_path, CLEANUP), h) + assert out.status == run_flows.PASS + assert "cleanup" in out.detail and "btn_close" in out.detail and "No click handler" in out.detail + + +def test_a_cleanup_whose_target_is_already_gone_is_nothing_to_close(tmp_path): + """csd_092's own last step closes the card it opened; its cleanup then + finds nothing to close, and that is not a failure to report.""" + h = FakeHelper("Thing", {"thing_list": ""}) # btn_close absent + out = _run(_spec(tmp_path, CLEANUP), h) + assert out.status == run_flows.PASS + assert "cleanup" not in out.detail, out.detail + + +class _NextFrame(FakeHelper): + """A click whose effect lands on the next frame — 0.3 s later on the wall + clock, as a Compose recomposition does after `/click` returns. Reading the + tree in the same instant sees the old screen.""" + + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.lands_at = None + + async def click(self, tag, timeout=2000): + import time as _t + if tag in self.leads: + self.calls.append(f"click {tag}") + self.lands_at = (_t.monotonic() + 0.3, self.leads[tag]) + return True + return await super().click(tag, timeout) + + def _land(self): + import time as _t + if self.lands_at and _t.monotonic() >= self.lands_at[0]: + self.screen, shown = self.lands_at[1] + self.els = {t: _El(t, "") for t in shown} + self.lands_at = None + + async def get_elements(self): + self._land() + return await super().get_elements() + + async def get_screen(self): + self._land() + return await super().get_screen() + + +def test_an_expect_after_an_action_waits_for_the_frame(tmp_path): + """csd_057's back click 'succeeded' and the same-instant expect still saw + `card_wallet_balance` (local Linux leg, 2026-09-29): the click returns + before the frame that applies it. An assertion made in the instant of the + click is a race, not a test — the same rule `navigate` already states.""" + body = GOOD.replace(" expect:\n visible: [thing_list]\n", + " do:\n - click: btn_back\n expect:\n" + " absent: [thing_list]\n screen: Elsewhere\n") + h = _NextFrame("Thing", {"thing_list": "", "btn_back": ""}) + h.leads = {"btn_back": ("Elsewhere", ["other"])} + out = _run(_spec(tmp_path, body), h) + assert out.status == run_flows.PASS, out.detail + + +def test_an_expect_that_never_holds_still_fails_and_is_bounded(tmp_path): + import time as _t + body = GOOD.replace("visible: [thing_list]", "visible: [thing_list, never_there]") + started = _t.monotonic() + out = _run(_spec(tmp_path, body), FakeHelper("Thing", {"thing_list": ""})) + assert out.status == run_flows.FAIL and "never_there" in out.detail + assert _t.monotonic() - started < 10 + + +def test_a_failed_action_carries_the_drivers_reason(tmp_path): + """"did not succeed" was the whole verdict on csd_047; the driver knew why.""" + body = GOOD.replace(" expect:\n", " do:\n - click: btn_gone\n expect:\n") + out = _run(_spec(tmp_path, body), FakeHelper("Thing", {"thing_list": ""})) + assert out.status == run_flows.FAIL + assert "no such element 'btn_gone'" in out.detail, out.detail + + def test_a_failing_expect_fails_the_flow_and_the_leg(tmp_path): out = _run(_spec(tmp_path), FakeHelper("Thing", {"something_else": ""})) assert out.status == run_flows.FAIL @@ -535,9 +640,85 @@ def test_a_missing_hop_tag_is_cannot_start_and_names_the_tag(tmp_path): assert out.status == run_flows.CANNOT_START assert "'tab_y'" in out.detail and "hop 2 of 3" in out.detail assert "never appeared" in out.detail, "waited for, not blindly clicked" + # THE EVIDENCE TRAVELS WITH THE VERDICT. Four cannot-starts on the + # 2026-09-29 run said "never appeared ... on 'CircleTab'" and nothing + # else; which rows WERE listed was the fact that named the cause. + assert "on screen and drivable now" in out.detail and "circle_x" in out.detail assert not run_flows.leg_ok([out]) +# ── the circle hop must LAND before the tab is clicked ────────────────────── + +class _RacyShell(FakeHelper): + """`CIRISApp.openTab` as it behaves: a circle click changes the circle on + the NEXT FRAME (modelled as the next `/state` read), and a tab click opens + the tab of whichever circle the last frame saw. Sign-in lands a node + client in Neighbours (`defaultCircle`), whose Chats tab is Rooms; the + flow wants Just me › Chats › Notes.""" + + def __init__(self): + super().__init__("Contacts", {"circle_agent": "", "tab_chats": ""}) + self.circle, self.tab, self.pending = "local-community", "people", None + + async def get_state(self): + self.calls.append("state") + if self.pending: + self.circle, self.pending = self.pending, None + return {"screen": self.screen, "circle": self.circle, "tab": self.tab} + + async def click(self, tag, timeout=2000): + self.calls.append(f"click {tag}") + if tag.startswith("circle_"): + self.pending = tag[len("circle_"):].replace("_", "-") + return True + if tag == "tab_chats": + self.tab = "chats" + self.screen = "Notes" if self.circle == "agent" else "CommunityChats" + body = "input_note" if self.screen == "Notes" else "rooms_list" + self.els = {t: _El(t, "") for t in ("circle_agent", "tab_chats", body)} + return True + return tag in self.els + + +def test_navigate_waits_for_the_circle_hop_to_land_before_the_tab(): + """macOS, 2026-09-29: `circle_agent -> tab_chats` landed on CommunityChats — + the tab was clicked with the circle the previous frame had.""" + h = _RacyShell() + got = asyncio.run(run_flows.navigate(h, "Notes", ["circle_agent", "tab_chats"], + hop_timeout=1.0, arrive_timeout=0.1)) + assert got is None, got + assert h.screen == "Notes" + assert h.calls.index("state") < h.calls.index("click tab_chats"), \ + "the circle was read back before the tab was clicked" + + +def test_a_circle_hop_that_never_lands_is_named_as_the_circle_not_the_row(): + h = _RacyShell() + + async def stuck(): + h.calls.append("state") + return {"screen": h.screen, "circle": "local-community", "tab": h.tab} + h.get_state = stuck + got = asyncio.run(run_flows.navigate(h, "Notes", ["circle_agent", "tab_chats"], + hop_timeout=0.3, arrive_timeout=0.1)) + assert got and "circle_agent" in got and "local-community" in got, got + assert "click tab_chats" not in h.calls, "no tab is clicked in the wrong circle" + + +def test_a_client_whose_state_has_no_circle_is_walked_without_verification(): + """An older client serves no `circle` in /state: the runner cannot verify + the hop, says so, and still walks it rather than refusing every flow.""" + h = _walkable() + + async def old_state(): + return {"screen": h.screen} + h.get_state = old_state + got = asyncio.run(run_flows.navigate(h, "Thing", ["circle_x", "tab_y", "nav_thing"], + hop_timeout=0.2, arrive_timeout=0.1)) + assert got is None, got + assert h.screen == "Thing" + + def test_a_screen_with_no_hop_that_is_not_flow_only_cannot_start(tmp_path): h = _walkable() out = _nav_run(_spec(tmp_path), h, hops={}) From 42ae09f9e917a5423849e2d834ecd469d577cedd Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:31 -0500 Subject: [PATCH 09/27] fix(client): input sinks for the three Provision an accord holder fields csd_068 reached its screen for the first time on the local Linux leg and failed `input into 'input_provision_holder_usb_path' did not succeed`: the key-id, USB-path and PIN fields carried `input_*` tags and nothing subscribed to them, so `/input` had nothing to apply to (CIRISClient#30). They were three of check_ui_drivable.py's baseline debt. Declared beside the dispatch, as SetupScreen does; the baseline drops from 142 to 139 and a source test pins the three. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../ui/screens/ProvisionAccordHolderScreen.kt | 43 +++++++++++++--- .../screens/ProvisionAccordHolderSinksTest.kt | 50 +++++++++++++++++++ client/tools/ui_drivable_baseline.json | 5 -- 4 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderSinksTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index 6b549328..7db7e51e 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `a1f488c422e756a6fa6f354061079495b65c872e3cfceced2f402b49ea86a5b5` +**state digest:** `036fc60435ab9a3498cb611532e07cdff3ec5da1249442fab7214ea8eb186a85` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt index 539e6cc7..c8b822e2 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt @@ -2,6 +2,8 @@ package ai.ciris.mobile.shared.ui.screens import ai.ciris.mobile.shared.localization.localizedString import ai.ciris.mobile.shared.platform.DirectoryPickerDialog +import ai.ciris.mobile.shared.platform.TestAutomation +import ai.ciris.mobile.shared.platform.rememberInputSinks import ai.ciris.mobile.shared.platform.testable import ai.ciris.mobile.shared.platform.testableClickable import ai.ciris.mobile.shared.ui.components.CIRISIcons @@ -91,6 +93,27 @@ fun ProvisionAccordHolderScreen( var copied by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.refreshYubiKeyStatus() } + // TEXT ENTRY FOR TEST AUTOMATION (CIRISClient#30). The three fields carried + // `input_*` tags and nothing subscribed to them, so `/input` had nothing to + // apply to: CSD-068's flow reached this screen for the first time on + // 2026-09-29 and failed its third step on a form a person can type into. + // Declared beside the dispatch, as SetupScreen does, so the two cannot + // drift apart (check_ui_drivable.py fails a dispatched tag with no sink). + rememberInputSinks("input_provision_holder_key_id", "input_provision_holder_usb_path", "input_provision_holder_pin") + val textInputRequest by TestAutomation.textInputRequests.collectAsState() + LaunchedEffect(textInputRequest) { + textInputRequest?.let { request -> + val (current, apply) = when (request.testTag) { + "input_provision_holder_key_id" -> keyId to viewModel::setKeyId + "input_provision_holder_usb_path" -> usbPath to viewModel::setUsbPath + "input_provision_holder_pin" -> userPin to viewModel::setUserPin + else -> return@let + } + apply(if (request.clearFirst) request.text else current + request.text) + TestAutomation.clearTextInputRequest() + } + } + Scaffold( topBar = { ScreenTopBar( @@ -114,13 +137,19 @@ fun ProvisionAccordHolderScreen( .testableVerticalScroll(), ) { Spacer(Modifier.height(8.dp)) - // The empty state: the three steps, none done yet. - Text( - text = localizedString("mobile.provision_holder_subtitle"), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.testable("txt_provision_holder_start"), - ) + // The empty state: the three steps, none done yet — and ONLY then. + // CSD-068 declares this tag as the empty state and + // `provision_holder_error` as the error; drawn in every state, a + // refused submit showed both at once, and error and empty must + // never look alike (CSD/3 §2.2; the local Linux leg, 2026-09-29). + if (!busy && error == null && provisionedKeyId == null) { + Text( + text = localizedString("mobile.provision_holder_subtitle"), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testable("txt_provision_holder_start"), + ) + } // ── Success state ──────────────────────────────────────────────── val doneKeyId = provisionedKeyId diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderSinksTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderSinksTest.kt new file mode 100644 index 00000000..5e424721 --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderSinksTest.kt @@ -0,0 +1,50 @@ +package ai.ciris.mobile.shared.ui.screens + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * EVERY TEXT FIELD HAS AN INPUT SINK. + * + * The three fields on Provision an accord holder carried `input_*` tags and + * nothing subscribed to them, so `/input` had nothing to apply to. CSD-068's + * flow reached the screen for the first time on 2026-09-29 (once the runner's + * circle hop was verified) and failed its third step — "input into + * 'input_provision_holder_usb_path' did not succeed" — on a form a person can + * type into. `check_ui_drivable.py` carried the three as baseline debt; this + * pins that the debt stays paid. No Compose UI harness in this module, so it + * reads the source, the trade `QrNoStandInTest` makes. + */ +class ProvisionAccordHolderSinksTest { + + private fun commonMain(): File = + listOf("src/commonMain/kotlin", "shared/src/commonMain/kotlin", "client/shared/src/commonMain/kotlin") + .map { File(it) }.firstOrNull { it.isDirectory } + ?: error("commonMain not found from ${File(".").absolutePath}") + + @Test + fun theThreeFieldsDeclareSinksAndDispatchThem() { + val src = File(commonMain(), "ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt").readText() + val declared = Regex("""rememberInputSinks\(([^)]*)\)""").find(src)?.groupValues?.get(1) ?: "" + for (tag in listOf("input_provision_holder_key_id", "input_provision_holder_usb_path", "input_provision_holder_pin")) { + assertTrue("\"$tag\"" in declared, "$tag has no declared input sink (rememberInputSinks)") + assertTrue(Regex("\"$tag\"\\s*->").containsMatchIn(src), "$tag is declared but never dispatched") + } + } + + /** + * THE EMPTY STATE'S TAG LEAVES WITH THE EMPTY STATE. CSD-068 declares + * `txt_provision_holder_start` as the empty state ("the three steps, none + * of them done yet") and `provision_holder_error` as the error; the screen + * drew the intro in every state, so after a refused submit both tags were + * on screen and `state: error` failed on the local Linux leg (2026-09-29). + * Error and empty never look alike (CSD/3 §2.2). + */ + @Test + fun theIntroIsDrawnOnlyWhileNothingHasHappened() { + val src = File(commonMain(), "ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt").readText() + val guarded = Regex("""if \(!busy && error == null && provisionedKeyId == null\)[\s\S]{0,400}testable\("txt_provision_holder_start"\)""") + assertTrue(guarded.containsMatchIn(src), "txt_provision_holder_start is not guarded on the empty state") + } +} diff --git a/client/tools/ui_drivable_baseline.json b/client/tools/ui_drivable_baseline.json index 1b31b239..6a692467 100644 --- a/client/tools/ui_drivable_baseline.json +++ b/client/tools/ui_drivable_baseline.json @@ -153,11 +153,6 @@ "input_moderation_note", "input_moderation_targets" ], - "shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt": [ - "input_provision_holder_key_id", - "input_provision_holder_pin", - "input_provision_holder_usb_path" - ], "shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SelfReaderOpsSection.kt": [ "chip_reader_standing" ], From c50c8c29ceea6571cd426a1e9fe5051f61a9f387 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:31 -0500 Subject: [PATCH 10/27] fix(flows): under the shell, back is the shell's btn_nav_back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit csd_057's last step clicked `btn_wallet_back` and csd_068's `btn_provision_holder_back`; neither was on screen on the local Linux leg. WalletPage draws its own arrow only when it runs outside the shell in a wide window, and the card sits in a seven-card tab, so the shell draws the back a person sees (`btn_nav_back`, CirclesShell). Both flows press that; CSD-057 §4 step 6 says so. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- FSD/CSD/CSD-057-wallet.md | 5 ++++- testing/flows/csd-057-wallet.yaml | 8 +++++++- testing/flows/csd-068-provision-accord-holder.yaml | 5 ++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/FSD/CSD/CSD-057-wallet.md b/FSD/CSD/CSD-057-wallet.md index 0cb994dd..536701a2 100644 --- a/FSD/CSD/CSD-057-wallet.md +++ b/FSD/CSD/CSD-057-wallet.md @@ -145,7 +145,10 @@ read-only, and it stops before the send. In order: and `btn_send_transfer`. 5. **The form takes input** (optional on the same) — a zero address, `0` and a memo are typed; `btn_send_transfer` is never pressed. -6. **Back** — `btn_wallet_back` leaves the card. +6. **Back** — the shell's `btn_nav_back` leaves the card (the card sits in a + seven-card tab, so the shell draws the arrow). `btn_wallet_back` is the + page's own arrow, drawn only when the page runs outside the shell in a + wide window; it is not what a person under the shell presses. **The confirm itself must not be flowed against a live rail.** A flow that moves USDC to pass is not a test. Opening `sheet_wallet_send` and cancelling diff --git a/testing/flows/csd-057-wallet.yaml b/testing/flows/csd-057-wallet.yaml index 3b97a61e..ebb0cf6e 100644 --- a/testing/flows/csd-057-wallet.yaml +++ b/testing/flows/csd-057-wallet.yaml @@ -95,7 +95,13 @@ steps: - step_id: back_leaves_the_card title: Back returns to Communities and Businesses > Rules + description: >- + The shell's back (`btn_nav_back`, CirclesShell) — the card opened from a + seven-card tab, and that is the arrow a person sees. `btn_wallet_back` is + the page's own arrow, drawn only when the page runs outside the shell + in a wide window; on the Linux desktop leg (2026-09-29) it was not on + screen and this step failed on it. do: - - click: btn_wallet_back + - click: btn_nav_back expect: absent: [card_wallet_balance] diff --git a/testing/flows/csd-068-provision-accord-holder.yaml b/testing/flows/csd-068-provision-accord-holder.yaml index 40d79a15..c306caf1 100644 --- a/testing/flows/csd-068-provision-accord-holder.yaml +++ b/testing/flows/csd-068-provision-accord-holder.yaml @@ -75,7 +75,10 @@ steps: - step_id: back_leaves_the_screen title: Back returns to Everyone > Safety + description: >- + The shell's back (`btn_nav_back`); the page's own `btn_provision_holder_back` + is drawn only outside the shell (csd-057-wallet.yaml says the same). do: - - click: btn_provision_holder_back + - click: btn_nav_back expect: absent: [btn_provision_holder_submit] From 4ced0fb349f99c89bd75e6f4f9d21d2dbfa8ae72 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:32 -0500 Subject: [PATCH 11/27] fix(ci): call build_qa_gallery.py as it is written The gallery job of run 36588619656 died on `unrecognized arguments: --shots` and warned "gallery build failed; the raw artifacts are still attached", so no gallery has ever built. The script takes the artifacts directory positionally and the step passed it as a flag. The parser is now a function the workflow test parses the step's argv with, so the two cannot drift again; the step also appends the table to the job summary. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- .github/workflows/five-platform-live-qa.yml | 2 +- testing/gate/build_qa_gallery.py | 10 ++++++++-- testing/test_five_platform_workflow.py | 13 +++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/five-platform-live-qa.yml b/.github/workflows/five-platform-live-qa.yml index b19c0c0d..dd5ce74e 100644 --- a/.github/workflows/five-platform-live-qa.yml +++ b/.github/workflows/five-platform-live-qa.yml @@ -871,7 +871,7 @@ jobs: path: collected - name: Build the gallery run: | - python3 testing/gate/build_qa_gallery.py --shots collected --out gallery.html || \ + python3 testing/gate/build_qa_gallery.py collected --out gallery.html --summary "$GITHUB_STEP_SUMMARY" || \ echo "::warning::gallery build failed; the raw artifacts are still attached" - uses: actions/upload-artifact@v4 with: diff --git a/testing/gate/build_qa_gallery.py b/testing/gate/build_qa_gallery.py index bd4df5d3..901be013 100644 --- a/testing/gate/build_qa_gallery.py +++ b/testing/gate/build_qa_gallery.py @@ -216,12 +216,18 @@ def _summary(tiles: List[Tile]) -> str: return "\n".join(lines) + "\n" -def main() -> int: +def parser() -> argparse.ArgumentParser: + """The CLI, as a function so the workflow test can parse the step's argv: + run 36588619656's gallery job died on `unrecognized arguments: --shots`.""" ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("root", type=Path, help="Directory the artifacts were downloaded into") ap.add_argument("--out", type=Path, required=True, help="Where to write index.html") ap.add_argument("--summary", type=Path, default=None, help="Append a table here (GITHUB_STEP_SUMMARY)") - args = ap.parse_args() + return ap + + +def main() -> int: + args = parser().parse_args() if not args.root.exists(): print(f"gallery: {args.root} does not exist", file=sys.stderr) diff --git a/testing/test_five_platform_workflow.py b/testing/test_five_platform_workflow.py index 0b10f3bf..8ec2a30e 100644 --- a/testing/test_five_platform_workflow.py +++ b/testing/test_five_platform_workflow.py @@ -210,6 +210,19 @@ def test_the_gallery_is_built_for_red_runs_too(wf): assert leg in gallery["needs"], f"the gallery ignores {leg}" +def test_the_gallery_step_calls_the_script_as_written(wf): + """Run 36588619656: `build_qa_gallery.py: error: unrecognized arguments: + --shots` — the script takes the artifacts directory positionally, and the + step passed it as a flag, so the gallery never built on any run.""" + body = "\n".join(str(s.get("run", "")) for s in wf["jobs"]["gallery"]["steps"]) + call = re.search(r"build_qa_gallery\.py\s+([^\n|]*)", body) + assert call, "no build_qa_gallery.py call in the gallery job" + argv = call.group(1).replace("\\", " ").split() + from testing.gate import build_qa_gallery + ns = build_qa_gallery.parser().parse_args(argv) + assert str(ns.root) == "collected" and str(ns.out) == "gallery.html" + + def test_the_gate_is_not_wired_to_every_push(wf): # It boots an emulator and a simulator. On every push it would be switched # off within a week, and a gate that is switched off protects nothing. From 5477a0bade3924b7991e19c03af7a3f3d15364ae Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 11:46:32 -0500 Subject: [PATCH 12/27] docs(csd): what each 0.5.225 flow does on the local Linux desktop leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5 lines of the eight CSDs and the flow README rows say what passed, skipped and failed on the Linux desktop leg run locally the way five-platform-live-qa.yml runs it (scratch ports and homes, node v0.5.217, `--flows testing/flows`, the two-node fixture). None has run on the other four legs since the fixes; the stages stay `building`. Co-Authored-By: Claude Fable 5.1 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-008-notes-to-self.md | 2 +- FSD/CSD/CSD-047-network-content.md | 2 +- FSD/CSD/CSD-057-wallet.md | 2 +- FSD/CSD/CSD-068-provision-accord-holder.md | 2 +- FSD/CSD/CSD-092-share-contact-code.md | 2 +- FSD/CSD/CSD-101-household-members.md | 2 +- testing/flows/README.md | 35 ++++++++++++---------- testing/flows/people.yaml | 5 ++-- 10 files changed, 31 insertions(+), 25 deletions(-) diff --git a/FSD/CSD/CSD-005-people.md b/FSD/CSD/CSD-005-people.md index 45403080..f5d4d67c 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/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. The two-node fixture seeds the contact; on the Linux desktop leg (2026-09-28) the row, its trust chip, its hamburger and the five-fact receipt passed, and the step that then failed (`btn_scan_contact_code` is a button only where there is a camera) is now gated on the button. +Spec complete and flow written (`testing/flows/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **11/12 passed, 1 skipped** — the list, the seeded row with its trust chip and hamburger, the five-fact receipt and its close, the empty search and its clearing, the add card, the node-code refusal by name, and the code card from the header; `a_scan_is_offered_where_there_is_a_camera` skipped as designed (desktop has no `btn_scan_contact_code`). The matrix run of the same day (36588619656) failed this flow on every desktop leg for csd-092's open contact-code card, which now closes itself (`cleanup:`), and its fixture waited only for the peer's owner key, not the binding (`reachable_nodes`, CIRISServer#699) — both fixed. Not yet run on the other four legs. **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 abc7e688..95673505 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 -Spec complete and flow written (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. 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:`). Linux desktop, 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. Not yet run on the other four legs. +Spec complete and flow written (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. 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:`). Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/6 passed, 1 skipped** — the seeded row's hamburger, all five envelope rows, the wire dimension, the rule row off the wire (`chat:` among the grant's prefixes) and the close; the grant-less step skipped as designed (the node sends the grant). On the matrix run of the same day (36588619656) every desktop leg failed this flow for csd-092's open contact-code card, since fixed (`cleanup:`). Not yet run on the other four legs. 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-008-notes-to-self.md b/FSD/CSD/CSD-008-notes-to-self.md index fc3d1024..14730574 100644 --- a/FSD/CSD/CSD-008-notes-to-self.md +++ b/FSD/CSD/CSD-008-notes-to-self.md @@ -87,7 +87,7 @@ The new note is **not** listed under Files (CSD-007: `DriveEntry.isNote`). ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-008-notes-to-self.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. +Spec complete and flow written (`testing/flows/csd-008-notes-to-self.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/2 passed**. On the matrix run of the same day (36588619656) it passed on Linux and could not start on macOS — `circle_agent -> tab_chats` landed on Rooms because the tab was clicked before the circle hop had landed (a node client signs in under Neighbours); the runner now verifies each hop against `/state`. Not yet run on the other four legs since. **Verified live** (desktop, scratch ciris-server 0.5.215, 2026-09-24): writing a note from the UI and reading it back from `/v1/notes`; a readable note diff --git a/FSD/CSD/CSD-047-network-content.md b/FSD/CSD/CSD-047-network-content.md index c15e7a53..a0abe535 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 -Spec complete and flow written (`testing/flows/csd-047-network-content.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. It enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. Its hop to LayerGlobalCommons stopped on CircleTab when last walked (2026-09-28, before `navigate` learned to open a one-card tab); if it still does, the leg reports `cannot-start` naming the hop. A real fetch still needs a digest the peer holds, which the fixture does not seed. +Spec complete and flow written (`testing/flows/csd-047-network-content.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. It enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **4/4 passed** — the Content tile (below the fold; the runner now scrolls to an off-screen control), the peer search, the fixture's peer row, the digest step and its refusal of a bad digest. On the matrix run of the same day (36588619656) it could not start on any desktop leg: the tab was clicked before the circle hop had landed, so Everyone › Rules was never shown; fixed in the runner. 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-057-wallet.md b/FSD/CSD/CSD-057-wallet.md index 536701a2..382c3645 100644 --- a/FSD/CSD/CSD-057-wallet.md +++ b/FSD/CSD/CSD-057-wallet.md @@ -161,7 +161,7 @@ warning quietly disappear would be testing the wrong half. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-057-wallet.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. +Spec complete and flow written (`testing/flows/csd-057-wallet.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/6 passed, 3 skipped** — the experimental notice before any number, the paymaster and the limits, and back to the tab; the address, the transfer form and its inputs skipped as designed (a node build synthesises a wallet with no address). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner), and the page's own `btn_wallet_back` is not drawn under the shell — the flow presses the shell's `btn_nav_back` (§4 step 6). **Platforms.** All five, agent build. Plus a node build for the `wallet_unsupported` state in §2 once it exists. diff --git a/FSD/CSD/CSD-068-provision-accord-holder.md b/FSD/CSD/CSD-068-provision-accord-holder.md index cb01939c..ceb80554 100644 --- a/FSD/CSD/CSD-068-provision-accord-holder.md +++ b/FSD/CSD/CSD-068-provision-accord-holder.md @@ -262,7 +262,7 @@ stays at `building` until it does. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. +Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). **Platforms.** Desktop and Android in practice — the flow needs a USB path and a physical token, and the iOS/browser corners have neither. The screen composes on diff --git a/FSD/CSD/CSD-092-share-contact-code.md b/FSD/CSD/CSD-092-share-contact-code.md index 8e5600cb..57ad67dc 100644 --- a/FSD/CSD/CSD-092-share-contact-code.md +++ b/FSD/CSD/CSD-092-share-contact-code.md @@ -199,7 +199,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-092-share-contact-code.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The tags are the client's at 0.5.225; the route is ciris-server 0.5.218's, so on an older node the flow drives the version fact and skips the populated, empty and refusal states. +Spec complete and flow written (`testing/flows/csd-092-share-contact-code.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The tags are the client's at 0.5.225; the route is ciris-server 0.5.218's, so on an older node the flow drives the version fact and skips the populated, empty and refusal states. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/7 passed, 4 skipped** — the card opens, names the version it needs ("0.5.218 or newer") and closes; the four 0.5.218 states skipped as designed. On the matrix run of the same day (36588619656) the version step failed on every desktop leg: the client drew the sentence as the error's body and `StateBlock` registered its tag with the title alone, so the tree could not show it — fixed in the client (the tag now carries body and detail). The flow also closes its card whatever its verdict (`cleanup:`), because left open it replaced People's body for the three flows after it. **Platforms.** All five for the card. The copy → paste → contact round trip needs two nodes and runs on desktop. diff --git a/FSD/CSD/CSD-101-household-members.md b/FSD/CSD/CSD-101-household-members.md index 74d116d5..610d28c1 100644 --- a/FSD/CSD/CSD-101-household-members.md +++ b/FSD/CSD/CSD-101-household-members.md @@ -167,7 +167,7 @@ with either a `btn_household_member_pick_*` per contact or ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-101-household-members.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The matrix's node has no household, so the first step accepts the roster's empty shape and the populated roster is gated on `household_members_list`. +Spec complete and flow written (`testing/flows/csd-101-household-members.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The matrix's node has no household, so the first step accepts the roster's empty shape and the populated roster is gated on `household_members_list`. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/4 passed, 2 skipped** — the roster composes in its empty shape and points to the hub; the populated roster and the add card skipped as designed (no household on the bare node). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner). **Platforms.** All five. diff --git a/testing/flows/README.md b/testing/flows/README.md index 2abdece1..5c596763 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -74,23 +74,28 @@ Every file in this directory runs on every leg. Promoted from `testing/flows/drafts/` on the 0.5.225 run (2026-09-29); what still waits there, and why, is in `drafts/README.md`. -| file | CSD | first screen | fixture | floor | -|---|---|---|---|---| -| `people.yaml` | CSD-005 | Contacts (bare node: the add card instead of an empty block) | — | `>=0.5.224` | -| `csd-005-people.yaml` | CSD-005 | Contacts (a seeded contact: row, chip, hamburger, receipt) | `two_node` | `>=0.5.225` | -| `csd-006-receipt.yaml` | CSD-006 | Contacts (the five facts, off the wire) | `two_node` | `>=0.5.225` | -| `csd-008-notes-to-self.yaml` | CSD-008 | Notes | — | `>=0.5.225` | -| `csd-047-network-content.yaml` | CSD-047 | LayerGlobalCommons → `tile_federation_content` | `two_node` | `>=0.5.225` | -| `csd-057-wallet.yaml` | CSD-057 | Wallet (read-only; never presses send) | — | `>=0.5.224` | -| `csd-068-provision-accord-holder.yaml` | CSD-068 | ProvisionAccordHolder (the no-token refusal) | — | `>=0.5.224` | -| `csd-092-share-contact-code.yaml` | CSD-092 | Contacts → the contact-code card | — | `>=0.5.225` | -| `csd-101-household-members.yaml` | CSD-101 | HouseholdMembers (the bare node's empty shape; the roster is gated) | — | `>=0.5.225` | +| file | CSD | first screen | fixture | floor | Linux desktop, local, 2026-09-29 | +|---|---|---|---|---|---| +| `people.yaml` | CSD-005 | Contacts (bare node: the add card instead of an empty block) | — | `>=0.5.224` | pass 3/3 | +| `csd-005-people.yaml` | CSD-005 | Contacts (a seeded contact: row, chip, hamburger, receipt) | `two_node` | `>=0.5.225` | pass 11/12, 1 skipped (no camera) | +| `csd-006-receipt.yaml` | CSD-006 | Contacts (the five facts, off the wire) | `two_node` | `>=0.5.225` | pass 5/6, 1 skipped (the node sends the grant) | +| `csd-008-notes-to-self.yaml` | CSD-008 | Notes | — | `>=0.5.225` | pass 2/2 | +| `csd-047-network-content.yaml` | CSD-047 | LayerGlobalCommons → `tile_federation_content` | `two_node` | `>=0.5.225` | pass 4/4 | +| `csd-057-wallet.yaml` | CSD-057 | Wallet (read-only; never presses send) | — | `>=0.5.224` | pass 3/6, 3 skipped (no address on a node build) | +| `csd-068-provision-accord-holder.yaml` | CSD-068 | ProvisionAccordHolder (the no-token refusal) | — | `>=0.5.224` | pass 5/5 | +| `csd-092-share-contact-code.yaml` | CSD-092 | Contacts → the contact-code card | — | `>=0.5.225` | pass 3/7, 4 skipped (0.5.218 states) | +| `csd-101-household-members.yaml` | CSD-101 | HouseholdMembers (the bare node's empty shape; the roster is gated) | — | `>=0.5.225` | pass 2/4, 2 skipped (no household) | A flow's floor is the client that carries every tag it names — checked at the -keyboard by `testing/test_flows.py`. `csd-005` and `csd-006` were run locally on -the Linux desktop leg before promotion (their CSDs' §5 say what passed); the -other six have not run anywhere yet, which is what their CSDs' `stage:` -(`building`) says. +keyboard by `testing/test_flows.py`. The last column is the Linux desktop leg +run locally the way the workflow runs it (candidate 0.5.225, node v0.5.217, +`--flows testing/flows`, the two-node fixture) after the fixes for the +0.5.225 matrix run (36588619656): that run failed all three desktop legs — +every row hop lost to a circle-hop race, three flows to a card the previous +flow left open, csd-092 to a tree text that carried only a title, and Windows +to a session fixture that slept two seconds through the wizard's mint. None of +the nine has run on the other four legs since, which is what their CSDs' +`stage:` (`building`) says. ## Verdicts diff --git a/testing/flows/people.yaml b/testing/flows/people.yaml index 7fb599f4..7841ef9a 100644 --- a/testing/flows/people.yaml +++ b/testing/flows/people.yaml @@ -10,8 +10,9 @@ description: >- (PeopleTags in ContactsScreen/PeopleSupport.kt). Not driven here, and why: the populated list and the receipt sheet need a - second node to be a contact of, which the matrix does not stand up; the - `proposed:` trust chip cannot be named by a flow at all. + second node to be a contact of — `csd-005-people.yaml` and + `csd-006-receipt.yaml` drive them with the two-node fixture, and the runner + orders this flow before any fixture so the node is still bare here. client: ">=0.5.224" steps: From ca68daea9fe56fb456370b65fbd63664ebf83cdc Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 12:37:34 -0500 Subject: [PATCH 13/27] fix(gate): print UTF-8 whatever code page the host opened the console with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows, run 36600766576: the two-node fixture's note about the owner→node binding wait was printed to a cp1252 stdout, `→` is not in cp1252, and `print` raised UnicodeEncodeError inside `up()`. The runner reported the fixture as one that could not be stood up and three flows never ran. The runner's own `—` and `§` survived only because cp1252 holds those two. Every gate CLI (run_platform, run_flows, two_node, session_fixture) now reconfigures stdout/stderr to UTF-8 on entry (testing/gate/console.py), and every text-mode read_text/write_text under testing/gate/ names its encoding. testing/test_console.py reproduces the crash on a cp1252 console, shows the reconfigure prints the note, and pins both rules against the sources. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/atlas_context.py | 2 +- testing/gate/build_screen_atlas_site.py | 6 +- testing/gate/candidate_artifacts.py | 4 +- testing/gate/console.py | 43 ++++++++ testing/gate/run_flows.py | 2 + testing/gate/run_platform.py | 2 + testing/gate/screen_atlas.py | 2 +- testing/gate/session_fixture.py | 2 + testing/gate/two_node.py | 3 + testing/test_console.py | 125 ++++++++++++++++++++++++ 10 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 testing/gate/console.py create mode 100644 testing/test_console.py diff --git a/testing/gate/atlas_context.py b/testing/gate/atlas_context.py index a5eb5d64..a90504be 100644 --- a/testing/gate/atlas_context.py +++ b/testing/gate/atlas_context.py @@ -27,7 +27,7 @@ def namespaces() -> dict: """The constitutional family count, from the file that generates it.""" if not REGISTRY.exists(): return {} - meta = json.loads(REGISTRY.read_text()).get("_meta", {}) + meta = json.loads(REGISTRY.read_text(encoding="utf-8")).get("_meta", {}) return { "cc_version": meta.get("cc_version"), "families": meta.get("n_families"), diff --git a/testing/gate/build_screen_atlas_site.py b/testing/gate/build_screen_atlas_site.py index 635aeb6c..55c5589b 100644 --- a/testing/gate/build_screen_atlas_site.py +++ b/testing/gate/build_screen_atlas_site.py @@ -497,7 +497,7 @@ def main() -> int: ap.add_argument("--out", required=True, type=Path, help="site directory to write") args = ap.parse_args() - manifest = json.loads((args.atlas / "atlas.json").read_text()) + manifest = json.loads((args.atlas / "atlas.json").read_text(encoding="utf-8")) # The SHAPE and the REASONS ride along with the pictures, so the page can # explain an arrangement instead of merely listing it. from testing.gate import atlas_context, nav_map @@ -518,8 +518,8 @@ def main() -> int: if shots_out.exists(): shutil.rmtree(shots_out) shutil.copytree(args.atlas / "shots", shots_out) - (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2)) - (args.out / "index.html").write_text(PAGE) + (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + (args.out / "index.html").write_text(PAGE, encoding="utf-8") kept = len(list(shots_out.glob("*.png"))) print(f"site -> {args.out} ({kept} shots, {manifest['captured']}/{manifest['total']} captured)") diff --git a/testing/gate/candidate_artifacts.py b/testing/gate/candidate_artifacts.py index 14c53001..4403dea9 100644 --- a/testing/gate/candidate_artifacts.py +++ b/testing/gate/candidate_artifacts.py @@ -81,7 +81,7 @@ def expected_jar_version() -> str: 1.5.201 where VERSION reads 0.5.201. Derived here rather than pattern-matched so the two cannot drift. """ - major, minor, patch = (ROOT / "VERSION").read_text().strip().split(".")[:3] + major, minor, patch = (ROOT / "VERSION").read_text(encoding="utf-8").strip().split(".")[:3] return f"{max(int(major), 1)}.{minor}.{patch}" @@ -128,7 +128,7 @@ def local(kind: str) -> Path: others = ", ".join(h.name for h in hits[:4]) raise Stale( f"{art.name} is not this tree's build -- VERSION is " - f"{(ROOT / 'VERSION').read_text().strip()}, so the jar should carry {want}.\n" + f"{(ROOT / 'VERSION').read_text(encoding='utf-8').strip()}, so the jar should carry {want}.\n" f" Found: {others}\n" f" Rebuild: ./gradlew {task}\n" f" Driving a stale jar reports a platform green for code that is not the\n" diff --git a/testing/gate/console.py b/testing/gate/console.py new file mode 100644 index 00000000..44cecc4a --- /dev/null +++ b/testing/gate/console.py @@ -0,0 +1,43 @@ +"""UTF-8 on the gate's console, whatever code page the host opened it with. + +Windows, run 36600766576 (2026-09-29): the two-node fixture had just seen the +peer become reachable and printed its note about the owner→node binding wait. +The runner's Python had opened stdout as cp1252 — the default when nothing +sets `PYTHONUTF8` — and `→` (U+2192) is not in cp1252, so `print` raised +`UnicodeEncodeError` inside `up()` and the runner reported + + fixture two_node: UNAVAILABLE — the `two_node` fixture could not be stood up: + UnicodeEncodeError: 'charmap' codec can't encode character '\\u2192' in position 69 + +which read as a node problem. Three flows could not start over a print. + +The runner's own `—` and `§` had survived the same stream only because cp1252 +happens to hold those two code points; they rendered as `�` in the UTF-8 job +log rather than crashing, which is why the crash looked like it came from +somewhere other than stdout. It did not: the whole difference was which +characters the code page has. + +THE MECHANISM, NOT THE CHARACTER. Stripping the arrow would fix one note and +leave every future one a coin flip. Every gate CLI reconfigures its console to +UTF-8 on entry instead (`sys.stdout.reconfigure`, Python 3.7+); the in-process +runner, the fixture and the flow printer all inherit it. `errors="replace"` +keeps a lone surrogate from being the next crash. +""" + +from __future__ import annotations + +import sys + + +def utf8_console() -> None: + """Put stdout and stderr in UTF-8. A stream that cannot be reconfigured + (a test's StringIO, a closed pipe) is left as it is: this must never be the + thing that fails a leg.""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (ValueError, OSError): + continue diff --git a/testing/gate/run_flows.py b/testing/gate/run_flows.py index 2d4d3733..faabbff8 100644 --- a/testing/gate/run_flows.py +++ b/testing/gate/run_flows.py @@ -52,6 +52,7 @@ from pathlib import Path from typing import Any, Callable, List, Optional, Sequence +from testing.gate.console import utf8_console from testing.gate.flow_spec import FlowRunner, FlowSpec, SpecError, check_client_floor, discover REPO = Path(__file__).resolve().parents[2] @@ -456,6 +457,7 @@ def build(name: str): def main(argv: Optional[List[str]] = None) -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--platform", default="desktop", choices=("desktop", "android", "ios")) diff --git a/testing/gate/run_platform.py b/testing/gate/run_platform.py index 056f40f9..17af79f1 100644 --- a/testing/gate/run_platform.py +++ b/testing/gate/run_platform.py @@ -44,6 +44,7 @@ from testing.driver import DriverError, TestAutomationServer from testing.gate import bringup +from testing.gate.console import utf8_console from testing.gate.platforms import CaptureKind @@ -235,6 +236,7 @@ def walk(drv: TestAutomationServer, rep: Report, shots: Path, platform, def main() -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--platform", required=True, choices=("desktop", "android", "ios")) diff --git a/testing/gate/screen_atlas.py b/testing/gate/screen_atlas.py index b0c0f251..d661b0fc 100644 --- a/testing/gate/screen_atlas.py +++ b/testing/gate/screen_atlas.py @@ -370,7 +370,7 @@ def main() -> int: "flow_only": sorted(flow_only()), "screens": results, } - (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2)) + (args.out / "atlas.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") print(f"\n{manifest['captured']}/{manifest['total']} captured -> {args.out}") return 0 if manifest["captured"] else 1 finally: diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index d511f2d9..3f68e2bf 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -47,6 +47,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from testing.driver import DriverError, TestAutomationServer # noqa: E402 +from testing.gate.console import utf8_console # noqa: E402 class SessionUnavailable(RuntimeError): @@ -318,6 +319,7 @@ def establish(drv: TestAutomationServer, username: str, password: str) -> str: def main(argv: list[str]) -> int: + utf8_console() ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--url", default="http://127.0.0.1:9091") diff --git a/testing/gate/two_node.py b/testing/gate/two_node.py index 149b029a..3e281a27 100644 --- a/testing/gate/two_node.py +++ b/testing/gate/two_node.py @@ -116,6 +116,8 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple +from testing.gate.console import utf8_console + #: The flow-level key a flow sets to ask for this fixture (`fixture: two_node`). FIXTURE = "two_node" @@ -761,6 +763,7 @@ def __exit__(self, *exc: Any) -> None: def main(argv: Optional[List[str]] = None) -> int: + utf8_console() 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") diff --git a/testing/test_console.py b/testing/test_console.py new file mode 100644 index 00000000..d835ebf7 --- /dev/null +++ b/testing/test_console.py @@ -0,0 +1,125 @@ +"""The gate prints UTF-8 whatever code page the host opened its console with. + +Windows, run 36600766576 (2026-09-29): the two-node fixture printed its note +about the owner→node binding wait to a cp1252 stdout, `→` is not in cp1252, +and `print` raised `UnicodeEncodeError` inside `up()`. The runner reported the +fixture as one that could not be stood up and three flows never ran — over a +print. `testing/gate/console.py` says why the runner's own `—` survived the +same stream. + +Three pins: the offending function on a cp1252 console (red without the +reconfigure, green with it); every gate CLI reconfigures on entry; every +text-mode file under `testing/gate/` names its encoding, so a note written to +a file is not the next code-page crash. +""" + +from __future__ import annotations + +import ast +import io +import pathlib +import re +import sys + +import pytest + +from testing.gate import console +from testing.gate import two_node as tn +from testing.test_two_node import _Clock, _contacts_http + +GATE = pathlib.Path(__file__).resolve().parent / "gate" + +#: The gate's command lines: what the matrix legs invoke, and what a developer +#: runs by hand. Each must put the console in UTF-8 before it prints anything. +CLIS = ("run_platform.py", "run_flows.py", "two_node.py", "session_fixture.py") + + +def _cp1252_console(monkeypatch) -> io.TextIOWrapper: + """A stdout opened the way the Windows runner opens it.""" + stream = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", stream) + return stream + + +def _binding_wait(monkeypatch): + """`add_contact` on the path that prints the `→` note: reachable after two asks.""" + posts: list = [] + monkeypatch.setattr(tn, "http", _contacts_http([0, 1], posts)) + monkeypatch.setattr(tn, "time", _Clock()) + host = tn.Party("local", "http://h", "t") + guest = tn.Party("peer", "http://g", "t", owner_key_id="peer-user", node_key_id="peer-node") + return lambda: tn.add_contact(host, guest, wait=0, notes=[], reachable_wait=60) + + +def test_the_binding_note_is_what_a_cp1252_console_cannot_print(monkeypatch): + """The red half: the crash the Windows leg saw, reproduced without Windows.""" + _cp1252_console(monkeypatch) + with pytest.raises(UnicodeEncodeError): + _binding_wait(monkeypatch)() + + +def test_the_gate_console_prints_the_note_whatever_the_code_page(monkeypatch): + stream = _cp1252_console(monkeypatch) + console.utf8_console() + key, via = _binding_wait(monkeypatch)() + assert (key, via) == ("peer-user", "owner") + sys.stdout.flush() + out = stream.buffer.getvalue().decode("utf-8") + assert "owner→node binding" in out, out + + +def test_a_console_that_cannot_be_reconfigured_is_left_alone(monkeypatch): + """A StringIO (a test's capture) has no `reconfigure`; a leg must not fail there.""" + monkeypatch.setattr(sys, "stdout", io.StringIO()) + console.utf8_console() + print("still prints →") + assert "→" in sys.stdout.getvalue() + + +@pytest.mark.parametrize("cli", CLIS) +def test_every_gate_cli_puts_its_console_in_utf8_on_entry(cli): + src = (GATE / cli).read_text(encoding="utf-8") + m = re.search(r"^def main\([^)]*\)[^:]*:\n((?: .*\n|\n)+?)", src, re.M) + assert m, f"{cli}: no main()" + first_lines = [ln.strip() for ln in m.group(1).splitlines() if ln.strip() and not ln.strip().startswith(('"""', "#"))] + assert first_lines and first_lines[0] == "utf8_console()", ( + f"{cli}: main() must call utf8_console() before anything prints; got {first_lines[:2]}" + ) + + +def _text_opens_without_encoding(path: pathlib.Path) -> list[str]: + """`open`/`read_text`/`write_text` calls in text mode with no `encoding=`.""" + out = [] + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.id if isinstance(func, ast.Name) else (func.attr if isinstance(func, ast.Attribute) else "") + if name not in ("open", "read_text", "write_text"): + continue + kws = {k.arg: k.value for k in node.keywords} + if "encoding" in kws: + continue + # A positional encoding: Path.read_text("utf-8", "replace"). + positional = 1 if name == "read_text" else 2 if name == "write_text" else None + if positional is not None and len(node.args) >= positional: + continue + if name == "open": + owner = func.value if isinstance(func, ast.Attribute) else None + # Other modules' `open` (tarfile.open, urllib's urlopen is not named open). + if isinstance(owner, ast.Name) and owner.id in ("tarfile", "zipfile", "gzip"): + continue + mode_index = 1 if isinstance(func, ast.Name) else 0 + mode = kws.get("mode") + if mode is None and len(node.args) > mode_index: + mode = node.args[mode_index] + if isinstance(mode, ast.Constant) and "b" in str(mode.value): + continue + out.append(f"{path.name}:{node.lineno} {name}(...) names no encoding") + return out + + +def test_every_text_mode_file_under_the_gate_names_its_encoding(): + offenders = [line for p in sorted(GATE.glob("*.py")) for line in _text_opens_without_encoding(p)] + assert not offenders, "\n".join(offenders) From 1dcf0b0cb2fe7956fc700e153705669b6346016b Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 12:38:51 -0500 Subject: [PATCH 14/27] fix(client): a disabled Provision submit is disabled to /click too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows, run 36600766576, csd_068 `fips_and_a_path`: the "confirm your YubiKey" banner was on screen before anything had been submitted. The flow's disabled-submit step had clicked `btn_provision_holder_submit` while it was greyed out, and the click RAN provision(): the Button passed `enabled = canProvision` but its testableClickable did not, so the automation handler stayed registered (CIRISClient#69's shape). Linux and macOS passed the same step by accident: the runner had scrolled down to reach the submit, the banner composed above the fold with clipped zero-size bounds, and the geometry-based `absent:` read it as gone. Windows' shorter window scrolled back up for the checkbox and it was there. Not a path problem: the client validates nothing about the USB path. The submit (and the browse button beside it) now pass `enabled` to testableClickable. testing/test_platform_automation_wiring.py pins the mirror for every tag a shipped flow clicks; CSD-068 §5 records the run. Vendoring digest re-recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- FSD/CSD/CSD-068-provision-accord-holder.md | 2 +- client/VENDORING.md | 2 +- .../ui/screens/ProvisionAccordHolderScreen.kt | 11 +- testing/test_platform_automation_wiring.py | 101 ++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/FSD/CSD/CSD-068-provision-accord-holder.md b/FSD/CSD/CSD-068-provision-accord-holder.md index ceb80554..48ccc37f 100644 --- a/FSD/CSD/CSD-068-provision-accord-holder.md +++ b/FSD/CSD/CSD-068-provision-accord-holder.md @@ -262,7 +262,7 @@ stays at `building` until it does. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). +Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). On the second (36600766576) Linux and macOS passed 5/5 and Windows failed `fips_and_a_path` with `provision_holder_error` already on screen — a third client defect: the submit's `testableClickable` did not carry the Button's `enabled`, so the disabled-submit step's `/click` ran `provision()` and raised the "confirm your YubiKey" banner (CIRISClient#69's shape). Linux and macOS had passed that step only because the banner composed above the fold the runner had scrolled past, where the geometry-based `absent:` could not see it. Fixed; `testing/test_platform_automation_wiring.py` now pins the mirror for every tag a flow clicks. **Platforms.** Desktop and Android in practice — the flow needs a USB path and a physical token, and the iOS/browser corners have neither. The screen composes on diff --git a/client/VENDORING.md b/client/VENDORING.md index 7db7e51e..783f4961 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `036fc60435ab9a3498cb611532e07cdff3ec5da1249442fab7214ea8eb186a85` +**state digest:** `c8f9990cdbfe3a6089d9d269e076a03a58572ba8665c2077f884fb0c936f654f` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt index c8b822e2..950496ed 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ProvisionAccordHolderScreen.kt @@ -336,7 +336,7 @@ fun ProvisionAccordHolderScreen( TextButton( onClick = { if (!busy) showDirPicker = true }, enabled = !busy, - modifier = Modifier.testableClickable("btn_provision_holder_usb_browse") { + modifier = Modifier.testableClickable("btn_provision_holder_usb_browse", enabled = !busy) { if (!busy) showDirPicker = true }, ) { @@ -381,10 +381,17 @@ fun ProvisionAccordHolderScreen( // ── Step 3: Provision ────────────────────────────────────────────── Spacer(Modifier.height(24.dp)) val canProvision = fipsAck && keyId.isNotBlank() && usbPath.isNotBlank() && !busy + // `enabled` goes to the automation handler too (CIRISClient#69): + // without it `/click` ran provision() behind the greyed-out + // button and put the "confirm your YubiKey" banner on a form + // nobody had submitted (Windows leg, run 36600766576). Button( onClick = { viewModel.provision() }, enabled = canProvision, - modifier = Modifier.fillMaxWidth().testableClickable("btn_provision_holder_submit") { + modifier = Modifier.fillMaxWidth().testableClickable( + "btn_provision_holder_submit", + enabled = canProvision, + ) { viewModel.provision() }, ) { diff --git a/testing/test_platform_automation_wiring.py b/testing/test_platform_automation_wiring.py index 324dee3f..bf2d01bf 100644 --- a/testing/test_platform_automation_wiring.py +++ b/testing/test_platform_automation_wiring.py @@ -186,3 +186,104 @@ def test_every_csd_surface_is_reachable(): assert m, f"{doc.name}: no csd:surface block" screen = _re.search(r"screen:\s*(\w+)", m.group(1)).group(1) assert screen in hops, f"{doc.name}: no sidebar route to Screen.{screen}" + + +# ── A disabled control a flow clicks must be disabled to `/click` too (#69) ─── +# +# Windows, run 36600766576 (2026-09-29), csd_068 `fips_and_a_path`: the +# "Confirm your YubiKey…" banner was on screen before anything had been +# submitted. The flow's second step had clicked `btn_provision_holder_submit` +# while it was greyed out, and the click RAN `provision()`: the Button passed +# `enabled = canProvision` but its `testableClickable` did not, so the +# automation handler stayed registered — CIRISClient#69's shape, which +# `bindClickHandler` exists to close and which every call site has to opt into. +# +# Linux and macOS passed the same step by accident: the runner had scrolled +# down to reach the submit, the banner composed above the fold with a clipped, +# zero-size bounds, and the geometry-based `absent:` read it as gone. Windows' +# shorter window scrolled back up for the checkbox and the banner was there. +# +# Pinned for the tags flows CLICK (a flow asserting "a disabled submit does +# nothing" is asserting exactly this), parsed with `re`, per AGENTS.md. + +FLOWS_DIR = pathlib.Path(__file__).resolve().parents[1] / "testing" / "flows" +COMMON = SHARED / "commonMain" / "kotlin" +BUTTONS = re.compile( + r"\b(?:Button|OutlinedButton|TextButton|FilledTonalButton|ElevatedButton|IconButton|FilledIconButton)\(" +) + + +def _flow_click_targets() -> set[str]: + """Every literal tag a shipped flow clicks (`${...}` tags cannot be grepped).""" + tags: set[str] = set() + for flow in FLOWS_DIR.glob("*.yaml"): + for tag in re.findall(r"^\s*-\s*click:\s*\"?([A-Za-z0-9_]+)\"?\s*$", flow.read_text(encoding="utf-8"), re.M): + tags.add(tag) + return tags + + +def _args_of(src: str, open_paren: int) -> str: + """The text inside the parentheses that open at `open_paren`.""" + depth = 0 + for i in range(open_paren, len(src)): + if src[i] == "(": + depth += 1 + elif src[i] == ")": + depth -= 1 + if depth == 0: + return src[open_paren + 1:i] + return src[open_paren + 1:] + + +def _top_level(text: str) -> str: + """`text` with nested braces and parentheses blanked, so a Button's own + `enabled =` is found and a lambda's or a nested call's is not.""" + out, depth = [], 0 + for ch in text: + if ch in "({": + depth += 1 + elif ch in ")}": + depth -= 1 + elif depth == 0: + out.append(ch) + return "".join(out) + + +def _buttons_that_do_not_disable_their_click_handler() -> dict[str, str]: + """tag -> file:line for every Button whose `enabled =` is not mirrored into + its `testableClickable(...)`, restricted to the tags flows click.""" + wanted = _flow_click_targets() + found: dict[str, str] = {} + for path in sorted(COMMON.rglob("*.kt")): + src = path.read_text(encoding="utf-8") + for m in BUTTONS.finditer(src): + args = _args_of(src, m.end() - 1) + if not re.search(r"(?:^|,)\s*enabled\s*=", _top_level(args), re.M): + continue + tc = re.search(r"testableClickable\(", args) + if not tc: + continue + tc_args = _args_of(args, tc.end() - 1) + tag = re.search(r'"([A-Za-z0-9_]+)"', tc_args) + if not tag or tag.group(1) not in wanted: + continue + if re.search(r"\benabled\s*=", _top_level(tc_args)): + continue + line = src[:m.start()].count("\n") + 1 + found[tag.group(1)] = f"{path.relative_to(SHARED)}:{line}" + return found + + +def test_the_probe_sees_the_flows_click_targets(): + """A parser that finds nothing where the construct plainly exists must fail loudly.""" + tags = _flow_click_targets() + assert "btn_provision_holder_submit" in tags and "btn_nav_back" in tags, tags + + +def test_a_button_a_flow_clicks_disables_its_click_handler_with_itself(): + offenders = _buttons_that_do_not_disable_their_click_handler() + assert not offenders, ( + "a Button's `enabled =` must be passed to its testableClickable too, or " + "`/click` presses what the person cannot (CIRISClient#69): " + + ", ".join(f"{t} at {where}" for t, where in sorted(offenders.items())) + ) From 564159d4dec8582d2293755e0211ed62c1c64fce Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 12:43:17 -0500 Subject: [PATCH 15/27] fix(flows): a cleanup can be guarded by `when:`, and an off-screen row says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS, run 36600766576: csd_005's last steps leave People's add card open with a refusal in it, and on the 1024x656 window the expanded card pushes the list below the fold; csd_006 then failed its first step with "contacts_row_ is not on screen" — composed, below the card, on a screen the harness cannot scroll (a LazyColumn with no testableVerticalScroll). Linux's taller window never noticed. - `cleanup:` actions take `when: ` and run only while that tag is on screen. The control that closes the add card is the header toggle that also opens it, so "already gone" is decided by the card, not the toggle. `when:` is refused in a step's `do:`: an action that quietly does nothing asserts nothing. - csd-005-people.yaml's cleanup clears the refusal (the field change clears it) and closes the card, both guarded. - A failed `visible:` now says "not composed (not in /tree)" or "composed but off screen after scrolling", quoting what `/scroll` answered; the helper keeps those answers in `last_scroll`. The runner already scrolled both directions, bounded; what it lacked was the sentence. Tests: the guard runs and does not run, `when:` outside cleanup is a load error, both `visible:` verdicts over a fake helper, and the helper's notes. README and CSD-005/006 §5 record the run. 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 +- testing/flows/README.md | 16 +++++++- testing/flows/csd-005-people.yaml | 11 +++++ testing/gate/flow_helper.py | 8 ++++ testing/gate/flow_spec.py | 65 ++++++++++++++++++++++++------ testing/test_flow_helper.py | 16 ++++++++ testing/test_flows.py | 67 ++++++++++++++++++++++++++++++- 8 files changed, 169 insertions(+), 18 deletions(-) diff --git a/FSD/CSD/CSD-005-people.md b/FSD/CSD/CSD-005-people.md index f5d4d67c..f54c49b6 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/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **11/12 passed, 1 skipped** — the list, the seeded row with its trust chip and hamburger, the five-fact receipt and its close, the empty search and its clearing, the add card, the node-code refusal by name, and the code card from the header; `a_scan_is_offered_where_there_is_a_camera` skipped as designed (desktop has no `btn_scan_contact_code`). The matrix run of the same day (36588619656) failed this flow on every desktop leg for csd-092's open contact-code card, which now closes itself (`cleanup:`), and its fixture waited only for the peer's owner key, not the binding (`reachable_nodes`, CIRISServer#699) — both fixed. Not yet run on the other four legs. +Spec complete and flow written (`testing/flows/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **11/12 passed, 1 skipped** — the list, the seeded row with its trust chip and hamburger, the five-fact receipt and its close, the empty search and its clearing, the add card, the node-code refusal by name, and the code card from the header; `a_scan_is_offered_where_there_is_a_camera` skipped as designed (desktop has no `btn_scan_contact_code`). The matrix run of the same day (36588619656) failed this flow on every desktop leg for csd-092's open contact-code card, which now closes itself (`cleanup:`), and its fixture waited only for the peer's owner key, not the binding (`reachable_nodes`, CIRISServer#699) — both fixed. Not yet run on the other four legs. On the second matrix run (36600766576) it passed 11/12 on Linux and macOS (the scan step skipped, no camera) and could not start on Windows (the fixture's console crash, fixed in the gate); its last steps left the add card open with a refusal in it, which on macOS's shorter window pushed the list below the fold for csd_006 — the flow's `cleanup:` now clears the refusal and closes the card (`when:` guards the header toggle). **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 95673505..d2ad228f 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 -Spec complete and flow written (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. 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:`). Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/6 passed, 1 skipped** — the seeded row's hamburger, all five envelope rows, the wire dimension, the rule row off the wire (`chat:` among the grant's prefixes) and the close; the grant-less step skipped as designed (the node sends the grant). On the matrix run of the same day (36588619656) every desktop leg failed this flow for csd-092's open contact-code card, since fixed (`cleanup:`). Not yet run on the other four legs. +Spec complete and flow written (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. 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:`). Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/6 passed, 1 skipped** — the seeded row's hamburger, all five envelope rows, the wire dimension, the rule row off the wire (`chat:` among the grant's prefixes) and the close; the grant-less step skipped as designed (the node sends the grant). On the matrix run of the same day (36588619656) every desktop leg failed this flow for csd-092's open contact-code card, since fixed (`cleanup:`). Not yet run on the other four legs. On the second matrix run (36600766576) it passed 6/6 on Linux and failed on macOS at its first step: `contacts_row_${PEER_KEY_ID}` was composed but below the fold, under the add card csd_005 had left open with a refusal, on a screen the harness cannot scroll (People's list is a LazyColumn with no `testableVerticalScroll`). csd_005's `cleanup:` now closes the card, and the runner says "composed but off screen after scrolling" with what `/scroll` answered, rather than "not on screen". 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/testing/flows/README.md b/testing/flows/README.md index 5c596763..b6816ca1 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -19,6 +19,8 @@ description: >- # optional; say what is NOT driven and why client: ">=0.5.224" # the client that carries every tag it names cleanup: # optional; run AFTER the flow, pass or fail - click: btn_contact_code_close + - click: btn_contacts_add_open + when: card_contacts_add # only while the card is open: the toggle would open it steps: - step_id: landing @@ -185,7 +187,19 @@ bring-up per leg and one session. first failed step, and a card that step left open (the contact-code card replaces People's body and its open state lives in the view model) is the next flow's failure. A cleanup that fails is reported in the outcome's - detail and the per-flow JSON; it never changes the verdict. + detail and the per-flow JSON; it never changes the verdict. A cleanup + action may carry `when: ` and then runs only while that tag is on + screen: a control that toggles (People's `btn_contacts_add_open` opens the + add card and closes it) would otherwise open the card on a run that failed + before it got there. `when:` is for `cleanup:` only — a step's action that + quietly does nothing asserts nothing. +- **A `visible:` that fails says which of two things went wrong.** "Not + composed (not in /tree)" is the client's: the tag is not drawn. "Composed + but off screen after scrolling" is the screen's or the previous flow's: the + runner scrolled both ways within its budget and quotes what `/scroll` + answered ("nothing on screen can scroll" names a container with no + `testableVerticalScroll`). The macOS csd_006 row (run 36600766576) was the + second kind, under an add card csd_005 had left open. - **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. Three flows here ask for it diff --git a/testing/flows/csd-005-people.yaml b/testing/flows/csd-005-people.yaml index 4e54cbf7..2d23410f 100644 --- a/testing/flows/csd-005-people.yaml +++ b/testing/flows/csd-005-people.yaml @@ -26,8 +26,19 @@ client: ">=0.5.225" fixture: two_node # The last step opens the contact-code card, which replaces People's body # until closed (csd-092-share-contact-code.yaml); closed here whatever the verdict. +# Before it, `a_node_code_is_refused_by_name` leaves the add card open with a +# refusal in it, and on a short window (macOS, 1024x656) the expanded card +# pushes the list below the fold — csd_006 then found its row composed but +# off screen (run 36600766576). The refusal clears when the field changes +# (`onKeyIdChange` -> `clearAddError`) and the card closes on the header +# toggle; `when:` keeps the toggle from OPENING the card on a run that failed +# before it got there. cleanup: - click: btn_contact_code_close + - input: {input_contacts_add_key: ""} + when: contacts_add_refusal + - click: btn_contacts_add_open + when: card_contacts_add steps: - step_id: on_people diff --git a/testing/gate/flow_helper.py b/testing/gate/flow_helper.py index 2d0195bc..e36a80ae 100644 --- a/testing/gate/flow_helper.py +++ b/testing/gate/flow_helper.py @@ -76,6 +76,10 @@ def __init__(self, drv: TestAutomationServer) -> None: #: csd_047 the reason was "composed but off screen", which names both #: the cause and the remedy. self.last_error = "" + #: What the last `scroll_into_view` did, one note per direction tried + #: ("down: moved", "up: already at the top"), so a `visible:` that + #: fails after scrolling can say what the screen answered. + self.last_scroll: List[str] = [] # ---- reaching --------------------------------------------------------- @@ -174,13 +178,17 @@ async def scroll_into_view(self, tag: str) -> bool: `boundsInWindow`, so "visible" here is what the scroll changes. A client without /scroll is not a failed scroll: the runner re-asks `is_element_visible` and reports honestly either way.""" + notes: List[str] = [] for direction in ("down", "up"): for _ in range(_SCROLL_STEPS): if await self.is_element_visible(tag): + self.last_scroll = list(dict.fromkeys(notes)) return True msg = self._step(tag, direction) + notes.append(f"{direction}: {msg or 'moved'}") if msg and ("no /scroll" in msg or any(word in msg for word in _SCROLL_END)): break + self.last_scroll = list(dict.fromkeys(notes)) return await self.is_element_visible(tag) async def wait_for_element(self, tag: str, timeout: int = 2000) -> bool: diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index 44e55d4f..ade4653c 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -58,7 +58,7 @@ "screen", "visible", "absent", "text", "count", "number", "matches", "one_of", "each", "relation", "state", } -_ACTION_KEYS = {"click", "input", "scroll_to", "wait", "wait_ms"} +_ACTION_KEYS = {"click", "input", "scroll_to", "wait", "wait_ms", "when"} #: 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", "fixture", "cleanup"} @@ -201,15 +201,29 @@ class Action: target: str value: Optional[str] = None wait_ms: int = 500 + #: LOCAL DELTA, `cleanup:` only: run this action only while `when` is on + #: screen. A cleanup closes what the flow opened, and a control that + #: TOGGLES (People's `btn_contacts_add_open` opens the add card and closes + #: it) would open on a flow that failed before it got there. `when:` names + #: the thing being closed, so "already gone" is decided by the card and + #: not by the toggle that is always on screen (macOS leg, run 36600766576). + when: Optional[str] = None @classmethod - def parse(cls, raw: Any, where: str) -> "Action": + def parse(cls, raw: Any, where: str, *, cleanup: bool = False) -> "Action": if not isinstance(raw, dict): raise SpecError(f"{where}: expected a mapping, got {type(raw).__name__}") unknown = set(raw) - _ACTION_KEYS if unknown: raise SpecError(f"{where}: unknown key(s) {sorted(unknown)}; allowed: {sorted(_ACTION_KEYS)}") wait_ms = int(raw.get("wait_ms", 500)) + when = raw.get("when") + if when is not None and not cleanup: + # A step's action that quietly does nothing is a step that asserts + # nothing; only a cleanup may be conditional. + raise SpecError(f"{where}: `when:` is for `cleanup:` actions only") + if when is not None and (not isinstance(when, str) or not when.strip()): + raise SpecError(f"{where}: `when:` names one tag") verbs = [k for k in ("click", "input", "scroll_to", "wait") if k in raw] if len(verbs) != 1: raise SpecError( @@ -221,8 +235,8 @@ def parse(cls, raw: Any, where: str) -> "Action": if not isinstance(spec, dict) or len(spec) != 1: raise SpecError(f"{where}: `input` takes one {{tag: text}} pair") tag, text = next(iter(spec.items())) - return cls("input", str(tag), str(text), wait_ms) - return cls(verb, str(raw[verb]), None, wait_ms) + return cls("input", str(tag), str(text), wait_ms, when) + return cls(verb, str(raw[verb]), None, wait_ms, when) def describe(self) -> str: if self.kind == "input": @@ -296,6 +310,7 @@ def variables(self) -> List[str]: names = {n for step in self.steps for n in _step_variables(step)} for a in self.cleanup: names |= set(_VAR.findall(a.target)) | set(_VAR.findall(a.value or "")) + names |= set(_VAR.findall(a.when or "")) return sorted(names) @classmethod @@ -335,7 +350,7 @@ def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": cleanup_raw = raw.get("cleanup") or [] if not isinstance(cleanup_raw, list): raise SpecError(f"{path}: `cleanup` is a list of actions (click / input / scroll_to / wait)") - spec.cleanup = [Action.parse(a, f"{path}: cleanup[{i}]") for i, a in enumerate(cleanup_raw)] + spec.cleanup = [Action.parse(a, f"{path}: cleanup[{i}]", cleanup=True) for i, a in enumerate(cleanup_raw)] # 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. @@ -414,11 +429,12 @@ def _bind_csd(self, csd_id: Any, csd_root: Optional[Path]) -> None: f"marks `proposed:`" ) for action in self.cleanup: - if action.target in doc.proposed: - raise SpecError( - f"{where}: cleanup drives {action.target!r}, which {doc.csd_id} still " - f"marks `proposed:`" - ) + for tag in (action.target, action.when): + if tag in doc.proposed: + raise SpecError( + f"{where}: cleanup names {tag!r}, which {doc.csd_id} still " + f"marks `proposed:`" + ) class UnresolvedVariable(Exception): @@ -603,6 +619,22 @@ async def _drivable(self) -> List[str]: out.append(e.test_tag) return sorted(out) + async def _not_on_screen(self, tag: str) -> str: + """LOCAL DELTA: WHY a `visible:` tag is not on screen — never composed, + or composed but off screen after the scroll budget. The two send a + reader to different places: the first to the client (the tag is not + drawn), the second to the flow before this one (what it left open) or + to the screen's scroll container (what `/scroll` answered). The macOS + leg's csd_006 (run 36600766576) said "is not on screen" for a row that + WAS composed, below an add card the previous flow had left open, on a + screen the harness cannot scroll; the words above are what it took a + screenshot to learn.""" + if await self.helper.get_element(tag) is None: + return f"{tag!r} is not composed (not in /tree)" + scrolls = getattr(self.helper, "last_scroll", None) or [] + answered = f" (scrolls: {'; '.join(scrolls)})" if scrolls else "" + return f"{tag!r} is composed but off screen after scrolling{answered}" + async def _check(self, cond: Condition, label: str) -> Optional[str]: """None if the condition holds, else the FIRST failure, named precisely.""" if cond.screen: @@ -616,7 +648,7 @@ async def _check(self, cond: Condition, label: str) -> Optional[str]: if not await self.helper.is_element_visible(tag): await self.helper.scroll_into_view(tag) if not await self.helper.is_element_visible(tag): - return f"{label}: {tag!r} is not on screen" + return f"{label}: {await self._not_on_screen(tag)}" for tag in cond.absent: if await self.helper.is_element_visible(tag): return f"{label}: {tag!r} is on screen but should not be" @@ -650,7 +682,7 @@ async def _check(self, cond: Condition, label: str) -> Optional[str]: if not await self.helper.is_element_visible(want): await self.helper.scroll_into_view(want) if not await self.helper.is_element_visible(want): - return f"{label}: state {cond.state!r} — its tag {want!r} is not on screen" + return f"{label}: state {cond.state!r} — its tag {await self._not_on_screen(want)}" for other, tag in sorted(self.state_tags.items()): if other != cond.state and tag != want and await self.helper.is_element_visible(tag): return (f"{label}: state {cond.state!r} expected, but {other!r}'s " @@ -828,13 +860,20 @@ async def _cleanup(self, spec: FlowSpec) -> None: substitute(action.target, self.variables, self.variable_notes), substitute(action.value, self.variables, self.variable_notes) if action.value is not None else None, - action.wait_ms) + action.wait_ms, + substitute(action.when, self.variables, self.variable_notes) + if action.when is not None else None) except UnresolvedVariable as exc: self.cleanup_failures.append(str(exc)) print(f" cleanup: {exc}") continue # Already gone is nothing to close: a flow whose own last step shut # the card it opened must not then report its cleanup as a failure. + # `when:` says what "gone" means for a control that would otherwise + # open the thing it is there to close. + if action.when is not None and await self.helper.get_element(action.when) is None: + print(f" cleanup: nothing to close \u2014 {action.when!r} is not on screen") + continue if action.kind in ("click", "input") and await self.helper.get_element(action.target) is None: print(f" cleanup: nothing to close \u2014 {action.target!r} is not on screen") continue diff --git a/testing/test_flow_helper.py b/testing/test_flow_helper.py index 2d3c8ae6..702e27f2 100644 --- a/testing/test_flow_helper.py +++ b/testing/test_flow_helper.py @@ -84,3 +84,19 @@ def test_scrolling_is_bounded_and_the_bottom_is_reported(): assert asyncio.run(h.click("tile_far_away")) is False assert len(d.scrolls) < 40, "bounded" assert "off screen" in h.last_error and "already at the bottom" in h.last_error + + +def test_scroll_into_view_keeps_what_the_screen_answered(): + """A `visible:` that fails after scrolling quotes the screen's answer, so + "composed but off screen" says whether there was anything to scroll.""" + d = _Drv(refuse="") + d.scroll_to = lambda tag, direction="down", amount=300: { + "success": False, + "error": f"nothing on screen 'Contacts' can scroll (no testableVerticalScroll registered)", + } + h = SyncFlowHelper(d) + assert asyncio.run(h.scroll_into_view("contacts_row_peer")) is False + assert h.last_scroll == [ + "down: nothing on screen 'Contacts' can scroll (no testableVerticalScroll registered)", + "up: nothing on screen 'Contacts' can scroll (no testableVerticalScroll registered)", + ], h.last_scroll diff --git a/testing/test_flows.py b/testing/test_flows.py index 4a255c21..10508d7f 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -336,10 +336,19 @@ async def get_screen(self): return self.screen async def is_element_visible(self, tag): - return tag in self.els + # Geometry, as the real helper reads it: composed with zero size is + # off screen (a row below the fold), not on screen. + e = self.els.get(tag) + if e is None: + return False + return e.visible if e.visible is not None else (e.width > 0 and e.height > 0) async def scroll_into_view(self, tag): - return tag in self.els + self.calls.append(f"scroll {tag}") + # This screen has nothing the harness can scroll; say so, as the real + # helper keeps what `/scroll` answered. + self.last_scroll = ["down: nothing on screen can scroll (no testableVerticalScroll registered)"] + return await self.is_element_visible(tag) async def click(self, tag, timeout=2000): self.calls.append(f"click {tag}") @@ -420,6 +429,60 @@ def test_a_cleanup_whose_target_is_already_gone_is_nothing_to_close(tmp_path): assert "cleanup" not in out.detail, out.detail +CLEANUP_WHEN = GOOD + """\ + cleanup: + - click: btn_toggle + when: card_open +""" + + +def test_a_cleanup_guarded_by_when_runs_only_while_its_card_is_open(tmp_path): + """macOS, run 36600766576: csd_005 left People's add card open (its last + step provokes a refusal in it) and csd_006's row sat below the fold. The + control that closes the card is the header toggle that also OPENS it, so + "already gone" has to be decided by the card, not by the toggle.""" + h = FakeHelper("Thing", {"thing_list": "", "btn_toggle": "", "card_open": ""}) + out = _run(_spec(tmp_path, CLEANUP_WHEN), h) + assert out.status == run_flows.PASS + assert h.calls[-1] == "click btn_toggle" + assert "cleanup" not in out.detail, out.detail + + h = FakeHelper("Thing", {"thing_list": "", "btn_toggle": ""}) # the toggle is there, the card is not + out = _run(_spec(tmp_path, CLEANUP_WHEN), h) + assert out.status == run_flows.PASS + assert "click btn_toggle" not in h.calls, "a guarded cleanup must not open what it is there to close" + assert "cleanup" not in out.detail, out.detail + + +def test_when_is_for_cleanup_actions_only(tmp_path): + """A step's action that quietly does nothing is a step that asserts nothing.""" + body = GOOD.replace(" expect:\n", " do:\n - wait: thing_list\n when: thing_list\n expect:\n") + assert "when: thing_list" in body + with pytest.raises(SpecError, match="cleanup"): + _spec(tmp_path, body) + + +def test_a_visible_tag_that_was_never_composed_says_so(tmp_path): + h = FakeHelper("Thing", {}) # thing_list is not in /tree at all + out = _run(_spec(tmp_path), h) + assert out.status == run_flows.FAIL + assert "'thing_list' is not composed" in out.detail, out.detail + + +def test_a_visible_tag_composed_below_the_fold_says_off_screen_after_scrolling(tmp_path): + """The macOS csd_006 shape: the row is in /tree with clipped, zero-size + bounds. "Is not on screen" sent the reader to the client; the row was + there, under a card the previous flow had left open, on a screen the + harness could not scroll — which is what the message now says.""" + h = FakeHelper("Thing", {"thing_list": ""}) + h.els["thing_list"] = _El("thing_list", "", visible=None, width=0, height=0) + out = _run(_spec(tmp_path), h) + assert out.status == run_flows.FAIL + assert "'thing_list' is composed but off screen after scrolling" in out.detail, out.detail + assert "no testableVerticalScroll" in out.detail, "what /scroll answered belongs in the verdict" + assert "scroll thing_list" in h.calls, "the runner tried to bring it on screen first" + + class _NextFrame(FakeHelper): """A click whose effect lands on the next frame — 0.3 s later on the wall clock, as a Compose recomposition does after `/click` returns. Reading the From 0acc2f2e402aac6ded988b38aafdb51e54a1f25c Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 12:46:32 -0500 Subject: [PATCH 16/27] fix(client): a node that answers is first-run until it has an owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android leg, run 36600766576: the leg's node was fresh (the desktop leg's seeded node had been stopped, as the workflow intends) and the client's checkFirstRunStatus took its NODE-only branch, which returned "setup complete" the moment the node answered /health — the #48 shortcut, written for the run-without-AI hand-off, where the node HAD been claimed. Login rendered with isFirstRun=false, "Local login" opened a password form for an owner that did not exist, and the session fixture reported "did not reach Setup … should have seen txt_owner_hint". Desktop never saw it because its ActiveBackend was still the agent pin at that point, so it took the /v1/setup/status + probeNodeOwnership path and got FRESH. The last green Android leg (main, 12:18) had signed in within 18 s: it was on the desktop leg's already-owned node, so nothing was ever set up there. Client: the NODE-only branch now asks probeNodeOwnership; FRESH is a first run, an owned node is configured, as the post-reconfigure router already does. Pinned at the source in test_platform_automation_wiring.py. Fixture: run_setup judges by what the client shows after btn_local_login — the wizard, or the login FORM (the client's verdict that the node is owned, which txt_owner_hint never was: it needs a served hint). On a refused sign-in, log_in reads the node's own /v1/setup/status from the nodeUrl in /state and says which side was wrong. Three fake-driver tests. Vendoring digest re-recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../kotlin/ai/ciris/mobile/shared/CIRISApp.kt | 25 ++++- testing/gate/session_fixture.py | 92 +++++++++++++++++-- testing/test_platform_automation_wiring.py | 57 ++++++++++++ testing/test_session_fixture.py | 72 +++++++++++++++ 5 files changed, 239 insertions(+), 9 deletions(-) diff --git a/client/VENDORING.md b/client/VENDORING.md index 783f4961..cb833d7a 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `c8f9990cdbfe3a6089d9d269e076a03a58572ba8665c2077f884fb0c936f654f` +**state digest:** `a9632f3a70c660c0ce933bb9806891591ed6f4130bb3a4662652c14472d298fa` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt index 1fc46d5a..291eb8ec 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/CIRISApp.kt @@ -5373,9 +5373,32 @@ private suspend fun checkFirstRunStatus( // waited for rather than declared unreachable. if (ActiveBackend.endpoint == NODE_ONLY_ENDPOINT) { if (isNodeReachable(nodeUrl)) { + // ANSWERING IS NOT OWNED. This branch used to return + // "setup complete" the moment the node answered, which is + // right after the run-without-AI hand-off (#48: that node + // was claimed before the agent left) and wrong for a node + // nobody has claimed yet: the Android leg of the + // five-platform gate (run 36600766576) met a fresh + // ciris-server, was told it was configured, rendered Login + // with isFirstRun=false, and "Local login" opened a + // password form for an owner that did not exist. Desktop + // never saw it only because its ActiveBackend was still + // the agent pin at this point, so it took the + // /v1/setup/status + probeNodeOwnership path below and got + // FRESH. Ask the node the same two questions here. + val ownership = probeNodeOwnership(nodeUrl) + if (ownership == NodeOwnership.FRESH) { + platformLog( + "checkFirstRunStatus", + "[INFO] backend is the node on :${ActiveBackend.endpoint.port} and it answers, " + + "but it has no owner (FRESH) — first run", + ) + return true + } platformLog( "checkFirstRunStatus", - "[INFO] backend is the node on :${ActiveBackend.endpoint.port} and it answers — setup complete", + "[INFO] backend is the node on :${ActiveBackend.endpoint.port} and it answers, " + + "$ownership — setup complete", ) return false } diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index 3f68e2bf..781e6a60 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -11,6 +11,10 @@ client actually does on a fresh node, observed step by step through /tree: Login (isFirstRun=true) `btn_local_login` + (isFirstRun=false: the same button reveals the LOGIN FORM instead — + the client's own verdict that the node is owned; the fixture takes + that verdict and signs in, and if the node then refuses, says which + of the two was wrong) -> Setup, step `you` username / password / confirm / device name, an age band, then `btn_next` -> Setup, step `join_federation` `trace_consent_yes`, consent toggles, `btn_next` @@ -159,21 +163,58 @@ def _settle(drv: TestAutomationServer, want: str, timeout: float = 90.0) -> bool return False +#: The login FORM: what "Local login" reveals when the client has judged the +#: node OWNED (LoginScreen: `if (isFirstRun) onLocalLogin() else showLoginForm`). +LOGIN_FORM = frozenset({"input_username", "input_password", "btn_login_submit"}) + + +def _login_form_shown(drv: TestAutomationServer) -> bool: + return drv.screen() == "Login" and LOGIN_FORM <= _tags(drv) + + +def _after_local_login(drv: TestAutomationServer, timeout: float) -> str: + """Where `btn_local_login` took the client: "Setup" (the wizard — the + client read the node as fresh), "form" (the login form — the client read + it as owned), or "" when neither showed within `timeout`.""" + deadline = time.monotonic() + timeout + while True: + if drv.screen() == "Setup": + return "Setup" + if _login_form_shown(drv): + return "form" + if time.monotonic() >= deadline: + return "" + time.sleep(1.5) + + def run_setup(drv: TestAutomationServer, username: str, password: str, device: str = "gate") -> None: - """Drive the first-run wizard until the node has an owner.""" - if "txt_owner_hint" in _tags(drv): - return # already owned; nothing to do + """Drive the first-run wizard until the node has an owner — or find that + the client already holds the node to be owned, and leave it to `log_in`. + + THE CLIENT'S VERDICT, NOT A HINT. `txt_owner_hint` composes only when + `/v1/auth/owner-hint` returns a hint, which a node claimed by this very + fixture need not serve; the Android leg (run 36600766576) showed Login + without it, "Local login" opened the login FORM (the client's isFirstRun + was false), and the fixture — waiting for Setup — called that "did not + reach Setup" and blamed a missing hint. What the form says is that the + client judged the node owned; that is the thing to act on, and if the + node then refuses the credentials, `log_in` says which side was wrong.""" + if "txt_owner_hint" in _tags(drv) or _login_form_shown(drv): + return # the client holds the node to be owned; sign in # Desktop's first run shows Login; a client whose first run opens the wizard # directly is already where this click would take it, and clicking a # `btn_local_login` that is not on screen fails the fixture for nothing. if drv.screen() != "Setup": drv.click("btn_local_login") - if not _settle(drv, "Setup", timeout=30): + landed = _after_local_login(drv, timeout=30) + if landed == "form": + return + if landed != "Setup": raise SessionUnavailable( - f"btn_local_login did not reach Setup (on {drv.screen()!r}); on a node " - f"that already has an owner this fixture should have seen txt_owner_hint" + f"btn_local_login reached neither Setup (the wizard) nor the login form " + f"within 30s (on {drv.screen()!r}); on screen: {sorted(_tags(drv))}" ) for tag, value in (("input_username", username), @@ -306,7 +347,44 @@ def log_in(drv: TestAutomationServer, username: str, password: str, if screen != "Login": return screen time.sleep(1.5) - raise SessionUnavailable(f"still on Login after {timeout:.0f}s") + raise SessionUnavailable(f"still on Login after {timeout:.0f}s{_why_login_failed(drv)}") + + +def _node_setup_required(node_url: str) -> bool | None: + """The NODE's own first-run predicate (`GET /v1/setup/status`), or None + when it cannot be read. Best effort: this is a diagnosis, never a gate.""" + import json # noqa: PLC0415 + import urllib.request # noqa: PLC0415 + try: + with urllib.request.urlopen(f"{node_url.rstrip('/')}/v1/setup/status", timeout=5) as r: + body = json.loads(r.read().decode("utf-8", "replace")) + except Exception: # noqa: BLE001 — unreadable is "cannot say" + return None + data = body.get("data", body) if isinstance(body, dict) else {} + value = data.get("setup_required") if isinstance(data, dict) else None + return value if isinstance(value, bool) else None + + +def _why_login_failed(drv: TestAutomationServer) -> str: + """Which side was wrong when the client's login form refused the fixture's + owner: the node (it has an owner and these are not its credentials) or + the client (the node says setup is required — it has NO owner — and the + client offered a password form instead of the wizard). Read off the + node's own predicate, from the node URL the client reports in `/state`.""" + try: + node_url = str(drv.state().get("nodeUrl") or "") + except Exception: # noqa: BLE001 — an older client serves no /state + node_url = "" + if not node_url: + return "" + required = _node_setup_required(node_url) + if required is True: + return (f"; the node at {node_url} says setup_required=true (it has no owner), yet the " + f"client offered a login form instead of the wizard: the client's first-run " + f"check is wrong, not these credentials (CIRISApp.checkFirstRunStatus, NODE-only branch)") + if required is False: + return f"; the node at {node_url} has an owner, and these are not its credentials" + return f"; the node at {node_url} could not say whether it has an owner" def establish(drv: TestAutomationServer, username: str, password: str) -> str: diff --git a/testing/test_platform_automation_wiring.py b/testing/test_platform_automation_wiring.py index bf2d01bf..ecd7e86c 100644 --- a/testing/test_platform_automation_wiring.py +++ b/testing/test_platform_automation_wiring.py @@ -287,3 +287,60 @@ def test_a_button_a_flow_clicks_disables_its_click_handler_with_itself(): "`/click` presses what the person cannot (CIRISClient#69): " + ", ".join(f"{t} at {where}" for t, where in sorted(offenders.items())) ) + + +# ── A node that ANSWERS is not a node that is OWNED (Android leg) ──────────── +# +# Run 36600766576: the Android leg's node was fresh (the desktop leg's seeded +# node had been stopped, as the workflow intends), the client's +# `checkFirstRunStatus` took its NODE-only branch, and that branch returned +# "setup complete" the moment the node answered `/health` — the #48 shortcut, +# written for the run-without-AI hand-off, where the node HAD been claimed. +# Login rendered with isFirstRun=false, "Local login" opened a password form +# for an owner that did not exist, and the session fixture called it "did not +# reach Setup". Desktop never saw it because its ActiveBackend was still the +# agent pin at that point, so it took the /v1/setup/status path and got FRESH. +# +# Pinned at the source: the NODE-only branch must ask the node whether it has +# an owner (`probeNodeOwnership` / `nodeHasOwner`) before it may answer false. + +CIRISAPP = SHARED / "commonMain" / "kotlin" / "ai" / "ciris" / "mobile" / "shared" / "CIRISApp.kt" + + +def _block(src: str, open_brace: int) -> str: + depth = 0 + for i in range(open_brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[open_brace:i + 1] + return src[open_brace:] + + +def _node_only_branch_of_first_run_check() -> str: + src = CIRISAPP.read_text(encoding="utf-8") + start = src.index("private suspend fun checkFirstRunStatus(") + body = _block(src, src.index("{", src.index(")", start))) + # The function opens with `val nodeUrl = if (ActiveBackend.endpoint == …)`, + # which is the same test on a different question; the branch wanted is the + # one that decides on the node answering. + blocks = [_block(body, m.end() - 1) + for m in re.finditer(r"if \(ActiveBackend\.endpoint == NODE_ONLY_ENDPOINT\) \{", body)] + deciding = [b for b in blocks if "isNodeReachable(" in b] + assert deciding, "checkFirstRunStatus has no NODE-only branch that decides on the node answering (did it move?)" + return deciding[0] + + +def test_the_node_only_first_run_check_asks_the_node_whether_it_is_owned(): + branch = _node_only_branch_of_first_run_check() + asks = re.search(r"probeNodeOwnership\(|nodeHasOwner\(", branch) + first_false = re.search(r"return false", branch) + assert asks, "the NODE-only branch never asks whether the node has an owner: a fresh node is called configured" + assert first_false and asks.start() < first_false.start(), ( + "the NODE-only branch answers 'configured' before asking whether the node has an owner" + ) + assert re.search(r"NodeOwnership\.FRESH[^\n]*\n[^\n]*\n?[^\n]*return true|FRESH.*?return true", branch, re.S), ( + "a FRESH node must be a first run (return true) on the NODE-only branch" + ) diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py index b05b15ec..b2b8b063 100644 --- a/testing/test_session_fixture.py +++ b/testing/test_session_fixture.py @@ -116,3 +116,75 @@ def test_a_step_that_truly_stalls_is_still_reported_by_name(monkeypatch): sf.run_setup(w, "qaadmin", "QaAdmin!2345") assert "'you'" in str(e.value) assert clock.t < 300, "bounded: a stalled wizard is reported in minutes, not hours" + + +class _OwnedLogin: + """A client that has judged the node OWNED: `btn_local_login` reveals the + login FORM (LoginScreen: `if (isFirstRun) onLocalLogin() else + showLoginForm`), never the wizard, and no `txt_owner_hint` composes + (the node serves no owner hint). The Android leg's shape, run + 36600766576.""" + + def __init__(self): + self.form = False + self.clicks: list[str] = [] + self.inputs: dict[str, str] = {} + + def screen(self): + return "Login" + + def tree(self): + if self.form: + return [_el(t) for t in ("input_username", "input_password", "btn_login_submit")] + return [_el("btn_local_login")] + + def click(self, tag): + self.clicks.append(tag) + if tag == "btn_local_login": + self.form = True + + def input(self, tag, text): + self.inputs[tag] = text + + def state(self): + return {"screen": "Login", "clientMode": "NODE", "nodeUrl": "http://127.0.0.1:4243"} + + +def test_the_login_form_is_the_clients_verdict_that_the_node_is_owned(monkeypatch): + """No wizard and no owner hint, but a password form: the client holds the + node to be owned. That is not "did not reach Setup"; it is "sign in".""" + monkeypatch.setattr(sf, "time", _Clock()) + w = _OwnedLogin() + sf.run_setup(w, "qaadmin", "QaAdmin!2345") # must not raise + assert w.clicks == ["btn_local_login"] + assert w.form, "the form the client offered is what the fixture judged by" + + +def test_a_client_that_shows_neither_wizard_nor_form_is_still_reported(monkeypatch): + monkeypatch.setattr(sf, "time", _Clock()) + w = _OwnedLogin() + w.click = lambda tag: w.clicks.append(tag) # the click lands nowhere + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "neither Setup" in str(e.value) and "login form" in str(e.value) + + +def test_a_refused_sign_in_says_which_side_was_wrong(monkeypatch): + """The Android leg's actual state: the node has NO owner (its own + /v1/setup/status says setup_required=true) and the client offered a + password form anyway. The fixture names the client's first-run check, + not its credentials.""" + monkeypatch.setattr(sf, "time", _Clock()) + monkeypatch.setattr(sf, "_node_setup_required", lambda url: True) + w = _OwnedLogin() + w.form = True + with pytest.raises(sf.SessionUnavailable) as e: + sf.log_in(w, "qaadmin", "QaAdmin!2345", timeout=10) + msg = str(e.value) + assert "setup_required=true" in msg and "first-run check is wrong" in msg, msg + assert "127.0.0.1:4243" in msg + + monkeypatch.setattr(sf, "_node_setup_required", lambda url: False) + with pytest.raises(sf.SessionUnavailable) as e: + sf.log_in(w, "qaadmin", "QaAdmin!2345", timeout=10) + assert "has an owner, and these are not its credentials" in str(e.value) From 3b4ae6f7d9e41c7a306b05b932d30ace581b3cdc Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 18:33:43 -0500 Subject: [PATCH 17/27] fix(flows): a cleanup guard reads the frame after the previous close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local Linux leg, 2026-09-29, with the `when:` guard in place: csd_005's cleanup closed the contact-code card and, in the same instant, read People's add card as gone — it had not recomposed yet — so the guarded toggle was skipped and csd_006 started under the open card after all (its row on screen only because the window is tall; the per-flow JSON shows `card_contacts_add` and `contacts_add_refusal` on screen at its first step). Once a cleanup action has run, "already gone" is re-read for up to the expect settle budget before it is believed; the first guard still reads once. Pinned over the next-frame fake helper, which now lands on element reads too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/flow_spec.py | 26 ++++++++++++++++++++++++-- testing/test_flows.py | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index ade4653c..41f5c4c5 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -850,10 +850,31 @@ async def run(self, spec: FlowSpec) -> bool: finally: await self._cleanup(spec) + async def _composed(self, tag: str, *, settle: bool, budget: float = EXPECT_SETTLE_S) -> bool: + """Is `tag` in /tree — read once, or re-read for up to `budget` when a + cleanup action just changed the screen. The local Linux leg + (2026-09-29) closed the contact-code card and, in the same instant, + read People's add card as gone: the card had not recomposed yet, the + guarded toggle was skipped, and csd_006 started under the open card + after all (its row on screen only because the window was tall).""" + import asyncio # noqa: PLC0415 + + deadline = time.monotonic() + (budget if settle else 0.0) + while True: + if await self.helper.get_element(tag) is not None: + return True + if time.monotonic() >= deadline: + return False + await asyncio.sleep(0.25) + async def _cleanup(self, spec: FlowSpec) -> None: """LOCAL DELTA: close what the flow opened, whatever its verdict. A failure here is recorded, never raised: it is not this flow's verdict, and hiding the verdict behind it would help nobody.""" + # Whether a cleanup action has run: the tree read after one is the + # same-instant race `_settled` names, so "gone" is then judged on + # the frame that follows, not the frame the click was made on. + changed = False for action in spec.cleanup: try: action = Action(action.kind, @@ -871,12 +892,13 @@ async def _cleanup(self, spec: FlowSpec) -> None: # the card it opened must not then report its cleanup as a failure. # `when:` says what "gone" means for a control that would otherwise # open the thing it is there to close. - if action.when is not None and await self.helper.get_element(action.when) is None: + if action.when is not None and not await self._composed(action.when, settle=changed): print(f" cleanup: nothing to close \u2014 {action.when!r} is not on screen") continue - if action.kind in ("click", "input") and await self.helper.get_element(action.target) is None: + if action.kind in ("click", "input") and not await self._composed(action.target, settle=changed): print(f" cleanup: nothing to close \u2014 {action.target!r} is not on screen") continue + changed = True err = await self._do(action) if err: self.cleanup_failures.append(err) diff --git a/testing/test_flows.py b/testing/test_flows.py index 10508d7f..7b0ca17b 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -454,6 +454,28 @@ def test_a_cleanup_guarded_by_when_runs_only_while_its_card_is_open(tmp_path): assert "cleanup" not in out.detail, out.detail +CLEANUP_AFTER_CLOSE = GOOD + """\ + cleanup: + - click: btn_code_close + - click: btn_toggle + when: card_open +""" + + +def test_a_cleanup_guard_reads_the_frame_after_the_previous_close(tmp_path): + """Local Linux leg, 2026-09-29: closing the contact-code card brought + People's add card back on the NEXT frame; the guard read the same + instant, saw no card, skipped the toggle, and csd_006 started under the + open card after all.""" + h = _NextFrame("Thing", {"thing_list": "", "btn_code_close": "", "btn_toggle": ""}) + # Closing the code card lands a frame later, and only then is the add card back. + h.leads = {"btn_code_close": ("Thing", ["thing_list", "btn_toggle", "card_open"])} + out = _run(_spec(tmp_path, CLEANUP_AFTER_CLOSE), h) + assert out.status == run_flows.PASS + assert h.calls[-1] == "click btn_toggle", h.calls + assert "cleanup" not in out.detail, out.detail + + def test_when_is_for_cleanup_actions_only(tmp_path): """A step's action that quietly does nothing is a step that asserts nothing.""" body = GOOD.replace(" expect:\n", " do:\n - wait: thing_list\n when: thing_list\n expect:\n") @@ -511,6 +533,10 @@ async def get_elements(self): self._land() return await super().get_elements() + async def get_element(self, tag): + self._land() + return await super().get_element(tag) + async def get_screen(self): self._land() return await super().get_screen() From ccd5e77bad6d0aa5688c92422ea4d254b8fdc6e8 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 11:11:30 -0500 Subject: [PATCH 18/27] fix(flows): the session fixture waits out an in-progress ownership claim Android (run 36733112700) reached the claim for the first time and showed `setup_ownership_claiming` with the step indicators and no active step. The wizard loop only knew `setup_ownership_claimed`, looked for Next, and raised "wizard step '' offers no advance control". Any of the claim's three faces now ends the loop; the fixture waits up to 120 s for the claim to resolve, reports the node's reason on `setup_ownership_error`, and the screen on timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/session_fixture.py | 52 +++++++++++++++++++++++++--- testing/test_session_fixture.py | 60 ++++++++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index 781e6a60..78a91b00 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -18,8 +18,11 @@ -> Setup, step `you` username / password / confirm / device name, an age band, then `btn_next` -> Setup, step `join_federation` `trace_consent_yes`, consent toggles, `btn_next` - -> `setup_ownership_claimed` no advance control: the claim is work, - not a step, and it finishes on its own + -> `setup_ownership_claiming` no advance control and no active step: + the claim is work, not a step, and it + finishes on its own (CLAIM_TIMEOUT) + -> `setup_ownership_claimed` or `setup_ownership_error`, which + names why the node refused -> Login, now with `txt_owner_hint` Login `btn_local_login` reveals the form, username / password, `btn_login_submit` @@ -142,12 +145,52 @@ def _field_report(drv: TestAutomationServer) -> str: ADVANCE_TIMEOUT = 90.0 +#: The claim's three faces (SetupScreen's completion step). Any of them means +#: the wizard is done asking and the fixture has nothing left to click. +CLAIM_TAGS = frozenset({"setup_ownership_claiming", "setup_ownership_claimed", "setup_ownership_error"}) + +#: How long the in-progress claim may run before the fixture calls it stuck. +CLAIM_TIMEOUT = 120.0 + + +def _claim_started(tags: set[str]) -> bool: + return bool(CLAIM_TAGS & tags) + + +def _await_claim(drv: TestAutomationServer, timeout: float = CLAIM_TIMEOUT, poll: float = 1.5) -> None: + """Wait out `setup_ownership_claiming`. It has no control — Android (run + 36733112700) showed it with the step indicators and no active step, and a + fixture looking for Next there raised "offers no advance control". Returns + once the claim resolved (claimed, or the app left Setup); raises with the + node's reason on `setup_ownership_error`, and with the screen on timeout.""" + deadline = time.monotonic() + timeout + while True: + if drv.screen() != "Setup": + return + tree = drv.tree() + tags = {e.test_tag for e in tree} + if "setup_ownership_error" in tags: + why = next((e.text for e in tree if e.test_tag == "setup_ownership_error" and e.text), "") + raise SessionUnavailable( + f"the node refused the ownership claim: {why or '(no reason on screen)'}; " + f"on screen: {sorted(tags)}" + ) + if "setup_ownership_claiming" not in tags: + return + if time.monotonic() >= deadline: + raise SessionUnavailable( + f"the claim did not finish within {timeout:.0f}s (still claiming); " + f"on screen: {sorted(tags)}" + ) + time.sleep(poll) + + def _advanced(drv: TestAutomationServer, before: tuple, timeout: float, poll: float = 1.0) -> bool: """True once the wizard is past `before` (screen, active step) or the claim has taken over; False when it is still there after `timeout`.""" deadline = time.monotonic() + timeout while True: - if (drv.screen(), _active_step(drv)) != before or "setup_ownership_claimed" in _tags(drv): + if (drv.screen(), _active_step(drv)) != before or _claim_started(_tags(drv)): return True if time.monotonic() >= deadline: return False @@ -254,7 +297,7 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, # must say so rather than spin. for _ in range(12): tags = _tags(drv) - if "setup_ownership_claimed" in tags or drv.screen() != "Setup": + if _claim_started(tags) or drv.screen() != "Setup": break # Screen 2 asks whether to send traces and will not advance until # answered (no default, like the age band above). Yes is the fixture's @@ -322,6 +365,7 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, ) # The claim has no button; it completes and the app returns to Login. + _await_claim(drv) if not _settle(drv, "Login", timeout=180): raise SessionUnavailable( f"setup never returned to Login (on {drv.screen()!r}) — the claim did not finish" diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py index b2b8b063..fe8a71a8 100644 --- a/testing/test_session_fixture.py +++ b/testing/test_session_fixture.py @@ -39,8 +39,13 @@ class _SlowWizard: body meanwhile — the shape the Windows leg showed. The consent step answers, Next again claims, and the claim returns the app to Login.""" - def __init__(self, clock: _Clock, advance_after: float): + def __init__(self, clock: _Clock, advance_after: float, claim_takes: float = 0.0, + claim_error: str | None = None): self.clock, self.advance_after = clock, advance_after + # The claim is WORK: `setup_ownership_claiming` for `claim_takes` + # seconds (step indicators up, no step active, no advance control), + # then claimed — or the error panel, which never returns to Login. + self.claim_takes, self.claim_error = claim_takes, claim_error self.on = "Login" self.step = "you" self.next_at: float | None = None @@ -53,8 +58,18 @@ def _tick(self): if self.step == "you" and self.next_at is not None and self.clock.t - self.next_at >= self.advance_after: self.step, self.next_at = "join_federation", None + def _claim_phase(self) -> str: + if self.claimed_at is None: + return "" + spent = self.clock.t - self.claimed_at + if spent < self.claim_takes: + return "claiming" + if self.claim_error is not None: + return "error" + return "Login" if spent >= self.claim_takes + 1.0 else "claimed" + def screen(self): - if self.claimed_at is not None and self.clock.t - self.claimed_at >= 1.0: + if self._claim_phase() == "Login": return "Login" return self.on @@ -63,6 +78,14 @@ def tree(self): if self.screen() == "Login": return [_el("btn_local_login")] if self.step == "claimed": + phase = self._claim_phase() + # Android, run 36733112700: the indicators stay, none is active. + indicators = [_el("setup_step_indicators"), _el("step_indicator_you", ""), + _el("step_indicator_join_federation", "")] + if phase == "claiming": + return [_el("setup_ownership_claiming")] + indicators + if phase == "error": + return [_el("setup_ownership_error", self.claim_error)] + indicators return [_el("setup_ownership_claimed")] indicators = [_el("setup_step_indicators"), _el("step_indicator_you", "active" if self.step == "you" else ""), @@ -95,10 +118,10 @@ def scroll_to(self, tag, direction="down", amount=300): return {"error": "NO overflow"} -def _drive(monkeypatch, advance_after: float): +def _drive(monkeypatch, advance_after: float, **kw): clock = _Clock() monkeypatch.setattr(sf, "time", clock) - w = _SlowWizard(clock, advance_after) + w = _SlowWizard(clock, advance_after, **kw) return clock, w @@ -118,6 +141,35 @@ def test_a_step_that_truly_stalls_is_still_reported_by_name(monkeypatch): assert clock.t < 300, "bounded: a stalled wizard is reported in minutes, not hours" +def test_a_claim_in_progress_is_waited_for_not_asked_for_a_control(monkeypatch): + """Android, run 36733112700: after the last Next the wizard showed + `setup_ownership_claiming` with no step active and no advance control, and + the fixture raised "wizard step '' offers no advance control". The claim + is work that finishes on its own; the fixture waits for it.""" + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=40.0) + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.screen() == "Login" + assert w.clicks.count("btn_next") == 2 + + +def test_a_claim_that_never_finishes_is_reported_with_the_screen(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=10 ** 6) + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + msg = str(e.value) + assert "claim did not finish" in msg and "setup_ownership_claiming" in msg, msg + assert clock.t < 300, "bounded" + + +def test_a_refused_claim_is_reported_with_its_reason(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=5.0, + claim_error="claim PIN was not captured") + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "claim PIN was not captured" in str(e.value) + assert clock.t < 60, "a refused claim is reported when it is refused, not at the timeout" + + class _OwnedLogin: """A client that has judged the node OWNED: `btn_local_login` reveals the login FORM (LoginScreen: `if (isFirstRun) onLocalLogin() else From 2d5800f0435117f834c5546faab3d55bdc99e85e Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 11:28:39 -0500 Subject: [PATCH 19/27] fix(flows): a disabled control's click is refused the same way everywhere, and a flow can say so iOS, run 36733112700, csd_068 `empty_submit_does_nothing`: after 1dcf0b0c the disabled Provision submit had no /click handler, iOS answered 404 "No click handler for: btn_provision_holder_submit", and the plain `click:` failed the step whose claim is that the click does nothing. Desktop passed only because its no-handler fallback coordinate-clicked the greyed-out button and answered success; Android shares iOS's handler and never reached flows. Client: `bindClickHandler` now also records the control as disabled (`DisabledControls`, cleared when it enables or disposes). The shared handler and the desktop route answer a click on it with HTTP 409 and " is disabled", action `click-refused`, instead of 404 or a coordinate click. Pinned in DisabledClickHandlerTest and MobileAutomationSurfaceTest (both red with the two production lines neutralised). Vendoring digest re-recorded. Flows: a new verb, `click_refused: TAG`, holds when the platform refuses the click (either answer) or no handler answers it, and fails when a handler runs (CIRISClient#69); the step's `absent:` still judges the effect. csd_068 uses it. The session fixture's wait for a late-enabling Next reads "is disabled" as well as "No click handler". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- FSD/CSD/CSD-068-provision-accord-holder.md | 2 +- client/VENDORING.md | 2 +- .../desktop/testing/TestAutomationServer.kt | 19 ++++++ .../testing/TestAutomationServer.android.kt | 9 ++- .../mobile/shared/platform/TestAutomation.kt | 43 +++++++++++-- .../shared/testing/TestAutomationHandler.kt | 12 ++++ .../platform/DisabledClickHandlerTest.kt | 15 ++++- .../testing/MobileAutomationSurfaceTest.kt | 21 +++++++ .../testing/TestAutomationServer.ios.kt | 9 ++- testing/driver.py | 7 ++- testing/flows/README.md | 6 +- .../csd-068-provision-accord-holder.yaml | 11 ++-- testing/gate/VENDORED.md | 1 + testing/gate/flow_helper.py | 34 +++++++++++ testing/gate/flow_spec.py | 23 +++++-- testing/gate/session_fixture.py | 12 +++- testing/test_flow_helper.py | 61 ++++++++++++++++++- testing/test_flows.py | 45 ++++++++++++++ testing/test_platform_automation_wiring.py | 6 +- testing/test_session_fixture.py | 16 +++++ 20 files changed, 324 insertions(+), 30 deletions(-) diff --git a/FSD/CSD/CSD-068-provision-accord-holder.md b/FSD/CSD/CSD-068-provision-accord-holder.md index 48ccc37f..d7bd18e1 100644 --- a/FSD/CSD/CSD-068-provision-accord-holder.md +++ b/FSD/CSD/CSD-068-provision-accord-holder.md @@ -262,7 +262,7 @@ stays at `building` until it does. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). On the second (36600766576) Linux and macOS passed 5/5 and Windows failed `fips_and_a_path` with `provision_holder_error` already on screen — a third client defect: the submit's `testableClickable` did not carry the Button's `enabled`, so the disabled-submit step's `/click` ran `provision()` and raised the "confirm your YubiKey" banner (CIRISClient#69's shape). Linux and macOS had passed that step only because the banner composed above the fold the runner had scrolled past, where the geometry-based `absent:` could not see it. Fixed; `testing/test_platform_automation_wiring.py` now pins the mirror for every tag a flow clicks. +Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). On the second (36600766576) Linux and macOS passed 5/5 and Windows failed `fips_and_a_path` with `provision_holder_error` already on screen — a third client defect: the submit's `testableClickable` did not carry the Button's `enabled`, so the disabled-submit step's `/click` ran `provision()` and raised the "confirm your YubiKey" banner (CIRISClient#69's shape). Linux and macOS had passed that step only because the banner composed above the fold the runner had scrolled past, where the geometry-based `absent:` could not see it. Fixed; `testing/test_platform_automation_wiring.py` now pins the mirror for every tag a flow clicks. On the third (36733112700) every desktop leg passed; iOS failed `empty_submit_does_nothing` because the now-disabled submit refused `/click` (404 "No click handler") and a plain `click:` counts a refusal as a failure. The step now says `click_refused:`, which holds when the click is refused and fails if a handler runs, and every platform answers a disabled control the same way (409, "is disabled"). **Platforms.** Desktop and Android in practice — the flow needs a USB path and a physical token, and the iOS/browser corners have neither. The screen composes on diff --git a/client/VENDORING.md b/client/VENDORING.md index cb833d7a..220b73ae 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `a9632f3a70c660c0ce933bb9806891591ed6f4130bb3a4662652c14472d298fa` +**state digest:** `6f86e57207e12c116fa30a2b7929ce3b8afb80e76b54e3c3ec084fa873c8c010` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt index 4d3240e3..ff46d37d 100644 --- a/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt +++ b/client/desktopApp/src/main/kotlin/ai/ciris/desktop/testing/TestAutomationServer.kt @@ -407,6 +407,25 @@ class TestAutomationServer( return@post } + // DISABLED: REFUSED, NOT COORDINATE-CLICKED. A disabled + // `testableClickable` has no handler by design, and the + // fallback below used to click the greyed-out button and + // answer success — while iOS and Android answered "No click + // handler" for the same control (run 36733112700). All three + // now say what it is (ai.ciris.mobile.shared.platform.DisabledControls). + if (ai.ciris.mobile.shared.platform.DisabledControls.isDisabled(request.testTag)) { + call.respond( + HttpStatusCode.Conflict, + ActionResponse( + success = false, + element = request.testTag, + action = ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION, + error = ai.ciris.mobile.shared.platform.DisabledControls.refusal(request.testTag), + ) + ) + return@post + } + if (element == null) { call.respond( HttpStatusCode.NotFound, diff --git a/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt b/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt index 3d131da8..aebdd56a 100644 --- a/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt +++ b/client/shared/src/androidMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.android.kt @@ -85,8 +85,15 @@ class AndroidTestAutomationServer(private val port: Int = 9091) { post("/click") { val request = call.receive() val resp = TestAutomationHandler.handleClick(request) + // 409 for a disabled control: it exists and refuses, which + // is not "not found" (desktop and iOS answer the same). call.respond( - if (resp.success) HttpStatusCode.OK else HttpStatusCode.NotFound, + when { + resp.success -> HttpStatusCode.OK + resp.action == ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION -> + HttpStatusCode.Conflict + else -> HttpStatusCode.NotFound + }, resp ) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt index fe9f3993..9d0b319c 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt @@ -12,7 +12,9 @@ import androidx.compose.ui.composed import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update /** * Data class for pending text input requests. @@ -231,8 +233,8 @@ fun Modifier.testable(tag: String, text: String? = null): Modifier = composed { * Register [tag]'s programmatic click handler only while the control is * [enabled]; a disabled control has none. * - * With no handler `/click` answers "No click handler" and `/tree` reports - * `canClick: false` — which is what a disabled control IS. The handler used to + * With no handler `/tree` reports `canClick: false` — which is what a disabled + * control IS — and `/click` answers that it is disabled ([DisabledControls]). The handler used to * be registered unconditionally, so `/click btn_next` ran the wizard's final * step while Next was greyed out for a step already in flight (CIRISClient#69): * a robot could do what no person could. @@ -240,6 +242,39 @@ fun Modifier.testable(tag: String, text: String? = null): Modifier = composed { internal fun bindClickHandler(tag: String, enabled: Boolean, handler: () -> Unit) { if (enabled) TestAutomation.registerClickHandler(tag, handler) else TestAutomation.unregisterClickHandler(tag) + DisabledControls.mark(tag, disabled = !enabled) +} + +/** The control is gone (or its `enabled` is about to change): no handler, and no longer "disabled". */ +internal fun releaseClickHandler(tag: String) { + TestAutomation.unregisterClickHandler(tag) + DisabledControls.mark(tag, disabled = false) +} + +/** + * The controls composed right now with `enabled = false` — so `/click` can say + * "disabled" instead of "no click handler", on every platform, in one sentence. + * + * iOS, run 36733112700: a disabled Provision submit answered `/click` with 404 + * "No click handler", which reads as a wiring defect, while desktop fell back + * to a coordinate click on the greyed-out button and answered success. Three + * platforms, two answers, neither of them "disabled". A disabled control is + * a known state of a known control, and the answer now names it (HTTP 409, + * `action = "click-refused"`). + */ +object DisabledControls { + /** The `action` a refused click reports; the servers map it to HTTP 409. */ + const val REFUSED_ACTION = "click-refused" + + private val tags = MutableStateFlow>(emptySet()) + + fun mark(tag: String, disabled: Boolean) = + tags.update { if (disabled) it + tag else it - tag } + + fun isDisabled(tag: String): Boolean = tag in tags.value + + fun refusal(tag: String): String = + "$tag is disabled: it refuses /click, as it refuses a press, until the screen enables it" } /** @@ -266,13 +301,13 @@ fun Modifier.testableClickable( val currentOnClick by rememberUpdatedState(onClick) DisposableEffect(tag) { onDispose { - TestAutomation.unregisterClickHandler(tag) + releaseClickHandler(tag) TestAutomation.unregisterElement(tag) } } DisposableEffect(tag, enabled) { bindClickHandler(tag, enabled) { currentOnClick() } - onDispose { TestAutomation.unregisterClickHandler(tag) } + onDispose { releaseClickHandler(tag) } } this.testTag(tag).clickable(enabled = enabled) { onClick() }.trackPosition(tag, text) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt index 44d07676..41140749 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationHandler.kt @@ -207,6 +207,18 @@ object TestAutomationHandler { ) } + // DISABLED IS AN ANSWER, NOT A MISSING HANDLER. A `testableClickable` + // with `enabled = false` has no handler by design (CIRISClient#69); + // say so, the same way on every platform (HTTP 409 via the action). + if (ai.ciris.mobile.shared.platform.DisabledControls.isDisabled(request.testTag)) { + return ActionResponse( + success = false, + element = request.testTag, + action = ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION, + error = ai.ciris.mobile.shared.platform.DisabledControls.refusal(request.testTag), + ) + } + if (element == null) { return ActionResponse( success = false, diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt index 383ec5a6..5048830e 100644 --- a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/platform/DisabledClickHandlerTest.kt @@ -21,7 +21,20 @@ class DisabledClickHandlerTest { private val tag = "btn_disabled_probe" @AfterTest - fun cleanup() = TestAutomation.unregisterClickHandler(tag) + fun cleanup() = releaseClickHandler(tag) + + @Test + fun aDisabledControlIsKnownAsDisabledUntilItIsEnabledOrGone() { + // So `/click` can answer "disabled" rather than "no click handler" + // (iOS, run 36733112700) — the same answer on every platform. + bindClickHandler(tag, enabled = false) {} + assertTrue(DisabledControls.isDisabled(tag)) + bindClickHandler(tag, enabled = true) {} + assertFalse(DisabledControls.isDisabled(tag), "an enabled control is not disabled") + bindClickHandler(tag, enabled = false) {} + releaseClickHandler(tag) + assertFalse(DisabledControls.isDisabled(tag), "a disposed control is not anything") + } @Test fun aDisabledControlCannotBeClicked() { diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt index 308c93e7..ebaba107 100644 --- a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/testing/MobileAutomationSurfaceTest.kt @@ -1,5 +1,6 @@ package ai.ciris.mobile.shared.testing +import ai.ciris.mobile.shared.platform.DisabledControls import ai.ciris.mobile.shared.platform.TestAutomation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -64,6 +65,7 @@ class MobileAutomationSurfaceTest { TestAutomationState.clearElements() TestAutomation.unregisterInputSink(field) TestAutomation.unregisterClickHandler(btn) + DisabledControls.mark(btn, disabled = false) } // ── /input must not claim to have typed into nothing ────────────────── @@ -231,6 +233,25 @@ class MobileAutomationSurfaceTest { } } + @Test + fun a_click_on_a_disabled_control_says_disabled_not_no_handler() = runTest { + // iOS, run 36733112700: the disabled Provision submit answered 404 "No + // click handler", which reads as a wiring defect. It is a state. + val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "ciris-main-probe") } + Dispatchers.setMain(exec.asCoroutineDispatcher()) + try { + register(btn) + DisabledControls.mark(btn, disabled = true) + val r = TestAutomationHandler.handleClick(ClickRequest(btn)) + assertFalse(r.success) + assertEquals(DisabledControls.REFUSED_ACTION, r.action, "the servers map this action to 409") + assertTrue((r.error ?: "").contains("is disabled"), r.error ?: "") + } finally { + Dispatchers.resetMain() + exec.shutdownNow() + } + } + // ── /undrivable, the pre-flight ─────────────────────────────────────── @Test diff --git a/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt b/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt index e166d3bc..fb87a9a2 100644 --- a/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt +++ b/client/shared/src/iosMain/kotlin/ai/ciris/mobile/shared/testing/TestAutomationServer.ios.kt @@ -261,7 +261,14 @@ class IOSTestAutomationServer(private val port: Int = 9091) { method == "POST" && path == "/click" -> { val req = json.decodeFromString(body) val resp = TestAutomationHandler.handleClick(req) - (if (resp.success) 200 else 404) to json.encodeToString(resp) + // 409 for a disabled control: it exists and refuses, which + // is not "not found" (desktop and Android answer the same). + val status = when { + resp.success -> 200 + resp.action == ai.ciris.mobile.shared.platform.DisabledControls.REFUSED_ACTION -> 409 + else -> 404 + } + status to json.encodeToString(resp) } method == "POST" && path == "/input" -> { val req = json.decodeFromString(body) diff --git a/testing/driver.py b/testing/driver.py index 02b4e184..fd8bde43 100644 --- a/testing/driver.py +++ b/testing/driver.py @@ -208,8 +208,11 @@ def tags(self) -> set[str]: # ---- writes ------------------------------------------------------- - def click(self, test_tag: str) -> None: - self._call("POST", "/click", {"testTag": test_tag}) + def click(self, test_tag: str) -> Any: + """Click, and return the app's answer: `action` says whether a + programmatic handler ran ("click") or desktop fell back to a + coordinate click ("mouse-click"), which `click_refused` tells apart.""" + return self._call("POST", "/click", {"testTag": test_tag}) def input(self, test_tag: str, text: str, clear_first: bool = True, verify: bool = True) -> None: diff --git a/testing/flows/README.md b/testing/flows/README.md index b6816ca1..47dfefa5 100644 --- a/testing/flows/README.md +++ b/testing/flows/README.md @@ -27,7 +27,7 @@ steps: title: Signing in on a bare node lands on People requires: # checked BEFORE the step; the first step's is the entry screen: Contacts - do: # click / input / scroll_to / wait (one per entry) + do: # click / click_refused / input / scroll_to / wait (one per entry) - wait: card_contacts_add wait_ms: 5000 # a `wait` waits wait_ms × 4 expect: # checked AFTER @@ -44,7 +44,9 @@ steps: The language is `testing/gate/flow_spec.py`'s — vendored from CIRISAgent, with the CSD/3 §3 predicates (`count`, `number`, `matches`, `one_of`, `each`, -`relation`, `state`) and one local addition, `csd:` (see +`relation`, `state`) and local additions — `csd:`, `cleanup:` with `when:`, and +`click_refused:` for a control the step says is disabled, which holds when the +platform refuses the click and fails when a handler runs (see `testing/gate/VENDORED.md`). Unknown keys anywhere are a load error. ### How a flow is tied to its CSD diff --git a/testing/flows/csd-068-provision-accord-holder.yaml b/testing/flows/csd-068-provision-accord-holder.yaml index c306caf1..0581f5e0 100644 --- a/testing/flows/csd-068-provision-accord-holder.yaml +++ b/testing/flows/csd-068-provision-accord-holder.yaml @@ -33,12 +33,13 @@ steps: - step_id: empty_submit_does_nothing title: Submit with nothing filled in produces no banner at all description: >- - The button is disabled, so the click is swallowed by design. The assertion - is that NEITHER banner appears — a disabled control that still fires its - handler is CIRISClient#70's shape, and #92 added a disabled-click probe for - exactly this class. + The button is disabled, so the click is refused by design: `click_refused` + holds when the platform refuses it or no handler answers, and fails if the + handler runs. The assertion is also that NEITHER banner appears — a + disabled control that still fires its handler is CIRISClient#70's shape, + and #92 added a disabled-click probe for exactly this class. do: - - click: btn_provision_holder_submit + - click_refused: btn_provision_holder_submit expect: screen: ProvisionAccordHolder absent: [provision_holder_success, provision_holder_error] diff --git a/testing/gate/VENDORED.md b/testing/gate/VENDORED.md index 64bf56ae..4a664ef6 100644 --- a/testing/gate/VENDORED.md +++ b/testing/gate/VENDORED.md @@ -81,6 +81,7 @@ Added so CSD flows can run on this repo's matrix (`testing/flows/`, | `FlowRunner(state_tags=…)`; `run()` fills both maps from `spec.csd` when the caller did not | one source for the maps: the CSD | | `write_report` records `csd` | a result that cannot say what it tested cannot be acted on | | `cleanup` added to `_FLOW_KEYS`; `FlowSpec.cleanup` (a list of actions); `FlowRunner.run()` runs them in a `finally`, pass, fail or crash, and records what could not be done (`cleanup_failures`, in the report and the outcome's detail) | a flow stops at its first failed step, and whatever that step left open is the NEXT flow's failure. On 2026-09-29 `csd_092` opened the contact-code card (which replaces People's body, and whose open state lives in the view model), failed on step two, and `people`, `csd_005` and `csd_006` failed for its reason on every desktop leg. Only the flow knows what it opened; the runner guarantees the closing runs. A cleanup that fails is said, never the verdict — the verdict is the flow's | +| `click_refused` added to `_ACTION_KEYS` (`_VERBS`); `FlowRunner._do` calls the helper's `click_refused` | a step whose claim is "clicking this disabled control does nothing" had only `click:`, which calls a refused click a failure. iOS (run 36733112700, csd_068 `empty_submit_does_nothing`) refused the disabled Provision submit and the step failed for the outcome it asserts. `click_refused` holds when the platform refuses the click or no handler answers it, and fails when a handler runs (CIRISClient#69); the step's `absent:` judges the effect | A flow without `csd:` still loads and runs exactly as before; `run_flows.py` is what requires the key for flows in this repo. Upstream needs the same key diff --git a/testing/gate/flow_helper.py b/testing/gate/flow_helper.py index e36a80ae..a613cd99 100644 --- a/testing/gate/flow_helper.py +++ b/testing/gate/flow_helper.py @@ -47,6 +47,10 @@ _SCROLL_STEPS = 24 #: The answers `/scroll` gives when there is nowhere further to go. _SCROLL_END = ("already at the", "NO overflow", "can scroll") +#: How a platform refuses a click on a DISABLED control. From 0.5.225 every +#: platform says "is disabled" (409); before that iOS and Android answered +#: "No click handler" (404), because a disabled `testableClickable` has none. +_REFUSED_AS_DISABLED = ("is disabled", "No click handler") @dataclass @@ -169,6 +173,36 @@ async def is_element_visible(self, tag: str) -> bool: async def click(self, tag: str, timeout: int = 2000) -> bool: return self._reach(tag, lambda: self._drv.click(tag)) + async def click_refused(self, tag: str, timeout: int = 2000) -> bool: + """Click a control the flow says is DISABLED, and hold only if nothing + ran behind it: the platform refused the click, or (desktop before + 0.5.225) fell back to a coordinate click that no handler answered — + the step's `absent:` then judges whether anything happened. A + programmatic handler that ran is CIRISClient#69's shape and fails; + so does a control that is not there at all (that is not a refusal, + it is a missing control).""" + seen: dict = {} + + def act() -> None: + try: + seen["answer"] = self._drv.click(tag) + except DriverError as e: + if any(word in str(e) for word in _REFUSED_AS_DISABLED): + seen["refused"] = str(e) + return + raise + + if not self._reach(tag, act): + return False + if "refused" in seen: + return True + answer = seen.get("answer") + if isinstance(answer, dict) and answer.get("action") == "mouse-click": + return True + self.last_error = (f"{tag} accepted the click and its handler ran: a disabled control " + f"that still fires is CIRISClient#69's shape") + return False + async def input_text(self, tag: str, text: str) -> bool: return self._reach(tag, lambda: self._drv.input(tag, text)) diff --git a/testing/gate/flow_spec.py b/testing/gate/flow_spec.py index 41f5c4c5..c5a6edae 100644 --- a/testing/gate/flow_spec.py +++ b/testing/gate/flow_spec.py @@ -58,7 +58,14 @@ "screen", "visible", "absent", "text", "count", "number", "matches", "one_of", "each", "relation", "state", } -_ACTION_KEYS = {"click", "input", "scroll_to", "wait", "wait_ms", "when"} +_ACTION_KEYS = {"click", "click_refused", "input", "scroll_to", "wait", "wait_ms", "when"} +#: The verbs, one per action. LOCAL DELTA: `click_refused: TAG` clicks a control +#: the step says is DISABLED and holds only when nothing ran behind it — the +#: platform refused the click, or no handler answered it. A handler that fires +#: fails the action (CIRISClient#69); the step's `absent:` judges the effect. +#: iOS, run 36733112700: a plain `click:` called the refusal of a disabled +#: submit a failure, on the step whose claim IS that the click does nothing. +_VERBS = ("click", "click_refused", "input", "scroll_to", "wait") #: 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", "fixture", "cleanup"} @@ -195,7 +202,7 @@ def describe(self) -> str: @dataclass class Action: - """One interaction. Exactly one of click/input/scroll_to/wait per entry.""" + """One interaction. Exactly one of click/click_refused/input/scroll_to/wait per entry.""" kind: str target: str @@ -224,10 +231,10 @@ def parse(cls, raw: Any, where: str, *, cleanup: bool = False) -> "Action": raise SpecError(f"{where}: `when:` is for `cleanup:` actions only") if when is not None and (not isinstance(when, str) or not when.strip()): raise SpecError(f"{where}: `when:` names one tag") - verbs = [k for k in ("click", "input", "scroll_to", "wait") if k in raw] + verbs = [k for k in _VERBS if k in raw] if len(verbs) != 1: raise SpecError( - f"{where}: exactly one of click/input/scroll_to/wait per action, got {verbs or 'none'}" + f"{where}: exactly one of {'/'.join(_VERBS)} per action, got {verbs or 'none'}" ) verb = verbs[0] if verb == "input": @@ -349,7 +356,7 @@ def load(cls, path: Path, csd_root: Optional[Path] = None) -> "FlowSpec": spec.fixture = fixture cleanup_raw = raw.get("cleanup") or [] if not isinstance(cleanup_raw, list): - raise SpecError(f"{path}: `cleanup` is a list of actions (click / input / scroll_to / wait)") + raise SpecError(f"{path}: `cleanup` is a list of actions ({' / '.join(_VERBS)})") spec.cleanup = [Action.parse(a, f"{path}: cleanup[{i}]", cleanup=True) for i, a in enumerate(cleanup_raw)] # 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 @@ -819,6 +826,10 @@ async def _do(self, action: Action) -> Optional[str]: if action.kind == "click": ok = await self.helper.click(action.target, timeout=action.wait_ms * 4) return None if ok else f"click {action.target!r} did not succeed{self._why()}" + if action.kind == "click_refused": + ok = await self.helper.click_refused(action.target, timeout=action.wait_ms * 4) + return None if ok else (f"click_refused {action.target!r}: the disabled control " + f"did not refuse{self._why()}") if action.kind == "input": ok = await self.helper.input_text(action.target, action.value or "") return None if ok else f"input into {action.target!r} did not succeed{self._why()}" @@ -895,7 +906,7 @@ async def _cleanup(self, spec: FlowSpec) -> None: if action.when is not None and not await self._composed(action.when, settle=changed): print(f" cleanup: nothing to close \u2014 {action.when!r} is not on screen") continue - if action.kind in ("click", "input") and not await self._composed(action.target, settle=changed): + if action.kind in ("click", "click_refused", "input") and not await self._composed(action.target, settle=changed): print(f" cleanup: nothing to close \u2014 {action.target!r} is not on screen") continue changed = True diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index 78a91b00..1b9d1bcd 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -137,6 +137,11 @@ def _field_report(drv: TestAutomationServer) -> str: return "; ".join(sorted(parts)) +#: How a platform refuses a click on a disabled control: every platform from +#: 0.5.225 (DisabledControls, HTTP 409), and iOS/Android before it (HTTP 404). +DISABLED_ANSWERS = ("is disabled", "No click handler") + + #: How long a wizard step may take to show the next one after Next. Windows #: (run 36588619656) went blank for more than the 2 s the fixture used to #: sleep — the fed-ID mint on Next and the next step's first composition on a @@ -329,8 +334,9 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, before = (drv.screen(), _active_step(drv)) # iOS's /tree omits `canClick` when it is false (defaults are not # serialized), so `_wait_clickable` cannot see a disabled Next there. - # A disabled control answers the click with 404 "No click handler": - # treat that as "not yet", bounded, and name the step if it stays so. + # A disabled control refuses the click — 409 "is disabled" from 0.5.225 + # on every platform, 404 "No click handler" on older mobile clients: + # treat either as "not yet", bounded, and name the step if it stays so. clicked = False for _ in range(15): try: @@ -338,7 +344,7 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, clicked = True break except DriverError as e: - if "No click handler" not in str(e): + if not any(w in str(e) for w in DISABLED_ANSWERS): raise time.sleep(2.0) if not clicked: diff --git a/testing/test_flow_helper.py b/testing/test_flow_helper.py index 702e27f2..13d7500b 100644 --- a/testing/test_flow_helper.py +++ b/testing/test_flow_helper.py @@ -20,7 +20,10 @@ class _Drv: """A driver whose target sits `off_screen_until` scrolls below the fold.""" - def __init__(self, off_screen_until: int = 0, refuse: str = "", bottom_after: int = 99): + def __init__(self, off_screen_until: int = 0, refuse: str = "", bottom_after: int = 99, + response=None): + #: What an ACCEPTED /click answers (the driver returns the body). + self.response = response if response is not None else {"success": True, "action": "click"} self.scrolls: list = [] self.clicks: list = [] self.inputs: list = [] @@ -36,6 +39,7 @@ def _gate(self, verb, tag): def click(self, tag): self.clicks.append(tag) self._gate("click", tag) + return self.response def input(self, tag, text): self.inputs.append((tag, text)) @@ -78,6 +82,61 @@ def test_a_refusals_reason_is_kept_for_the_verdict(): assert not d.scrolls, "a refusal that is not about the fold is not answered by scrolling" +# ── click_refused: a disabled control's click does nothing ──────────────── +# iOS, run 36733112700, csd_068 `empty_submit_does_nothing`: a disabled +# Provision submit answered /click with a refusal (1dcf0b0c), the driver +# raised, and the step whose claim IS "clicking this does nothing" failed. + + +def test_a_click_refused_as_disabled_is_the_outcome_click_refused_asks_for(): + d = _Drv(refuse="POST /click -> HTTP 409: btn_submit is disabled: it refuses /click, " + "as it refuses a press") + h = SyncFlowHelper(d) + assert asyncio.run(h.click_refused("btn_submit")) is True + assert d.clicks == ["btn_submit"] + + +def test_an_older_mobile_clients_no_click_handler_is_a_refusal_too(): + d = _Drv(refuse="POST /click -> HTTP 404: No click handler for: btn_submit") + assert asyncio.run(SyncFlowHelper(d).click_refused("btn_submit")) is True + + +def test_an_older_desktops_coordinate_click_is_accepted_and_judged_by_the_expect(): + """Desktop before 0.5.225 fell back to a mouse click on a control with no + handler; Compose ignores it on a disabled button. No handler ran, so the + action holds and the step's `absent:` judges the effect.""" + d = _Drv(response={"success": True, "action": "mouse-click", + "error": "no programmatic handler for btn_submit; used a coordinate click"}) + assert asyncio.run(SyncFlowHelper(d).click_refused("btn_submit")) is True + + +def test_a_handler_that_fires_behind_a_disabled_control_fails_click_refused(): + d = _Drv(response={"success": True, "action": "click"}) + h = SyncFlowHelper(d) + assert asyncio.run(h.click_refused("btn_submit")) is False + assert "handler ran" in h.last_error and "#69" in h.last_error + + +def test_click_refused_on_a_missing_control_is_a_failure_not_a_refusal(): + d = _Drv(refuse="POST /click -> HTTP 404: Element not found: btn_submit; on screen: []") + h = SyncFlowHelper(d) + assert asyncio.run(h.click_refused("btn_submit")) is False + assert "Element not found" in h.last_error + + +def test_click_refused_scrolls_an_off_screen_control_first(): + d = _Drv(off_screen_until=2, response={"success": True, "action": "click"}) + d.refuse_after_scroll = True + real_gate = d._gate + + def gate(verb, tag): + real_gate(verb, tag) + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled") + d._gate = gate + assert asyncio.run(SyncFlowHelper(d).click_refused("btn_submit")) is True + assert len(d.scrolls) == 2 + + def test_scrolling_is_bounded_and_the_bottom_is_reported(): d = _Drv(off_screen_until=10 ** 6, bottom_after=3) h = SyncFlowHelper(d) diff --git a/testing/test_flows.py b/testing/test_flows.py index 7b0ca17b..5441ff5e 100644 --- a/testing/test_flows.py +++ b/testing/test_flows.py @@ -366,6 +366,16 @@ async def click(self, tag, timeout=2000): self.els = {t: _El(t, "") for t in shown} return True + async def click_refused(self, tag, timeout=2000): + self.calls.append(f"click_refused {tag}") + if tag not in self.els: + self.last_error = f"no such element {tag!r}" + return False + if tag not in self.refuse: + self.last_error = f"{tag} accepted the click and its handler ran" + return False + return True + async def input_text(self, tag, text): self.calls.append(f"input {tag}") return tag in self.els @@ -573,6 +583,41 @@ def test_a_failed_action_carries_the_drivers_reason(tmp_path): assert "no such element 'btn_gone'" in out.detail, out.detail +REFUSED = GOOD.replace(" expect:\n", " do:\n - click_refused: btn_submit\n expect:\n", 1) + + +def test_click_refused_parses_as_its_own_verb(tmp_path): + spec = _spec(tmp_path, REFUSED) + act = spec.steps[0].do[0] + assert (act.kind, act.target) == ("click_refused", "btn_submit") + assert act.describe() == "click_refused 'btn_submit'" + + +def test_click_refused_is_one_verb_among_the_others(tmp_path): + body = GOOD.replace(" expect:\n", + " do:\n - {click_refused: btn_submit, click: btn_submit}\n expect:\n", 1) + with pytest.raises(SpecError, match="exactly one of"): + _spec(tmp_path, body) + + +def test_a_refused_click_passes_the_step_that_claims_it_does_nothing(tmp_path): + """csd_068 `empty_submit_does_nothing` on iOS, run 36733112700: the + platform refused the disabled submit, and a plain `click:` called that a + failure. The refusal is the outcome the step claims.""" + h = FakeHelper("Thing", {"thing_list": "", "btn_submit": ""}) + h.refuse = {"btn_submit"} + out = _run(_spec(tmp_path, REFUSED), h) + assert out.status == run_flows.PASS, out.detail + assert "click_refused btn_submit" in h.calls + + +def test_a_disabled_control_whose_handler_fires_fails_click_refused(tmp_path): + h = FakeHelper("Thing", {"thing_list": "", "btn_submit": ""}) + out = _run(_spec(tmp_path, REFUSED), h) + assert out.status == run_flows.FAIL + assert "handler ran" in out.detail, out.detail + + def test_a_failing_expect_fails_the_flow_and_the_leg(tmp_path): out = _run(_spec(tmp_path), FakeHelper("Thing", {"something_else": ""})) assert out.status == run_flows.FAIL diff --git a/testing/test_platform_automation_wiring.py b/testing/test_platform_automation_wiring.py index ecd7e86c..739dba78 100644 --- a/testing/test_platform_automation_wiring.py +++ b/testing/test_platform_automation_wiring.py @@ -214,10 +214,12 @@ def test_every_csd_surface_is_reachable(): def _flow_click_targets() -> set[str]: - """Every literal tag a shipped flow clicks (`${...}` tags cannot be grepped).""" + """Every literal tag a shipped flow clicks (`${...}` tags cannot be grepped), + including `click_refused:` — the control a flow asserts is disabled is the + one whose handler most needs to follow `enabled`.""" tags: set[str] = set() for flow in FLOWS_DIR.glob("*.yaml"): - for tag in re.findall(r"^\s*-\s*click:\s*\"?([A-Za-z0-9_]+)\"?\s*$", flow.read_text(encoding="utf-8"), re.M): + for tag in re.findall(r"^\s*-\s*click(?:_refused)?:\s*\"?([A-Za-z0-9_]+)\"?\s*$", flow.read_text(encoding="utf-8"), re.M): tags.add(tag) return tags diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py index fe8a71a8..e22880a2 100644 --- a/testing/test_session_fixture.py +++ b/testing/test_session_fixture.py @@ -46,6 +46,7 @@ def __init__(self, clock: _Clock, advance_after: float, claim_takes: float = 0.0 # seconds (step indicators up, no step active, no advance control), # then claimed — or the error panel, which never returns to Login. self.claim_takes, self.claim_error = claim_takes, claim_error + self.refuse_next = 0 self.on = "Login" self.step = "you" self.next_at: float | None = None @@ -101,6 +102,11 @@ def click(self, tag): self.clicks.append(tag) if tag not in {e.test_tag for e in self.tree()}: raise DriverError(f"POST /click -> HTTP 404: No click handler for {tag!r}") + if tag == "btn_next" and self.refuse_next > 0: + # Next still disabled (iOS: the fields reach the ViewModel late), + # answered the way every platform answers from 0.5.225. + self.refuse_next -= 1 + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled: it refuses /click") if tag == "btn_local_login": self.on = "Setup" elif tag == "trace_consent_yes": @@ -170,6 +176,16 @@ def test_a_refused_claim_is_reported_with_its_reason(monkeypatch): assert clock.t < 60, "a refused claim is reported when it is refused, not at the timeout" +def test_a_next_that_answers_disabled_is_waited_for_like_one_with_no_handler(monkeypatch): + """From 0.5.225 a disabled control answers "is disabled" (409) on every + platform, not "No click handler"; the fixture's wait for a late-enabling + Next must read both.""" + clock, w = _drive(monkeypatch, advance_after=1.0) + w.refuse_next = 3 + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.screen() == "Login" + + class _OwnedLogin: """A client that has judged the node OWNED: `btn_local_login` reveals the login FORM (LoginScreen: `if (isFirstRun) onLocalLogin() else From ac05851371ec29386162455ce7139bdbe48a4d69 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 11:35:06 -0500 Subject: [PATCH 20/27] fix(client): a disabled button is disabled to /click, and a click waits for the frame that enables it iOS, run 36733112700, csd_005 `a_node_code_is_refused_by_name`: the flow typed a node code and clicked Add; the click answered success, no request was made (no `[addContact] POST` in the app log) and no refusal composed. The field held the code in the screenshot. `CirisButton` registered its handler unconditionally as `{ if (enabled) onClick() }`, and the click landed in the frame before the typed key recomposed the button: the handler still held `enabled = false`, did nothing, and reported a click. Not an input-to-ViewModel lag the runner could see: `/input` had verified the field's value. Client: `testableWithHandler` takes `enabled` and binds the handler to it, as `testableClickable` does (bindClickHandler; a disabled control answers 409 "is disabled"). CirisButton, CirisTextButton and the four screen controls that guarded inside the handler pass it. One positional caller now names onClick. Runner: a plain `click:` refused as disabled is retried until the control enables, bounded by ENABLE_SETTLE_S (5 s), then fails with the reason. It is the frame after an input, waited for like `expect:`'s, not a padded flow. Pinned: test_platform_automation_wiring refuses a handler guarded by `{ if (...) }` without a bound `enabled` (red on six call sites); test_flow_helper covers the retry and its bound (red before). Vendoring digest re-recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../mobile/shared/platform/TestAutomation.kt | 21 ++++++-- .../mobile/shared/ui/primitives/Controls.kt | 4 +- .../mobile/shared/ui/screens/ChatScreen.kt | 4 +- .../shared/ui/screens/ContactsScreen.kt | 2 +- .../shared/ui/screens/SettingsScreen.kt | 2 +- .../shared/ui/screens/TrustRootScreen.kt | 2 +- testing/gate/flow_helper.py | 20 +++++++- testing/test_flow_helper.py | 49 +++++++++++++++++++ testing/test_platform_automation_wiring.py | 42 ++++++++++++++++ 10 files changed, 135 insertions(+), 13 deletions(-) diff --git a/client/VENDORING.md b/client/VENDORING.md index 220b73ae..4a6c1301 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `6f86e57207e12c116fa30a2b7929ce3b8afb80e76b54e3c3ec084fa873c8c010` +**state digest:** `78182e163baeab21c73798836b481a06df1c9b0fdbd0191ecdb09101b01ed13b` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt index 9d0b319c..8e476195 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/platform/TestAutomation.kt @@ -316,18 +316,31 @@ fun Modifier.testableClickable( * Track an element and register a handler WITHOUT adding `clickable`. * * For components that already handle their own clicks (Button, DropdownMenuItem) - * — adding another `clickable` would give them two. + * — adding another `clickable` would give them two. Pass the component's + * `enabled`, so a disabled one is disabled to `/click` too. */ -fun Modifier.testableWithHandler(tag: String, onClick: () -> Unit): Modifier = composed { +fun Modifier.testableWithHandler( + tag: String, + enabled: Boolean = true, + onClick: () -> Unit, +): Modifier = composed { if (!TestAutomation.isEnabled()) return@composed this.testTag(tag) val currentOnClick by rememberUpdatedState(onClick) DisposableEffect(tag) { - TestAutomation.registerClickHandler(tag) { currentOnClick() } onDispose { - TestAutomation.unregisterClickHandler(tag) + releaseClickHandler(tag) TestAutomation.unregisterElement(tag) } } + // [enabled] is the component's own `enabled`, bound as in + // [testableClickable]. A guard INSIDE the handler (`{ if (enabled) … }`) + // answered /click with success while doing nothing: iOS, run 36733112700, + // csd_005 clicked Add in the frame before the typed key enabled it, and + // nothing was sent and nothing said so. + DisposableEffect(tag, enabled) { + bindClickHandler(tag, enabled) { currentOnClick() } + onDispose { releaseClickHandler(tag) } + } this.testTag(tag).trackPosition(tag, null) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt index 48ecc959..022ab390 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/Controls.kt @@ -51,7 +51,7 @@ fun CirisButton( disabledContentColor = t.mute, ), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), - modifier = modifier.testableWithHandler(tag) { if (enabled) onClick() }, + modifier = modifier.testableWithHandler(tag, enabled = enabled) { if (enabled) onClick() }, ) { Text(label, style = CirisTheme.type.body) } @@ -75,7 +75,7 @@ fun CirisTextButton( contentColor = if (danger) t.danger else t.brand, disabledContentColor = t.mute, ), - modifier = modifier.testableWithHandler(tag) { if (enabled) onClick() }, + modifier = modifier.testableWithHandler(tag, enabled = enabled) { if (enabled) onClick() }, ) { Text(label, style = CirisTheme.type.body) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt index 42c83d80..a37573eb 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ChatScreen.kt @@ -195,7 +195,7 @@ fun ChatScreen( // finish first, letting the OLDER response overwrite the // newer transcript. The handler re-checks what enabled // gates. - modifier = Modifier.testableWithHandler("btn_chat_refresh") { + modifier = Modifier.testableWithHandler("btn_chat_refresh", enabled = !loading) { if (!loading) viewModel.refresh() }, ) { @@ -331,7 +331,7 @@ fun ChatScreen( Button( onClick = { viewModel.send() }, enabled = canSend, - modifier = Modifier.testableWithHandler("btn_chat_send") { + modifier = Modifier.testableWithHandler("btn_chat_send", enabled = canSend) { if (canSend) viewModel.send() }, ) { diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt index fba82ca9..47a5234a 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/ContactsScreen.kt @@ -201,7 +201,7 @@ fun ContactsScreen( IconButton( onClick = { viewModel.refresh() }, enabled = !loading, - modifier = Modifier.testableWithHandler(PeopleTags.REFRESH) { if (!loading) viewModel.refresh() }, + modifier = Modifier.testableWithHandler(PeopleTags.REFRESH, enabled = !loading) { if (!loading) viewModel.refresh() }, ) { Glyph(GlyphName.REFRESH, tint = if (loading) t.mute else t.dim, contentDescription = localizedString("common_refresh")) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt index 430ff99b..c8dc3b19 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SettingsScreen.kt @@ -2183,7 +2183,7 @@ private fun PreferencesSection() { ) } }, - modifier = Modifier.testableWithHandler("language_${language.code}", onSelectLanguage) + modifier = Modifier.testableWithHandler("language_${language.code}", onClick = onSelectLanguage) ) } } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt index eac71b93..a4945e29 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/TrustRootScreen.kt @@ -125,7 +125,7 @@ fun TrustRootScreen( IconButton( onClick = { viewModel.refresh() }, enabled = !busy, - modifier = Modifier.testableWithHandler("btn_trust_root_refresh") { if (!busy) viewModel.refresh() }, + modifier = Modifier.testableWithHandler("btn_trust_root_refresh", enabled = !busy) { if (!busy) viewModel.refresh() }, ) { Glyph(GlyphName.REFRESH, tint = if (busy) t.mute else t.dim, contentDescription = localizedString("common_refresh")) } diff --git a/testing/gate/flow_helper.py b/testing/gate/flow_helper.py index a613cd99..03cd2f88 100644 --- a/testing/gate/flow_helper.py +++ b/testing/gate/flow_helper.py @@ -37,6 +37,7 @@ from __future__ import annotations +import time from dataclasses import dataclass from typing import List, Optional @@ -47,6 +48,14 @@ _SCROLL_STEPS = 24 #: The answers `/scroll` gives when there is nowhere further to go. _SCROLL_END = ("already at the", "NO overflow", "can scroll") +#: How long a plain `click:` waits for a control refused as disabled to enable. +#: `/input` returns before the frame that applies it, so a submit clicked right +#: after typing can still be disabled: iOS, run 36733112700, csd_005 clicked Add +#: in that frame, and (before the handler followed `enabled`) nothing was sent +#: and nothing said so. A control that stays disabled this long IS disabled, +#: and the click fails with that reason. +ENABLE_SETTLE_S = 5.0 + #: How a platform refuses a click on a DISABLED control. From 0.5.225 every #: platform says "is disabled" (409); before that iOS and Android answered #: "No click handler" (404), because a disabled `testableClickable` has none. @@ -171,7 +180,16 @@ async def is_element_visible(self, tag: str) -> bool: # ---- actions ------------------------------------------------------------ async def click(self, tag: str, timeout: int = 2000) -> bool: - return self._reach(tag, lambda: self._drv.click(tag)) + """Click `tag`. Refused as disabled, it is retried until the control + enables or ENABLE_SETTLE_S runs out — the frame after an input, not a + padded flow. Every other refusal is final, as before.""" + deadline = time.monotonic() + ENABLE_SETTLE_S + while True: + if self._reach(tag, lambda: self._drv.click(tag)): + return True + if "is disabled" not in self.last_error or time.monotonic() >= deadline: + return False + time.sleep(0.25) async def click_refused(self, tag: str, timeout: int = 2000) -> bool: """Click a control the flow says is DISABLED, and hold only if nothing diff --git a/testing/test_flow_helper.py b/testing/test_flow_helper.py index 13d7500b..49fb29d3 100644 --- a/testing/test_flow_helper.py +++ b/testing/test_flow_helper.py @@ -137,6 +137,55 @@ def gate(verb, tag): assert len(d.scrolls) == 2 +# ── A click refused as disabled waits for the frame, bounded ───────────── +# iOS, run 36733112700, csd_005: the Add submit was clicked before the frame +# that applied the typed key enabled it. With the handler bound to `enabled` +# the click is refused as "is disabled"; the helper waits for the control to +# enable, as `expect:` waits for its frame, and fails if it never does. + + +class _FakeClock: + def __init__(self): + self.t = 0.0 + + def monotonic(self): + return self.t + + def sleep(self, s): + self.t += s + + +def _disabled_for(n: int) -> _Drv: + d = _Drv() + d.disabled_left = n + + def gate(verb, tag): + if d.disabled_left > 0: + d.disabled_left -= 1 + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled: it refuses /click") + d._gate = gate + return d + + +def test_a_click_refused_as_disabled_is_retried_until_the_control_enables(monkeypatch): + from testing.gate import flow_helper + monkeypatch.setattr(flow_helper, "time", _FakeClock()) + d = _disabled_for(3) + assert asyncio.run(SyncFlowHelper(d).click("btn_contacts_add_submit")) is True + assert d.clicks.count("btn_contacts_add_submit") == 4 + + +def test_a_control_that_never_enables_fails_with_its_reason_and_is_bounded(monkeypatch): + from testing.gate import flow_helper + clock = _FakeClock() + monkeypatch.setattr(flow_helper, "time", clock) + d = _disabled_for(10 ** 6) + h = SyncFlowHelper(d) + assert asyncio.run(h.click("btn_contacts_add_submit")) is False + assert "is disabled" in h.last_error + assert clock.t <= flow_helper.ENABLE_SETTLE_S + 1, "bounded" + + def test_scrolling_is_bounded_and_the_bottom_is_reported(): d = _Drv(off_screen_until=10 ** 6, bottom_after=3) h = SyncFlowHelper(d) diff --git a/testing/test_platform_automation_wiring.py b/testing/test_platform_automation_wiring.py index 739dba78..a0cb738c 100644 --- a/testing/test_platform_automation_wiring.py +++ b/testing/test_platform_automation_wiring.py @@ -291,6 +291,48 @@ def test_a_button_a_flow_clicks_disables_its_click_handler_with_itself(): ) +# ── A disabled control's handler is REMOVED, not silenced ───────────────────── +# +# iOS, run 36733112700, csd_005 `a_node_code_is_refused_by_name`: the flow typed +# a node code and clicked Add. The click answered success, no request was ever +# made (no `[addContact] POST` in the app log), and no refusal composed. +# `CirisButton` registered its handler unconditionally as +# `{ if (enabled) onClick() }`: the click landed before the frame that applied +# the typed key, the handler still held `enabled = false`, did nothing, and +# said it had clicked. A guard INSIDE the handler turns "disabled" into a silent +# success; the handler has to follow `enabled` (bindClickHandler) so `/tree` +# says canClick=false and `/click` says "is disabled". + +_SILENT_GUARD = re.compile(r"testableWithHandler\(([^)]*)\)\s*\{\s*if\s*\(") + + +def _handlers_that_silently_ignore_a_click() -> list[str]: + found = [] + for path in sorted(COMMON.rglob("*.kt")): + src = path.read_text(encoding="utf-8") + for m in _SILENT_GUARD.finditer(src): + if "enabled" in m.group(1): + continue # the guard is belt-and-braces behind a bound handler + found.append(f"{path.relative_to(SHARED)}:{src[:m.start()].count(chr(10)) + 1}") + return found + + +def test_the_silent_guard_probe_sees_the_construct(): + """Red path: the probe finds the shape it exists to find.""" + src = 'Modifier.testableWithHandler(tag) { if (enabled) onClick() }' + assert _SILENT_GUARD.search(src) + assert not _SILENT_GUARD.search('Modifier.testableWithHandler(tag, enabled = enabled) { onClick() }') + + +def test_no_click_handler_silently_ignores_a_click_while_disabled(): + offenders = _handlers_that_silently_ignore_a_click() + assert not offenders, ( + "a handler guarded by `{ if (...) }` answers /click with success while " + "doing nothing; pass `enabled =` to testableWithHandler instead: " + + ", ".join(offenders) + ) + + # ── A node that ANSWERS is not a node that is OWNED (Android leg) ──────────── # # Run 36600766576: the Android leg's node was fresh (the desktop leg's seeded From 94d0b0ccf7b67e919babb29818576b8d0be853ac Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 12:32:11 -0500 Subject: [PATCH 21/27] fix(gate): a failed claim names its reason, on the screen and in the fixture Android, run 36746575125: the first-run claim failed and the session fixture said "the node refused the ownership claim: (no reason on screen)" while its own tag list included failure_panel_detail. Two halves of one defect: - FailurePanel registered its title, guidance and detail on /tree WITHOUT text (testable(tag) carries text only when handed one), and SetupScreen's setup_ownership_error container carried none either. The reason was drawn, never readable by a driver. Pinned by FailurePanelAutomationTextTest, which renders the panel headless and reads back what automation sees. - The fixture read only the container. It now reads the container, the panel title and the detail; and when none carries text it says the screen registered none and prints the field report, instead of implying the node gave no reason. It also stops saying "the node refused": in run four the node never received a claim at all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../shared/ui/components/FailurePanel.kt | 40 ++++++----- .../mobile/shared/ui/screens/SetupScreen.kt | 2 +- .../FailurePanelAutomationTextTest.kt | 68 +++++++++++++++++++ testing/gate/session_fixture.py | 23 ++++++- testing/test_session_fixture.py | 45 +++++++++++- 6 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanelAutomationTextTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index ab4b1c1a..177306fd 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `4e2fee3752198a517cd0c06199340b3092e22d04b3dc117d42e26c8eabb8b4aa` +**state digest:** `e9e9a6f5e75cc2d5810b70212695929a79b9984847838007f14ef488b973fdba` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt index 436c7cdd..b260f303 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanel.kt @@ -130,27 +130,33 @@ fun FailurePanel( fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.testable("failure_panel_title"), + // THE TEXTS ARE REGISTERED, NOT ONLY DRAWN. `testable(tag)` puts a + // tag on /tree with no text unless it is handed one, so a driver saw + // this panel and could not read it: Android, run 36746575125, the + // session fixture reported "(no reason on screen)" over a panel + // that said "claim PIN not captured". FailurePanelAutomationTextTest. + modifier = Modifier.testable("failure_panel_title", text = title), ) Spacer(Modifier.height(10.dp)) + val guidance = when (kind) { + FailureKind.Timeout -> + "This is taking longer than expected. It may still finish — you can keep " + + "waiting, or restart the app. If it never completes, please open an " + + "issue so we can fix it." + FailureKind.Recoverable -> + "This didn't work just now, but it should work shortly — nothing is " + + "broken and nothing needs reinstalling. Try again in a moment. If it " + + "keeps failing, please open an issue so we can fix it." + FailureKind.Unrecoverable -> + "We're sorry — this error is not recoverable by retrying. Please open an " + + "issue on GitHub so we can fix it, or wipe and reinstall the app to " + + "start over." + } Text( - text = when (kind) { - FailureKind.Timeout -> - "This is taking longer than expected. It may still finish — you can keep " + - "waiting, or restart the app. If it never completes, please open an " + - "issue so we can fix it." - FailureKind.Recoverable -> - "This didn't work just now, but it should work shortly — nothing is " + - "broken and nothing needs reinstalling. Try again in a moment. If it " + - "keeps failing, please open an issue so we can fix it." - FailureKind.Unrecoverable -> - "We're sorry — this error is not recoverable by retrying. Please open an " + - "issue on GitHub so we can fix it, or wipe and reinstall the app to " + - "start over." - }, + text = guidance, fontSize = 15.sp, color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.testable("failure_panel_guidance"), + modifier = Modifier.testable("failure_panel_guidance", text = guidance), ) Spacer(Modifier.height(14.dp)) @@ -183,7 +189,7 @@ fun FailurePanel( modifier = Modifier .padding(8.dp) .testableVerticalScroll() - .testable("failure_panel_detail"), + .testable("failure_panel_detail", text = detail), ) } diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt index 287e0716..3ac8bfe8 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/screens/SetupScreen.kt @@ -3368,7 +3368,7 @@ private fun CompleteStep( }, context = "first-run claim", onRetry = if (ownershipClaim.errorRecoverable) onRetryClaim else null, - modifier = Modifier.testable("setup_ownership_error"), + modifier = Modifier.testable("setup_ownership_error", text = ownershipClaim.error), ) if (onFinishUnclaimed != null) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanelAutomationTextTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanelAutomationTextTest.kt new file mode 100644 index 00000000..834f2617 --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/components/FailurePanelAutomationTextTest.kt @@ -0,0 +1,68 @@ +package ai.ciris.mobile.shared.ui.components + +import ai.ciris.mobile.shared.platform.TestAutomation +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.use +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A failure a driver cannot read is a failure it reports as silence. + * + * Android, run 36746575125: the first-run claim failed with "claim PIN not + * captured", the panel said so on screen, and `/tree` carried the panel's tags + * with NO text — `testable(tag)` registers text only when it is handed one. The + * session fixture then reported "(no reason on screen)" beside a tag list that + * included `failure_panel_detail`, and the cause took a logcat dig to find. + * + * Rendered headless and read back through the same registration a harness sees. + */ +class FailurePanelAutomationTextTest { + + private val registered = mutableMapOf() + + private fun armAutomation() = TestAutomation.configure( + onRegister = { tag, _, _, _, _, text -> registered[tag] = text }, + onUnregister = { registered.remove(it) }, + onSetScreen = {}, + onClear = { registered.clear() }, + isEnabled = { true }, + ) + + @AfterTest + fun disarm() = TestAutomation.configure( + onRegister = { _, _, _, _, _, _ -> }, + onUnregister = {}, + onSetScreen = {}, + onClear = {}, + isEnabled = { false }, + ) + + /** What was registered while the panel was on screen — read BEFORE the + * scene closes, because closing disposes every element (as it should). */ + private fun render(title: String, detail: String, kind: FailureKind): Map { + armAutomation() + return ImageComposeScene(width = 900, height = 1600) { + FailurePanel(title = title, detail = detail, kind = kind, context = "first-run claim") + }.use { + it.render() + it.render() // onGloballyPositioned lands after the first layout pass + registered.toMap() + } + } + + @Test + fun the_panel_registers_its_title_and_detail_as_text() { + val detail = "claim PIN not captured — this node's one-time ownership PIN never reached the app" + val registered = render("This node could not be claimed", detail, FailureKind.Unrecoverable) + + assertTrue("failure_panel_detail" in registered, "the detail must be on /tree at all: $registered") + assertEquals(detail, registered["failure_panel_detail"], + "the verbatim reason must be readable by a driver, not only by eye") + assertEquals("This node could not be claimed", registered["failure_panel_title"]) + assertTrue(!registered["failure_panel_guidance"].isNullOrBlank(), + "the guidance names the kind (retry or report) and must be readable too") + } +} diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index 1b9d1bcd..f5b8f438 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -162,6 +162,26 @@ def _claim_started(tags: set[str]) -> bool: return bool(CLAIM_TAGS & tags) +#: Where a failed claim's reason is readable, most specific first. +#: `setup_ownership_error` is the FailurePanel's CONTAINER; the reason itself is +#: the panel's detail, under its title. Android, run 36746575125: the fixture +#: read only the container, found no text there, and printed "(no reason on +#: screen)" beside a list that included `failure_panel_detail`. +CLAIM_REASON_TAGS = ("setup_ownership_error", "failure_panel_title", "failure_panel_detail") + + +def _claim_failure_reason(drv: TestAutomationServer, tree) -> str: + """The failed claim's reason as the screen states it. When no element + carries text, say THAT — with what each element held — because "no + reason" reads as the node's silence when it is the client's.""" + texts = {e.test_tag: (e.text or "").strip() for e in tree if e.test_tag in CLAIM_REASON_TAGS} + said = list(dict.fromkeys(t for t in (texts.get(k, "") for k in CLAIM_REASON_TAGS) if t)) + if said: + return " — ".join(said) + return ("the error screen registered no text for " + f"{', '.join(k for k in CLAIM_REASON_TAGS if k in texts)}; fields: {_field_report(drv)}") + + def _await_claim(drv: TestAutomationServer, timeout: float = CLAIM_TIMEOUT, poll: float = 1.5) -> None: """Wait out `setup_ownership_claiming`. It has no control — Android (run 36733112700) showed it with the step indicators and no active step, and a @@ -175,9 +195,8 @@ def _await_claim(drv: TestAutomationServer, timeout: float = CLAIM_TIMEOUT, poll tree = drv.tree() tags = {e.test_tag for e in tree} if "setup_ownership_error" in tags: - why = next((e.text for e in tree if e.test_tag == "setup_ownership_error" and e.text), "") raise SessionUnavailable( - f"the node refused the ownership claim: {why or '(no reason on screen)'}; " + f"the ownership claim failed: {_claim_failure_reason(drv, tree)}; " f"on screen: {sorted(tags)}" ) if "setup_ownership_claiming" not in tags: diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py index e22880a2..e2fdcca1 100644 --- a/testing/test_session_fixture.py +++ b/testing/test_session_fixture.py @@ -40,8 +40,15 @@ class _SlowWizard: answers, Next again claims, and the claim returns the app to Login.""" def __init__(self, clock: _Clock, advance_after: float, claim_takes: float = 0.0, - claim_error: str | None = None): + claim_error: str | None = None, claim_error_on: str = "container"): self.clock, self.advance_after = clock, advance_after + # WHERE the reason is readable in /tree. "container": on + # `setup_ownership_error` itself. "panel": only on the FailurePanel's + # own texts — the container carries none. "nowhere": no element on the + # error screen carries text, which is what a client whose panel + # registered its tags without their texts served (Android, run + # 36746575125: "(no reason on screen)" with the reason on screen). + self.claim_error_on = claim_error_on # The claim is WORK: `setup_ownership_claiming` for `claim_takes` # seconds (step indicators up, no step active, no advance control), # then claimed — or the error panel, which never returns to Login. @@ -86,7 +93,13 @@ def tree(self): if phase == "claiming": return [_el("setup_ownership_claiming")] + indicators if phase == "error": - return [_el("setup_ownership_error", self.claim_error)] + indicators + on = self.claim_error_on + panel = [_el("failure_panel"), + _el("failure_panel_title", "This node could not be claimed" if on == "panel" else None), + _el("failure_panel_detail", self.claim_error if on == "panel" else None), + _el("btn_failure_report"), _el("btn_setup_finish_unclaimed")] + return ([_el("setup_ownership_error", self.claim_error if on == "container" else None)] + + panel + indicators) return [_el("setup_ownership_claimed")] indicators = [_el("setup_step_indicators"), _el("step_indicator_you", "active" if self.step == "you" else ""), @@ -176,6 +189,34 @@ def test_a_refused_claim_is_reported_with_its_reason(monkeypatch): assert clock.t < 60, "a refused claim is reported when it is refused, not at the timeout" +def test_a_reason_on_the_failure_panel_is_read_from_the_panel(monkeypatch): + """Android, run 36746575125: `setup_ownership_error` is the panel's + CONTAINER and carries no text; the reason is the panel's detail. The + fixture reported "(no reason on screen)" with `failure_panel_detail` in + the very list it printed.""" + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=5.0, + claim_error="claim PIN not captured", claim_error_on="panel") + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + msg = str(e.value) + assert "claim PIN not captured" in msg, msg + assert "This node could not be claimed" in msg, msg + assert "no reason on screen" not in msg, msg + + +def test_a_reason_nobody_registered_says_what_each_element_held(monkeypatch): + """When no element carries text, say so per element — the field report — + so the next red run shows that the CLIENT withheld the text rather than + reading as though the node gave no reason.""" + clock, w = _drive(monkeypatch, advance_after=1.0, claim_takes=5.0, + claim_error="claim PIN not captured", claim_error_on="nowhere") + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + msg = str(e.value) + assert "failure_panel_detail[" in msg, msg + assert "registered no text" in msg, msg + + def test_a_next_that_answers_disabled_is_waited_for_like_one_with_no_handler(monkeypatch): """From 0.5.225 a disabled control answers "is disabled" (409) on every platform, not "No click handler"; the fixture's wait for a late-enabling From da7e8d888973517b12efd6e1450d7ceb6c7d1c23 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 12:38:03 -0500 Subject: [PATCH 22/27] fix(gate): carry the node's claim PIN into the Android app's home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android, run 36746575125: the app reached first-run Setup on its fresh node, drove the wizard, and at the final step logged SetupViewModel: claimLocalNodeOwnership: claim PIN not captured after wait — leaving node unclaimed The node's log has no claim request at all. The app reads the one-time PIN from File(CIRIS_HOME, "claim_pin"), and on Android CIRIS_HOME is the app's filesDir/ciris INSIDE the emulator. The node runs on the HOST (reached through adb reverse) and wrote its PIN to /claim_pin there. Desktop reads the file the node declares in /v1/setup/status, and iOS embeds its node, so both share a filesystem with the PIN. Android never could. The harness now does what two_node.py already does for the peer: stands in for the operator at the node's console. - bringup.node_claim_pin reads GET /v1/setup/status -> claim_pin_file and reads that file on the host (only the path crosses HTTP). It returns None for an owned node, and a first-run node whose PIN cannot be read raises CannotRun naming the path, instead of failing at the claim minutes later. - android_plan(claim_pin=...) adds hand-over-claim-pin after install and before launch: run-as writes it 0600 to /data/data//files/ciris/claim_pin. Anything not shaped like a PIN is refused before it reaches a shell. - android_teardown removes the copy, so a consumed PIN is never offered to the next first run (the CIRISClient#49 stale-PIN shape). - run_platform asks the leg's --node-url for the PIN when it builds the plan. node_claim_pin was also run against a real scratch ciris-server on :4463 and returned the PIN in its claim_pin file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/bringup.py | 93 ++++++++++++++++++++++++- testing/gate/run_platform.py | 5 +- testing/test_bringup.py | 127 +++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/testing/gate/bringup.py b/testing/gate/bringup.py index f855e07d..ae1b6e87 100644 --- a/testing/gate/bringup.py +++ b/testing/gate/bringup.py @@ -41,9 +41,13 @@ from __future__ import annotations +import json import os +import re import shutil import subprocess +import time +import urllib.request from dataclasses import dataclass, field from pathlib import Path @@ -84,6 +88,21 @@ ANDROID_TEST_SENTINEL = "/data/local/tmp/ciris_test_mode" +#: Where the Android app reads the node's one-time claim PIN: `CIRIS_HOME` is +#: `filesDir/ciris` (CirisVerify.setup) and PythonRuntime.android's +#: readLocalClaimPin() reads `File(CIRIS_HOME, "claim_pin")`. `/data/data/` +#: is the app's own data directory, writable through `run-as` on a debug build. +ANDROID_CLAIM_PIN = "files/ciris/claim_pin" + +#: What a claim PIN looks like (`FCZX-WTDT`), loosely: it is written into a +#: device shell command, so anything else is refused rather than quoted. +_PIN_SHAPE = re.compile(r"[A-Za-z0-9-]{4,32}") + + +def _android_pin_path(package: str) -> str: + return f"/data/data/{package}/{ANDROID_CLAIM_PIN}" + + class CannotRun(RuntimeError): """This platform is not available here. @@ -171,7 +190,8 @@ def _adb(serial: str | None = None) -> list[str]: def android_plan(apk: Path, package: str, serial: str | None = None, - host_port: int = 19091, activity: str = ANDROID_ACTIVITY) -> Plan: + host_port: int = 19091, activity: str = ANDROID_ACTIVITY, + claim_pin: str | None = None) -> Plan: """Emulator on this runner, node on the host, client reaching back to it. The node runs on the HOST and the app reaches it through `adb reverse`, so @@ -179,8 +199,31 @@ def android_plan(apk: Path, package: str, serial: str | None = None, in the REMOTE-node shape described by FSD/ONE_CLIENT_N_NODES.md and means no Android-specific node binary is needed — which is just as well, since CIRISServer publishes none. + + THE PIN DOES NOT CROSS `adb reverse`. A first-run node writes its one-time + claim PIN to `/claim_pin` on the HOST; the app looks for it in + ITS home, inside the emulator. Desktop and iOS share the host's filesystem + and read the file the node declares; Android cannot, and in run + 36746575125 it drove the whole wizard and then gave up with "claim PIN not + captured after wait" — the node never saw a claim. So with `claim_pin` + (from [node_claim_pin]) the plan writes it where the app reads it, after + install (the data directory exists) and before launch (setup reads it on + the final step, but a PIN that arrives late is a race nobody needs). The + harness is the operator at the node's console here, as two_node.py is for + the peer; the PIN still never crosses HTTP. """ adb = _adb(serial) + handover: list[Step] = [] + if claim_pin is not None: + if not _PIN_SHAPE.fullmatch(claim_pin): + raise CannotRun(f"refusing to hand over a claim PIN that does not look like one: {claim_pin!r}") + path = _android_pin_path(package) + handover.append(Step( + "hand-over-claim-pin", + adb + ["shell", f"run-as {package} sh -c " + f"'umask 077 && mkdir -p {path.rsplit('/', 1)[0]} && " + f"printf %s {claim_pin} > {path}'"], + )) return Plan( platform="android", test_url=f"http://127.0.0.1:{host_port}", @@ -193,6 +236,7 @@ def android_plan(apk: Path, package: str, serial: str | None = None, Step("force-stop", adb + ["shell", "am", "force-stop", package], optional=True), # INVARIANT 3: installed before anything is forwarded. Step("install", adb + ["install", "-r", str(apk)]), + *handover, # INVARIANT 2: the node is reachable before the app probes it. Step("reverse-node", adb + ["reverse", f"tcp:{NODE_API_PORT}", f"tcp:{NODE_API_PORT}"]), Step("forward-automation", adb + ["forward", f"tcp:{host_port}", f"tcp:{CLIENT_TEST_PORT}"]), @@ -243,12 +287,59 @@ def android_teardown(package: str, serial: str | None = None, steps=[ Step("stop-app", adb + ["shell", "am", "force-stop", package], optional=True), Step("disarm-test-mode", adb + ["shell", "rm", "-f", ANDROID_TEST_SENTINEL], optional=True), + # The node deletes ITS file when the claim consumes the PIN; the copy + # handed to the device would outlive it and be offered to the next + # first run — a stale PIN, which is CIRISClient#49 planted by the gate. + Step("remove-claim-pin", + adb + ["shell", f"run-as {package} rm -f {_android_pin_path(package)}"], optional=True), Step("remove-forward", adb + ["forward", "--remove", f"tcp:{host_port}"], optional=True), Step("remove-reverse", adb + ["reverse", "--remove", f"tcp:{NODE_API_PORT}"], optional=True), ], ) +def node_claim_pin(node_url: str, timeout: float = 30.0, poll: float = 1.0) -> str | None: + """The one-time claim PIN of the node at `node_url`, read from the file the + node itself declares (`GET /v1/setup/status` -> `claim_pin_file`, the same + declaration desktop's PythonRuntime reads), or None when the node is + already owned — the session fixture then logs in instead of claiming. + + Only the PATH crosses HTTP. The read is a same-host read of a 0600 file, + which is what makes this the operator's act and not a network one. + + A first-run node whose PIN cannot be read within `timeout` RAISES. None + would mean "owned", and the leg would fail at the claim minutes later for a + reason known here. The node writes the file a few seconds after /health + answers, hence the wait. + """ + deadline = time.monotonic() + timeout + last = "no answer yet" + while True: + try: + with urllib.request.urlopen(f"{node_url.rstrip('/')}/v1/setup/status", timeout=5) as r: + status = json.load(r) + data = status.get("data", status) if isinstance(status, dict) else {} + if not (data.get("setup_required") or data.get("is_first_run")): + return None + declared = data.get("claim_pin_file") + if not declared: + last = f"the node is first-run but declares no claim_pin_file: {data}" + else: + try: + pin = Path(declared).read_text(encoding="utf-8").strip() + except OSError as e: + last = f"cannot read the declared claim_pin_file {declared}: {e}" + else: + if pin: + return pin + last = f"the declared claim_pin_file {declared} is empty" + except (OSError, ValueError) as e: + last = f"GET {node_url}/v1/setup/status failed: {e}" + if time.monotonic() >= deadline: + raise CannotRun(f"no claim PIN to hand the app after {timeout:.0f}s: {last}") + time.sleep(poll) + + def ios_simulator_plan(app_bundle: Path, bundle_id: str, udid: str = "booted") -> Plan: """Simulator on a macOS runner, node on the same host. diff --git a/testing/gate/run_platform.py b/testing/gate/run_platform.py index 17af79f1..a21df946 100644 --- a/testing/gate/run_platform.py +++ b/testing/gate/run_platform.py @@ -74,8 +74,11 @@ def plan_for(args) -> bringup.Plan: if args.platform == "android": if not args.apk: raise bringup.CannotRun("--apk is required for android") + # The node is on THIS host; the app is not. Carry its claim PIN across + # (bringup.android_plan) — None when the node is already owned. + pin = bringup.node_claim_pin(args.node_url) return bringup.android_plan(Path(args.apk), args.package, serial=args.serial, - activity=args.activity) + activity=args.activity, claim_pin=pin) if args.platform == "ios": if not args.app: raise bringup.CannotRun("--app is required for ios") diff --git a/testing/test_bringup.py b/testing/test_bringup.py index 42b90ff9..19d8abaf 100644 --- a/testing/test_bringup.py +++ b/testing/test_bringup.py @@ -280,3 +280,130 @@ def test_the_mobile_launches_are_NOT_backgrounded(): # them background would drop the only synchronisation those plans have. android = bringup.android_plan(Path("/tmp/a.apk"), "pkg") assert not android.steps[android.index_of("launch")].background + + +# ---- the claim PIN crosses from the host's node to the device --------------- +# +# Android, run 36746575125: the app reached first-run Setup, drove the wizard, +# and gave up with "claim PIN not captured after wait — leaving node unclaimed" +# (logcat, SetupViewModel). The node never saw a claim. The app reads the PIN +# from `/claim_pin` — `files/ciris` INSIDE the emulator — while +# the node it attached to runs on the HOST and wrote its PIN there. Desktop and +# iOS share the host's filesystem and read the file the node declares; Android +# cannot, so the harness carries it, standing in for the operator at the node's +# console, exactly as two_node.py does for the peer. + +PIN = "FCZX-WTDT" + + +def test_android_hands_the_claim_pin_over_after_install_and_before_launch(): + p = android_plan(APK, PKG, claim_pin=PIN) + assert p.index_of("install") < p.index_of("hand-over-claim-pin") < p.index_of("launch") + + +def test_the_pin_lands_in_the_home_the_app_reads(): + step = android_plan(APK, PKG, claim_pin=PIN).steps[ + android_plan(APK, PKG, claim_pin=PIN).index_of("hand-over-claim-pin")] + cmd = " ".join(step.cmd) + # CirisVerify.setup(): CIRIS_HOME = filesDir/ciris; PythonRuntime.android + # readLocalClaimPin() reads File(CIRIS_HOME, "claim_pin"). + assert f"run-as {PKG}" in cmd, "only the app's own uid can write its private files" + assert f"/data/data/{PKG}/files/ciris/claim_pin" in cmd + assert PIN in cmd + + +def test_no_pin_no_handover_step(): + # An owned node has no PIN; the fixture logs in instead of claiming. + assert "hand-over-claim-pin" not in android_plan(APK, PKG).names() + + +@pytest.mark.parametrize("bad", ["ABCD-EFGH; rm -rf /", "$(id)", "a'b", "", "X" * 200]) +def test_a_pin_that_is_not_a_pin_is_refused_before_it_reaches_a_shell(bad): + with pytest.raises(CannotRun): + android_plan(APK, PKG, claim_pin=bad) + + +def test_teardown_takes_the_handed_over_pin_back(): + # A copy left on the device outlives the claim that consumed the node's own + # file — the stale-PIN shape CIRISClient#49 was about, planted by the gate. + td = android_teardown(PKG) + assert "remove-claim-pin" in td.names() + step = td.steps[td.index_of("remove-claim-pin")] + assert step.optional and f"/data/data/{PKG}/files/ciris/claim_pin" in " ".join(step.cmd) + + +class _StatusNode: + """A node's read API, serving only `/v1/setup/status`.""" + + def __init__(self, body: dict): + import http.server + import json + import threading + + payload = json.dumps(body).encode() + + class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + if self.path != "/v1/setup/status": + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *a): + pass + + self.srv = http.server.HTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.srv.server_address[1]}" + + def close(self): + self.srv.shutdown() + + +def test_the_pin_is_read_from_the_file_the_node_declares(tmp_path): + pin_file = tmp_path / "claim_pin" + pin_file.write_text(PIN + "\n") + node = _StatusNode({"data": {"is_first_run": True, "setup_required": True, + "claim_pin_file": str(pin_file)}}) + try: + assert bringup.node_claim_pin(node.url, timeout=2) == PIN + finally: + node.close() + + +def test_an_owned_node_has_no_pin_to_hand_over(): + node = _StatusNode({"data": {"is_first_run": False, "setup_required": False}}) + try: + assert bringup.node_claim_pin(node.url, timeout=2) is None + finally: + node.close() + + +def test_a_first_run_node_whose_pin_cannot_be_read_fails_loudly(tmp_path): + # Not None: None means "owned, log in", and the leg would then fail at the + # claim two minutes later for a reason this line already knew. + missing = tmp_path / "nowhere" / "claim_pin" + node = _StatusNode({"data": {"is_first_run": True, "setup_required": True, + "claim_pin_file": str(missing)}}) + try: + with pytest.raises(CannotRun) as e: + bringup.node_claim_pin(node.url, timeout=1, poll=0.2) + assert str(missing) in str(e.value) + finally: + node.close() + + +def test_the_android_leg_asks_its_node_for_the_pin(monkeypatch): + from testing.gate import run_platform + + asked = [] + monkeypatch.setattr(bringup, "node_claim_pin", lambda url, **kw: asked.append(url) or PIN) + args = type("A", (), dict(platform="android", apk=str(APK), package=PKG, serial=None, + activity=bringup.ANDROID_ACTIVITY, + node_url="http://127.0.0.1:4243"))() + plan = run_platform.plan_for(args) + assert asked == ["http://127.0.0.1:4243"] + assert "hand-over-claim-pin" in plan.names() From 2698e60c53cda622bb81579255e8733b2a67a5c0 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 13:59:55 -0500 Subject: [PATCH 23/27] fix(primitives): a sheet's way out stays on screen on a phone Matrix run 36752849889, Android 7/9. Both failures were the receipt sheet. On the phone-height emulator, Close sat at the end of the sheet's own scroll, below the fold. That scroll was a plain verticalScroll automation could not drive, so /scroll moved the People list behind the sheet. The open sheet's drivable list carried every receipt row and no btn_receipt_close, and /click answered 404 "composed but off screen". ReceiptSheet: Close moves into the header row, keeping btn_receipt_close. The body (facts, holders, notes, acts) scrolls inside the sheet on testableVerticalScroll, named sheet_receipt. The sheet pads by the status bar inset, because the full-height sheet drew under it and a header Close would sit beneath the system icons. ConfirmSheet had the same shape. With no scroll at all, a confirm taller than a small phone squeezed its third fact and its note to zero height. The buttons stay pinned, and the facts and note now scroll, named sheet_confirm. SheetCloseOnPhoneTest renders both headless at 360x640 dp and reads bounds back through the automation registration. It was red on all three cases before the fix and is green after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- client/VENDORING.md | 2 +- .../shared/ui/primitives/ConfirmSheet.kt | 25 ++- .../shared/ui/primitives/ReceiptSheet.kt | 106 ++++++++----- .../ui/primitives/SheetCloseOnPhoneTest.kt | 150 ++++++++++++++++++ 4 files changed, 232 insertions(+), 51 deletions(-) create mode 100644 client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index 177306fd..965dfeee 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `e9e9a6f5e75cc2d5810b70212695929a79b9984847838007f14ef488b973fdba` +**state digest:** `b26985bcb03fd1dbe75a4bea7dfcbb646787c72fead56b19e82a1278098fa411` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt index 829e5da8..f032d5f6 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ConfirmSheet.kt @@ -2,6 +2,7 @@ package ai.ciris.mobile.shared.ui.primitives import ai.ciris.mobile.shared.localization.localizedString import ai.ciris.mobile.shared.platform.testable +import ai.ciris.mobile.shared.platform.testableVerticalScroll import ai.ciris.mobile.shared.ui.theme.CirisShape import ai.ciris.mobile.shared.ui.theme.CirisTheme import androidx.compose.foundation.layout.Arrangement @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -70,18 +72,29 @@ fun ConfirmSheet( tonalElevation = 0.dp, dragHandle = null, ) { + // THE BUTTONS ARE PINNED, THE FACTS SCROLL. With no scroll at all, a + // confirm taller than a small phone squeezed its third fact and its + // note to nothing: the buttons stayed, the facts the person is meant to + // read before confirming did not, and nothing could bring them back. + // Same shape as the receipt's hidden Close (matrix run 36752849889). Column( modifier = Modifier.fillMaxWidth().testable("sheet_$tagPrefix", title) + .statusBarsPadding() .padding(horizontal = 18.dp, vertical = 14.dp).navigationBarsPadding(), ) { Text(title, style = type.title, color = t.ink) Spacer(Modifier.height(8.dp)) - checked.forEachIndexed { i, f -> - FieldRow(label = f.label, value = f.value, mono = f.mono, tag = "${tagPrefix}_fact_${i + 1}", divider = i < 2) - } - if (note != null) { - Spacer(Modifier.height(10.dp)) - Text(note, style = type.body, color = t.danger, modifier = Modifier.testable("${tagPrefix}_note", note)) + Column( + modifier = Modifier.fillMaxWidth().weight(1f, fill = false) + .testableVerticalScroll(name = "sheet_$tagPrefix"), + ) { + checked.forEachIndexed { i, f -> + FieldRow(label = f.label, value = f.value, mono = f.mono, tag = "${tagPrefix}_fact_${i + 1}", divider = i < 2) + } + if (note != null) { + Spacer(Modifier.height(10.dp)) + Text(note, style = type.body, color = t.danger, modifier = Modifier.testable("${tagPrefix}_note", note)) + } } Spacer(Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = androidx.compose.ui.Alignment.End)) { diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt index 45f93870..2a737703 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/ui/primitives/ReceiptSheet.kt @@ -6,6 +6,7 @@ import ai.ciris.mobile.shared.ceg.cohortScopeOf import ai.ciris.mobile.shared.ceg.shortKey import ai.ciris.mobile.shared.localization.localizedString import ai.ciris.mobile.shared.platform.testable +import ai.ciris.mobile.shared.platform.testableVerticalScroll import ai.ciris.mobile.shared.ui.glyphs.Glyph import ai.ciris.mobile.shared.ui.glyphs.GlyphName import ai.ciris.mobile.shared.ui.theme.CirisShape @@ -19,8 +20,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -60,61 +60,79 @@ fun ReceiptSheet( tonalElevation = 0.dp, dragHandle = null, ) { + // THE WAY OUT IS IN THE HEADER, AND ONLY THE BODY SCROLLS. Close used + // to sit at the end of the sheet's own scroll: on a phone-height + // Android emulator it was below the fold, and that scroll was a plain + // verticalScroll automation could not drive, so /scroll moved the list + // BEHIND the sheet and /click btn_receipt_close answered 404 "composed + // but off screen" (matrix run 36752849889, CSD-005/006). A person on a + // small phone had the same problem with a thumb. Column( modifier = Modifier .fillMaxWidth() .testable(tag, receipt.id) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 18.dp, vertical = 14.dp) + // A full-height sheet runs under the status bar; the header + // (and Close in it) must not sit beneath the system icons. + .statusBarsPadding() .navigationBarsPadding(), - verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { Glyph(GlyphName.RECEIPT, tint = t.brand, size = 18.dp) - Text(localizedString("mobile.receipt_title"), style = type.title, color = t.ink) + Text( + localizedString("mobile.receipt_title"), style = type.title, color = t.ink, + modifier = Modifier.weight(1f), + ) + CirisTextButton(localizedString("mobile.receipt_close"), tag = "btn_receipt_close", onClick = onDismiss) } - Spacer(Modifier.height(6.dp)) - - FactRow(Envelope.subjectKeyIds, receipt.subject, "receipt_subject", keyish = true) - FactRow(Envelope.attestingKeyId, receipt.attester, "receipt_attester", keyish = true) - ScopeRow(receipt.scope, receipt.scopeNote) - FactRow(Envelope.dimension, receipt.dimensionValue, "receipt_dimension", keyish = false) - FactRow(Envelope.consentScope, receipt.rule, "receipt_rule", keyish = false, divider = receipt.forAgent != null) - receipt.forAgent?.let { FactRow(ForAgentMember, it, "receipt_for_agent", keyish = true, divider = false) } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .testableVerticalScroll(name = tag) + .padding(start = 18.dp, end = 18.dp, top = 2.dp, bottom = 14.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + FactRow(Envelope.subjectKeyIds, receipt.subject, "receipt_subject", keyish = true) + FactRow(Envelope.attestingKeyId, receipt.attester, "receipt_attester", keyish = true) + ScopeRow(receipt.scope, receipt.scopeNote) + FactRow(Envelope.dimension, receipt.dimensionValue, "receipt_dimension", keyish = false) + FactRow(Envelope.consentScope, receipt.rule, "receipt_rule", keyish = false, divider = receipt.forAgent != null) + receipt.forAgent?.let { FactRow(ForAgentMember, it, "receipt_for_agent", keyish = true, divider = false) } - Spacer(Modifier.height(10.dp)) - FieldRow( - label = localizedString("mobile.receipt_holders"), - value = receipt.holders?.let { localizedString("mobile.receipt_holders_count", "count", it.toString()) } - ?: localizedString("ceg.envelope.not_sent"), - tone = if (receipt.holders == null) Tone.DANGER else Tone.INK, - mono = receipt.holders != null, - tag = "receipt_holders", - ) - FieldRow( - label = localizedString("mobile.receipt_notes"), - value = if (receipt.notes.isEmpty()) localizedString("mobile.receipt_notes_none") - else receipt.notes.joinToString("\n") { "${it.by}: ${it.text}" }, - tone = if (receipt.notes.isEmpty()) Tone.DIM else Tone.INK, - tag = "receipt_notes", - divider = receipt.acts.isNotEmpty(), - ) - if (receipt.acts.isNotEmpty()) { - Text( - localizedString("mobile.receipt_acts").uppercase(), - style = type.label, color = t.mute, - modifier = Modifier.padding(top = 10.dp, bottom = 6.dp), + Spacer(Modifier.height(10.dp)) + FieldRow( + label = localizedString("mobile.receipt_holders"), + value = receipt.holders?.let { localizedString("mobile.receipt_holders_count", "count", it.toString()) } + ?: localizedString("ceg.envelope.not_sent"), + tone = if (receipt.holders == null) Tone.DANGER else Tone.INK, + mono = receipt.holders != null, + tag = "receipt_holders", + ) + FieldRow( + label = localizedString("mobile.receipt_notes"), + value = if (receipt.notes.isEmpty()) localizedString("mobile.receipt_notes_none") + else receipt.notes.joinToString("\n") { "${it.by}: ${it.text}" }, + tone = if (receipt.notes.isEmpty()) Tone.DIM else Tone.INK, + tag = "receipt_notes", + divider = receipt.acts.isNotEmpty(), ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - for (act in receipt.acts) { - Chip(ChipSpec(label = act.label, tag = act.tag, kind = ChipKind.CHOICE, tone = Tone.BRAND, onClick = act.onAct)) + if (receipt.acts.isNotEmpty()) { + Text( + localizedString("mobile.receipt_acts").uppercase(), + style = type.label, color = t.mute, + modifier = Modifier.padding(top = 10.dp, bottom = 6.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (act in receipt.acts) { + Chip(ChipSpec(label = act.label, tag = act.tag, kind = ChipKind.CHOICE, tone = Tone.BRAND, onClick = act.onAct)) + } } } } - Spacer(Modifier.height(12.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - CirisTextButton(localizedString("mobile.receipt_close"), tag = "btn_receipt_close", onClick = onDismiss) - } } } } diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt new file mode 100644 index 00000000..daaaffbb --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/ui/primitives/SheetCloseOnPhoneTest.kt @@ -0,0 +1,150 @@ +package ai.ciris.mobile.shared.ui.primitives + +import ai.ciris.mobile.shared.ceg.Dim +import ai.ciris.mobile.shared.platform.TestAutomation +import ai.ciris.mobile.shared.testing.TestAutomationState +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.use +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * A sheet's way out must be on screen on a phone, without scrolling. + * + * Android, matrix run 36752849889 (CSD-005 and CSD-006): the receipt sheet + * filled the emulator, Close sat below the fold at the end of the sheet's own + * scroll, and that scroll was a plain `verticalScroll` automation could not + * drive — `/scroll` moved the People list BEHIND the sheet instead. The open + * sheet's drivable list carried every receipt row and no `btn_receipt_close`, + * and `/click` answered 404 "composed but off screen". + * + * Rendered headless at a small phone (360 x 640 dp) and read back through the + * same registration a harness sees: the exit's bounds must lie in the window. + */ +class SheetCloseOnPhoneTest { + + private data class Box(val x: Int, val y: Int, val w: Int, val h: Int) + + private val registered = mutableMapOf() + + private val density = 3f + private val widthPx = (360 * density).toInt() + private val heightPx = (640 * density).toInt() + + private fun armAutomation() = TestAutomation.configure( + onRegister = { tag, x, y, w, h, _ -> registered[tag] = Box(x, y, w, h) }, + onUnregister = { registered.remove(it) }, + onSetScreen = {}, + onClear = { registered.clear() }, + isEnabled = { true }, + ) + + @AfterTest + fun disarm() = TestAutomation.configure( + onRegister = { _, _, _, _, _, _ -> }, + onUnregister = {}, + onSetScreen = {}, + onClear = {}, + isEnabled = { false }, + ) + + /** + * Render, let the sheet finish animating in, and read what registered. With + * [scrollContainer], then post the same `/scroll` request a harness posts — + * through [TestAutomationState], naming the container — and read again: the + * sheet's BODY must be the thing that moves. + */ + private fun render( + scrollContainer: String? = null, + content: @androidx.compose.runtime.Composable () -> Unit, + ): Pair, Map?> { + armAutomation() + return ImageComposeScene(width = widthPx, height = heightPx, density = Density(density), content = content).use { + var t = 0L + fun frames(n: Int) = repeat(n) { _ -> it.render(t); t += 16_000_000L } + frames(120) // ~2 s: the sheet slides in + val before = registered.toMap() + val after = scrollContainer?.let { name -> + TestAutomationState.requestScroll(testTag = "", direction = "down", amount = 20_000, container = name) + frames(120) + registered.toMap() + } + before to after + } + } + + private fun assertOnScreen(tag: String, seen: Map) { + val b = assertNotNull(seen[tag], "$tag must be composed and registered at all: ${seen.keys}") + assertTrue( + b.w > 0 && b.h > 0 && b.x >= 0 && b.y >= 0 && b.x + b.w <= widthPx && b.y + b.h <= heightPx, + "$tag must lie within a 360x640 dp phone (${widthPx}x$heightPx px); it is at $b (a zero box is how the harness sees composed-but-off-screen)", + ) + } + + /** The receipt Android showed: five facts, a for-agent row, notes, two acts. */ + private fun phoneReceipt() = Receipt( + id = "ciris-gate-peer-9893b757-user-riv6mtomfo", + subject = Fact.Wire("ciris-gate-peer-9893b757-user-riv6mtomfo-kxcf"), + attester = Fact.Wire( + "gate-gate-peer-22es", + "The person who consented — signed with their own identity, not by this node.", + ), + scope = Fact.Wire("everyone"), + dimension = Dim.consentKind, + dimensionValue = Fact.Wire("consent:replication:v1"), + rule = Fact.Wire("capacity:, chat:, ownership:, self:delegates_to:, trace:"), + forAgent = Fact.Wire("ciris-server-agent-s37q"), + holders = null, + notes = listOf(ReceiptNote("peer", "Met at the community meeting; confirmed the code in person.")), + acts = listOf( + ReceiptAct("Open chat", "btn_receipt_act_chat", {}), + ReceiptAct("Remove", "btn_receipt_act_remove", {}), + ), + scopeNote = "The grant itself is a public record. What you send each other is not.", + ) + + @Test + fun the_receipt_close_is_on_screen_on_a_phone() { + val (seen, _) = render { ReceiptSheet(receipt = phoneReceipt(), onDismiss = {}) } + assertOnScreen("btn_receipt_close", seen) + } + + @Test + fun the_receipt_body_scrolls_under_automation_and_close_stays() { + val (_, scrolled) = render(scrollContainer = "sheet_receipt") { + ReceiptSheet(receipt = phoneReceipt(), onDismiss = {}) + } + val after = assertNotNull(scrolled) + assertOnScreen("btn_receipt_act_remove", after) // the last thing in the body + assertOnScreen("btn_receipt_close", after) + } + + @Test + fun the_confirm_buttons_stay_and_every_fact_is_reachable_on_a_phone() { + val long = List(12) { "A fact long enough to wrap across the width of a small phone, line $it." } + .joinToString(" ") + val (seen, scrolled) = render(scrollContainer = "sheet_confirm") { + ConfirmSheet( + title = "Share this with the whole community?", + facts = listOf( + ConfirmFact("What is shared", long), + ConfirmFact("Who can see it", long), + ConfirmFact("How to undo it", long), + ), + confirmLabel = "Share", + onConfirm = {}, + onDismiss = {}, + note = long, + ) + } + assertOnScreen("btn_confirm_cancel", seen) + assertOnScreen("btn_confirm_confirm", seen) + // The facts are what the person confirms; the last of them must be reachable. + val after = assertNotNull(scrolled) + assertOnScreen("confirm_note", after) + assertOnScreen("btn_confirm_confirm", after) + } +} From efdac2a249f16157d8533440da7729ac3f0d8bec Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 15:52:46 -0500 Subject: [PATCH 24/27] fix(session): a consent answer that did not land is given again while Next refuses Android, run 36762606620: the fixture clicked trace_consent_yes once, the answer never reached the ViewModel, and Next stayed disabled for 30 s with the question unanswered on screen. The fixture re-answered only after a Next that clicked but did not advance, never after one that refused. It now re-answers inside the refused-Next wait; the answer is idempotent. Two tests: a lost answer is given again (red first), and one that never lands is still reported by step, bounded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- testing/gate/session_fixture.py | 9 +++++++++ testing/test_session_fixture.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/testing/gate/session_fixture.py b/testing/gate/session_fixture.py index f5b8f438..5773ec40 100644 --- a/testing/gate/session_fixture.py +++ b/testing/gate/session_fixture.py @@ -365,6 +365,15 @@ def run_setup(drv: TestAutomationServer, username: str, password: str, except DriverError as e: if not any(w in str(e) for w in DISABLED_ANSWERS): raise + # A Next that refuses while the step's question is still on + # screen may be waiting on an answer that never landed (Android, + # run 36762606620: one click on `trace_consent_yes`, Next + # disabled for 30 s). Answering again is idempotent. + if "trace_consent_yes" in _tags(drv): + try: + drv.click("trace_consent_yes") + except DriverError: + pass time.sleep(2.0) if not clicked: raise SessionUnavailable( diff --git a/testing/test_session_fixture.py b/testing/test_session_fixture.py index e2fdcca1..df038786 100644 --- a/testing/test_session_fixture.py +++ b/testing/test_session_fixture.py @@ -54,6 +54,9 @@ def __init__(self, clock: _Clock, advance_after: float, claim_takes: float = 0.0 # then claimed — or the error panel, which never returns to Login. self.claim_takes, self.claim_error = claim_takes, claim_error self.refuse_next = 0 + # Clicks on `trace_consent_yes` that land but change nothing: the + # answer's handler was not live yet (Android, run 36762606620). + self.lose_consent_clicks = 0 self.on = "Login" self.step = "you" self.next_at: float | None = None @@ -123,7 +126,13 @@ def click(self, tag): if tag == "btn_local_login": self.on = "Setup" elif tag == "trace_consent_yes": - self.consented = True + if self.lose_consent_clicks > 0: + self.lose_consent_clicks -= 1 + else: + self.consented = True + elif tag == "btn_next" and self.step == "join_federation" and not self.consented: + # SetupState.canProceedFromCurrentStep: unanswered -> Next disabled. + raise DriverError(f"POST /click -> HTTP 409: {tag} is disabled: it refuses /click") elif tag == "btn_next": if self.step == "you": self.next_at = self.clock.t @@ -227,6 +236,28 @@ def test_a_next_that_answers_disabled_is_waited_for_like_one_with_no_handler(mon assert w.screen() == "Login" +def test_a_consent_answer_that_did_not_land_is_given_again(monkeypatch): + """Android, run 36762606620: the fixture clicked `trace_consent_yes` + once, the answer never reached the ViewModel, and Next stayed disabled + for 30 s while the question sat unanswered on screen. The fixture only + re-answered after a Next that CLICKED but did not advance, never after a + Next that refused. Answering again is idempotent; waiting is not.""" + clock, w = _drive(monkeypatch, advance_after=1.0) + w.lose_consent_clicks = 2 + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert w.screen() == "Login" + assert w.clicks.count("trace_consent_yes") >= 3 + + +def test_a_consent_that_never_lands_is_still_reported_by_step(monkeypatch): + clock, w = _drive(monkeypatch, advance_after=1.0) + w.lose_consent_clicks = 10 ** 6 + with pytest.raises(sf.SessionUnavailable) as e: + sf.run_setup(w, "qaadmin", "QaAdmin!2345") + assert "'join_federation'" in str(e.value) and "stayed disabled" in str(e.value) + assert clock.t < 120, "bounded" + + class _OwnedLogin: """A client that has judged the node OWNED: `btn_local_login` reveals the login FORM (LoginScreen: `if (isFirstRun) onLocalLogin() else From e06978a2a05e36359144dfb7cd68dee203c2e184 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 16:55:53 -0500 Subject: [PATCH 25/27] =?UTF-8?q?docs(csd):=20seven=20CSDs=20are=20testabl?= =?UTF-8?q?e=20=E2=80=94=20their=20flows=20passed=20on=20all=20five=20legs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 36775704425 (flows/matrix-0.5.225 at efdac2a2, node v0.5.217): every flow in testing/flows passed on Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. CSD.md §1's two conditions for `testable` — a floor that names a released version (>=0.5.224 / >=0.5.225, both on PyPI) and a flow that runs on the matrix — now hold for CSD-005, 006, 008, 047, 057, 068 and 092, and each passes check_csd_v3 at the new stage. CSD-101's flow passed too, but it stays at `building`: the consent-to-join ruling (#137) gave it x_private:membership_invitation, blocked_by CIRISPersist#955, and `testable` admits no unconfirmed field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- FSD/CSD/CSD-005-people.md | 4 ++-- FSD/CSD/CSD-006-receipt.md | 4 ++-- FSD/CSD/CSD-008-notes-to-self.md | 4 ++-- FSD/CSD/CSD-047-network-content.md | 4 ++-- FSD/CSD/CSD-057-wallet.md | 4 ++-- FSD/CSD/CSD-068-provision-accord-holder.md | 4 ++-- FSD/CSD/CSD-092-share-contact-code.md | 4 ++-- FSD/CSD/CSD-101-household-members.md | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/FSD/CSD/CSD-005-people.md b/FSD/CSD/CSD-005-people.md index f54c49b6..2abac935 100644 --- a/FSD/CSD/CSD-005-people.md +++ b/FSD/CSD/CSD-005-people.md @@ -5,7 +5,7 @@ **Reads with**: CSD-006 (the receipt it opens), CSD-091 (the chat a row opens), CSD-092 (the code card in its header), CSD-104 (the key check a row will offer once CIRISServer#683 lands) ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -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/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **11/12 passed, 1 skipped** — the list, the seeded row with its trust chip and hamburger, the five-fact receipt and its close, the empty search and its clearing, the add card, the node-code refusal by name, and the code card from the header; `a_scan_is_offered_where_there_is_a_camera` skipped as designed (desktop has no `btn_scan_contact_code`). The matrix run of the same day (36588619656) failed this flow on every desktop leg for csd-092's open contact-code card, which now closes itself (`cleanup:`), and its fixture waited only for the peer's owner key, not the binding (`reachable_nodes`, CIRISServer#699) — both fixed. Not yet run on the other four legs. On the second matrix run (36600766576) it passed 11/12 on Linux and macOS (the scan step skipped, no camera) and could not start on Windows (the fixture's console crash, fixed in the gate); its last steps left the add card open with a refusal in it, which on macOS's shorter window pushed the list below the fold for csd_006 — the flow's `cleanup:` now clears the refusal and closes the card (`when:` guards the header toggle). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-005-people.yaml`, floor `>=0.5.225`, `fixture: two_node`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **11/12 passed, 1 skipped** — the list, the seeded row with its trust chip and hamburger, the five-fact receipt and its close, the empty search and its clearing, the add card, the node-code refusal by name, and the code card from the header; `a_scan_is_offered_where_there_is_a_camera` skipped as designed (desktop has no `btn_scan_contact_code`). The matrix run of the same day (36588619656) failed this flow on every desktop leg for csd-092's open contact-code card, which now closes itself (`cleanup:`), and its fixture waited only for the peer's owner key, not the binding (`reachable_nodes`, CIRISServer#699) — both fixed. Not yet run on the other four legs. On the second matrix run (36600766576) it passed 11/12 on Linux and macOS (the scan step skipped, no camera) and could not start on Windows (the fixture's console crash, fixed in the gate); its last steps left the add card open with a refusal in it, which on macOS's shorter window pushed the list below the fold for csd_006 — the flow's `cleanup:` now clears the refusal and closes the card (`when:` guards the header toggle). **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 d2ad228f..e0baecbb 100644 --- a/FSD/CSD/CSD-006-receipt.md +++ b/FSD/CSD/CSD-006-receipt.md @@ -4,7 +4,7 @@ **Flow**: `testing/flows/csd-006-receipt.yaml` (floor `>=0.5.225`) — driven on the first surface that binds the template (Contacts, CSD-005) ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -143,7 +143,7 @@ Bound per surface; CSD-005 §4 is the first instance. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. 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:`). Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/6 passed, 1 skipped** — the seeded row's hamburger, all five envelope rows, the wire dimension, the rule row off the wire (`chat:` among the grant's prefixes) and the close; the grant-less step skipped as designed (the node sends the grant). On the matrix run of the same day (36588619656) every desktop leg failed this flow for csd-092's open contact-code card, since fixed (`cleanup:`). Not yet run on the other four legs. On the second matrix run (36600766576) it passed 6/6 on Linux and failed on macOS at its first step: `contacts_row_${PEER_KEY_ID}` was composed but below the fold, under the add card csd_005 had left open with a refusal, on a screen the harness cannot scroll (People's list is a LazyColumn with no `testableVerticalScroll`). csd_005's `cleanup:` now closes the card, and the runner says "composed but off screen after scrolling" with what `/scroll` answered, rather than "not on screen". +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-006-receipt.yaml`, floor `>=0.5.225`, `fixture: two_node`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. 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:`). Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/6 passed, 1 skipped** — the seeded row's hamburger, all five envelope rows, the wire dimension, the rule row off the wire (`chat:` among the grant's prefixes) and the close; the grant-less step skipped as designed (the node sends the grant). On the matrix run of the same day (36588619656) every desktop leg failed this flow for csd-092's open contact-code card, since fixed (`cleanup:`). Not yet run on the other four legs. On the second matrix run (36600766576) it passed 6/6 on Linux and failed on macOS at its first step: `contacts_row_${PEER_KEY_ID}` was composed but below the fold, under the add card csd_005 had left open with a refusal, on a screen the harness cannot scroll (People's list is a LazyColumn with no `testableVerticalScroll`). csd_005's `cleanup:` now closes the card, and the runner says "composed but off screen after scrolling" with what `/scroll` answered, rather than "not on screen". 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-008-notes-to-self.md b/FSD/CSD/CSD-008-notes-to-self.md index 14730574..fe6e3f24 100644 --- a/FSD/CSD/CSD-008-notes-to-self.md +++ b/FSD/CSD/CSD-008-notes-to-self.md @@ -5,7 +5,7 @@ **Flow**: `testing/flows/csd-008-notes-to-self.yaml` (floor `>=0.5.225`) ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -87,7 +87,7 @@ The new note is **not** listed under Files (CSD-007: `DriveEntry.isNote`). ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-008-notes-to-self.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/2 passed**. On the matrix run of the same day (36588619656) it passed on Linux and could not start on macOS — `circle_agent -> tab_chats` landed on Rooms because the tab was clicked before the circle hop had landed (a node client signs in under Neighbours); the runner now verifies each hop against `/state`. Not yet run on the other four legs since. +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-008-notes-to-self.yaml`, floor `>=0.5.225`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/2 passed**. On the matrix run of the same day (36588619656) it passed on Linux and could not start on macOS — `circle_agent -> tab_chats` landed on Rooms because the tab was clicked before the circle hop had landed (a node client signs in under Neighbours); the runner now verifies each hop against `/state`. Not yet run on the other four legs since. **Verified live** (desktop, scratch ciris-server 0.5.215, 2026-09-24): writing a note from the UI and reading it back from `/v1/notes`; a readable note diff --git a/FSD/CSD/CSD-047-network-content.md b/FSD/CSD/CSD-047-network-content.md index a0abe535..461cbd3f 100644 --- a/FSD/CSD/CSD-047-network-content.md +++ b/FSD/CSD/CSD-047-network-content.md @@ -6,7 +6,7 @@ **Flow**: `testing/flows/csd-047-network-content.yaml` (floor `>=0.5.225`) ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -108,7 +108,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-047-network-content.yaml`, floor `>=0.5.225`, `fixture: two_node`); promotes to `testable` when it runs on the matrix. It enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **4/4 passed** — the Content tile (below the fold; the runner now scrolls to an off-screen control), the peer search, the fixture's peer row, the digest step and its refusal of a bad digest. On the matrix run of the same day (36588619656) it could not start on any desktop leg: the tab was clicked before the circle hop had landed, so Everyone › Rules was never shown; fixed in the runner. A real fetch still needs a digest the peer holds, which the fixture does not seed. +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-047-network-content.yaml`, floor `>=0.5.225`, `fixture: two_node`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. It enters from the hub's `tile_federation_content` and picks `peer_pick_row_${PEER_NODE_KEY_ID}`, the peer the fixture admitted. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **4/4 passed** — the Content tile (below the fold; the runner now scrolls to an off-screen control), the peer search, the fixture's peer row, the digest step and its refusal of a bad digest. On the matrix run of the same day (36588619656) it could not start on any desktop leg: the tab was clicked before the circle hop had landed, so Everyone › Rules was never shown; fixed in the runner. 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-057-wallet.md b/FSD/CSD/CSD-057-wallet.md index 382c3645..4d5bd000 100644 --- a/FSD/CSD/CSD-057-wallet.md +++ b/FSD/CSD/CSD-057-wallet.md @@ -4,7 +4,7 @@ **Flow**: `testing/flows/csd-057-wallet.yaml` (floor `>=0.5.224`) ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -161,7 +161,7 @@ warning quietly disappear would be testing the wrong half. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-057-wallet.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/6 passed, 3 skipped** — the experimental notice before any number, the paymaster and the limits, and back to the tab; the address, the transfer form and its inputs skipped as designed (a node build synthesises a wallet with no address). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner), and the page's own `btn_wallet_back` is not drawn under the shell — the flow presses the shell's `btn_nav_back` (§4 step 6). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-057-wallet.yaml`, floor `>=0.5.224`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/6 passed, 3 skipped** — the experimental notice before any number, the paymaster and the limits, and back to the tab; the address, the transfer form and its inputs skipped as designed (a node build synthesises a wallet with no address). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner), and the page's own `btn_wallet_back` is not drawn under the shell — the flow presses the shell's `btn_nav_back` (§4 step 6). **Platforms.** All five, agent build. Plus a node build for the `wallet_unsupported` state in §2 once it exists. diff --git a/FSD/CSD/CSD-068-provision-accord-holder.md b/FSD/CSD/CSD-068-provision-accord-holder.md index d7bd18e1..886ceda6 100644 --- a/FSD/CSD/CSD-068-provision-accord-holder.md +++ b/FSD/CSD/CSD-068-provision-accord-holder.md @@ -4,7 +4,7 @@ **Flow**: `testing/flows/csd-068-provision-accord-holder.yaml` (floor `>=0.5.224`) ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -262,7 +262,7 @@ stays at `building` until it does. ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`); promotes to `testable` when it runs on the matrix. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). On the second (36600766576) Linux and macOS passed 5/5 and Windows failed `fips_and_a_path` with `provision_holder_error` already on screen — a third client defect: the submit's `testableClickable` did not carry the Button's `enabled`, so the disabled-submit step's `/click` ran `provision()` and raised the "confirm your YubiKey" banner (CIRISClient#69's shape). Linux and macOS had passed that step only because the banner composed above the fold the runner had scrolled past, where the geometry-based `absent:` could not see it. Fixed; `testing/test_platform_automation_wiring.py` now pins the mirror for every tag a flow clicks. On the third (36733112700) every desktop leg passed; iOS failed `empty_submit_does_nothing` because the now-disabled submit refused `/click` (404 "No click handler") and a plain `click:` counts a refusal as a failure. The step now says `click_refused:`, which holds when the click is refused and fails if a handler runs, and every platform answers a disabled control the same way (409, "is disabled"). +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-068-provision-accord-holder.yaml`, floor `>=0.5.224`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **5/5 passed** — the form with no banner, the disabled submit doing nothing, the FIPS acknowledgement and the two fields taking input, the no-token refusal landing on `provision_holder_error` and not on success, and back. Reaching the screen found two client defects the same day: the three fields had no input sinks (`/input` had nothing to apply to), and the empty state's tag `txt_provision_holder_start` was drawn in every state, so a refused submit showed error and empty at once — both fixed. On the matrix run (36588619656) it could not start on any desktop leg (the circle-hop race, fixed in the runner). On the second (36600766576) Linux and macOS passed 5/5 and Windows failed `fips_and_a_path` with `provision_holder_error` already on screen — a third client defect: the submit's `testableClickable` did not carry the Button's `enabled`, so the disabled-submit step's `/click` ran `provision()` and raised the "confirm your YubiKey" banner (CIRISClient#69's shape). Linux and macOS had passed that step only because the banner composed above the fold the runner had scrolled past, where the geometry-based `absent:` could not see it. Fixed; `testing/test_platform_automation_wiring.py` now pins the mirror for every tag a flow clicks. On the third (36733112700) every desktop leg passed; iOS failed `empty_submit_does_nothing` because the now-disabled submit refused `/click` (404 "No click handler") and a plain `click:` counts a refusal as a failure. The step now says `click_refused:`, which holds when the click is refused and fails if a handler runs, and every platform answers a disabled control the same way (409, "is disabled"). **Platforms.** Desktop and Android in practice — the flow needs a USB path and a physical token, and the iOS/browser corners have neither. The screen composes on diff --git a/FSD/CSD/CSD-092-share-contact-code.md b/FSD/CSD/CSD-092-share-contact-code.md index 57ad67dc..8eaf5e54 100644 --- a/FSD/CSD/CSD-092-share-contact-code.md +++ b/FSD/CSD/CSD-092-share-contact-code.md @@ -6,7 +6,7 @@ **Card**: built, PR #113 — `ContactsScreen.kt` (the card), `ContactCodeState.kt` + `ContactsViewModel` (the states), `ContactCodeResponse` (the wire), `ContactCodeViewModelTest` / `ContactCodeWireTest` ```yaml csd:stage -stage: building +stage: testable owner: CIRISClient ``` @@ -199,7 +199,7 @@ expect: ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-092-share-contact-code.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The tags are the client's at 0.5.225; the route is ciris-server 0.5.218's, so on an older node the flow drives the version fact and skips the populated, empty and refusal states. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/7 passed, 4 skipped** — the card opens, names the version it needs ("0.5.218 or newer") and closes; the four 0.5.218 states skipped as designed. On the matrix run of the same day (36588619656) the version step failed on every desktop leg: the client drew the sentence as the error's body and `StateBlock` registered its tag with the title alone, so the tree could not show it — fixed in the client (the tag now carries body and detail). The flow also closes its card whatever its verdict (`cleanup:`), because left open it replaced People's body for the three flows after it. +**`testable`** since the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217): the flow (`testing/flows/csd-092-share-contact-code.yaml`, floor `>=0.5.225`) passed on all five legs — Linux, macOS and Windows desktop, the Android emulator and the iOS simulator. What follows is how it got there. The tags are the client's at 0.5.225; the route is ciris-server 0.5.218's, so on an older node the flow drives the version fact and skips the populated, empty and refusal states. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **3/7 passed, 4 skipped** — the card opens, names the version it needs ("0.5.218 or newer") and closes; the four 0.5.218 states skipped as designed. On the matrix run of the same day (36588619656) the version step failed on every desktop leg: the client drew the sentence as the error's body and `StateBlock` registered its tag with the title alone, so the tree could not show it — fixed in the client (the tag now carries body and detail). The flow also closes its card whatever its verdict (`cleanup:`), because left open it replaced People's body for the three flows after it. **Platforms.** All five for the card. The copy → paste → contact round trip needs two nodes and runs on desktop. diff --git a/FSD/CSD/CSD-101-household-members.md b/FSD/CSD/CSD-101-household-members.md index a9954bc9..50856f90 100644 --- a/FSD/CSD/CSD-101-household-members.md +++ b/FSD/CSD/CSD-101-household-members.md @@ -199,7 +199,7 @@ with either a `btn_household_member_pick_*` per contact or ## 5. QA plan -Spec complete and flow written (`testing/flows/csd-101-household-members.yaml`, floor `>=0.5.225`); promotes to `testable` when it runs on the matrix. The matrix's node has no household, so the first step accepts the roster's empty shape and the populated roster is gated on `household_members_list`. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/4 passed, 2 skipped** — the roster composes in its empty shape and points to the hub; the populated roster and the add card skipped as designed (no household on the bare node). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner). +Spec complete and flow written (`testing/flows/csd-101-household-members.yaml`, floor `>=0.5.225`); the flow passed on all five legs in the five-platform run 36775704425 (2026-09-30, `flows/matrix-0.5.225` at efdac2a2, node v0.5.217), but the CSD stays at `building`: `x_private:membership_invitation` (the consent-to-join ruling, CSD-106) is `blocked_by: CIRISPersist#955`, and `testable` admits no unconfirmed field. The matrix's node has no household, so the first step accepts the roster's empty shape and the populated roster is gated on `household_members_list`. Linux desktop leg run locally the way `five-platform-live-qa.yml` runs it (2026-09-29, candidate 0.5.225, node v0.5.217, `--flows testing/flows`, the two-node fixture): **2/4 passed, 2 skipped** — the roster composes in its empty shape and points to the hub; the populated roster and the add card skipped as designed (no household on the bare node). On the matrix run of the same day (36588619656) it could not start on any desktop leg (the tab was clicked before the circle hop landed; fixed in the runner). **Platforms.** All five. From 78a73cafc4016373d83dec408c2229a169a1cecb Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 16:57:21 -0500 Subject: [PATCH 26/27] docs(roadmap): seven CSDs testable, 79 CSDs, the drafts are next Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- docs/ROADMAP.md | 166 ++++-------------------------------------------- 1 file changed, 14 insertions(+), 152 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fcec8d44..3aa02198 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,11 +15,12 @@ and how far along each part is. The item IDs (F, S, N, B, G) are the execution p `build.yml` is green on the merge. It carries everything below. Server **0.5.217** is the latest server release; **0.5.218 is not cut**. Agent **2.12.1** shipped CIRISAgent#1213 (the node proxy forwards every `/v1` path the agent does not serve itself), and six CSDs say so (#128). -- **77 CSDs on `main`** (`FSD/CSD`): **71 building, 5 sketched, 1 envisioned, none `testable`**. - New since the last pass: CSD-007 Files and CSD-008 Notes to self (#77), CSD-105 This node's +- **79 CSDs** (`FSD/CSD`): **7 testable, 65 building, 5 sketched, 2 envisioned**. New since the + last pass: CSD-106 membership invitations (envisioned, #137) and CSD-107 Where is this file (#139). + Before that: CSD-007 Files and CSD-008 Notes to self (#77), CSD-105 This node's trust root (#126), CSD-100/101 household and members (#122). CSD-092 (share contact code) is now building. **Sketched:** 037 My Identity, 042 Help, 044 Health & Reputation, and 093/094 - (the second-device cards, which wait for server 0.5.218). **Envisioned:** 030 Telemetry. + (the second-device cards, which wait for server 0.5.218). **Envisioned:** 030 Telemetry, 106 membership invitations. - **Both review batches are merged.** #124 was runtime, identity, interact, setup, network, moderation. **#126** is batch 2: **accord / trust root** (the two unpushed trust-root branches folded in, a `TrustRoot` detail with posture, import, un-trust and family history, all at the @@ -42,155 +43,16 @@ and how far along each part is. The item IDs (F, S, N, B, G) are the execution p each owner a contact of the other. Both ran **all five legs green in one run**; `main`'s latest Five-Platform Live QA is green (2026-09-29). One flow is live (`testing/flows/people.yaml`, CSD-005); **37 drafts** wait under `testing/flows/drafts/`. -- **Nothing is `testable` yet, and #128 says exactly why.** `CSD.md` §1: a card reaches `testable` - when its flow's floor is a released version **and** the flow runs on the matrix. On `main`, - **21 CSDs open §5 with "Spec complete and flow written"** — 005, 006, 008, 032, 033, 036, 040, - 045, 046, 047, 048, 049, 057, 068, 069, 081, 090, 091, 092, 100, 101 — with floors of - `>=0.5.224` (5), `>=0.5.225` (3) and `unreleased` (13). **One says "Flow not complete"** (082: - its claim step asserts a transient state behind an LLM key). The other 49 at `building` fail a - trial promotion for a `proposed:` tag, an unconfirmed field, or no flow (003, 026, 070, 110). -- **Next: promote.** Tag 0.5.225, flip the 13 `unreleased` floors to it, and run the 21 flows on - the matrix; each that goes green on every leg is `testable`. The drafts stay staged for one - reason: the runner does not navigate and every draft starts somewhere other than where sign-in - lands, so promotion is also the `nav_map` hop. Two things are known to stop flows before the - client is at fault: **CIRISServer#698** (two released 0.5.217 nodes never key a pair room, so the - CSD-091 chat flow cannot cross a message; recorded in `evidence/blocked_upstream.tsv`) and the - drafts floored `>=0.5.225`, which the matrix refuses until that version exists. - -## Dependencies - -Arrows read "must come before". Upstream items (red hexagons, dotted arrows) are what another repo -still owes: the lane can start, but can't fully close without it. Done work is folded into the -green boxes. - -```mermaid -flowchart TD - classDef done fill:#1D7F45,stroke:#1D7F45,color:#fff - classDef part fill:#F8EEDC,stroke:#B8791C,color:#000 - classDef todo fill:#fff,stroke:#6E6875,color:#000 - classDef next fill:#E3EEF3,stroke:#2B6E8C,stroke-width:3px,color:#000 - classDef review fill:#EDE7F6,stroke:#5B4FC0,stroke-width:2px,color:#000 - classDef up fill:#F6E2E2,stroke:#B23A3A,color:#000 - - BASE["Done: F1 F3 F4 F5 foundation, N1-N10 shell and spine, G2 atlas, G4 nav contract, B1 People, B2 receipt sheet, bug lane released in 0.5.224, a CSD for every card PR 90, fix wave PRs 92-95"]:::done - WAVE["Done 09-26 to 09-28: state tags and drift test PR 100, route gate PRs 111 119, citations PR 107, QR PR 99, consent withdraw PR 112, contact code PR 113, key verification PRs 117 118, partnership and connectors PR 120, erase traces PR 123, review batch 1 PR 124"]:::done - WAVE2["Done 09-28 to 09-29: B3 Files and Notes PR 77, communities PR 121, households PR 122, review batch 2 PR 126 with CSD-105, Codex fixes PR 129, translations PR 127, promotion-readiness list PR 128"]:::done - FLOWS["Done: F7 and G3, the flow runner on the five-platform matrix PR 97, one live flow, 37 drafts"]:::done - TWO_NODE["Done: two-node fixture on all five legs PR 130"]:::done - REL["Release 0.5.225, PR 131, tag follows CI"]:::review - PROMOTE["Next: flip the unreleased floors to 0.5.225 and run the 21 written flows on the matrix, each green on every leg is testable"]:::next - F2["F2 renderers, per card"]:::part - F6["F6 invariant guards"]:::part - F8["F8 scopes on the surface"]:::todo - S1["S1 receipt CSD-006, spec complete, flow written"]:::part - G1["G1 moderation exposure contract, tag table in CSD-065, moderation reviewed"]:::part - B4["B4 Just me: Files, Notes, Data erase, consent revoke, partnership, interact reviewed"]:::part - B5["B5 Family: household and members built, reviewed in batch 2"]:::part - B6["B6 Neighbours: affiliations hub, rooms, key verification, chats at the node URL"]:::part - B7["B7 Communities and Businesses: community, rosters, rooms, moderator picker"]:::part - B8["B8 Everyone: agent-mode control replaced by a Settings link"]:::todo - B9["B9 instruments: This agent and This node, six Network tiles, CSD-045, trust root detail CSD-105"]:::part - B10["B10 setup wizard: loads templates adapters tool disclosure, connect-node works, new shape not started"]:::part - B11["B11 multi-self: devices in PR 94, CSD-093 094 sketched"]:::part - B12["B12 contacts, groups, rosters: contact code, community rosters, household members"]:::part - - SRV218{{"Server 0.5.218 cut: 673 contact code, 678 second device, 657 withdraw, 676 delegation_id, 672 OAuth redirect"}}:::up - SRV698{{"Server 698: two released nodes cannot key a pair room, Server 696 media policy caps"}}:::up - UB3{{"Server 614 renditions, 641 descriptor, Edge 646 own devices, Edge 675 person-signed files"}}:::up - UB5{{"Server 686 quorum M, 687 pending changes and rename"}}:::up - UB6{{"Server 665 safety reports, 688 moderator chain, 594 N-member communities, 683 684 peer SAS"}}:::up - UB7{{"Server 648 ledgers, 649 terms, 650 group book, 662 cohort roster, CC 105 registry"}}:::up - UB8{{"Server 651 shared knowledge, 652 trust root from a phone, 248 signed root, 681 accepted false, 682 supersede"}}:::up - UERA{{"Server 677 self-erasure, Persist 914 erasable minting, Server 671 DSAR, Agent 1207 1220 receipts"}}:::up - USELF{{"Server 675 canary visible to peers, 668 670 node facts, 694 add-from-code, 692 grant pruning; Agent 1202-1212 1219-1227"}}:::up - - BASE --> WAVE --> WAVE2 - WAVE2 --> REL --> PROMOTE - FLOWS --> PROMOTE - TWO_NODE --> PROMOTE - BASE --> F2 & F6 & B10 - WAVE2 --> B4 & B5 & B6 & B7 & B9 & B12 - PROMOTE --> B4 & B5 & B6 & B7 - B7 --> B8 - F8 --> S1 - G1 --> B6 - F6 --> B6 - B4 --> B11 - B5 --> B12 - SRV218 -.-> B4 & B9 & B11 & B12 - SRV698 -.-> PROMOTE & B4 - UB3 -.-> B4 - UB5 -.-> B5 - UB6 -.-> B6 - UB7 -.-> B7 - UB8 -.-> B8 & B9 - UERA -.-> B4 - USELF -.-> B9 -``` - -Green = done · purple = in review · amber = partial · blue outline = next · white = not started · -red hexagon = owed upstream. - -## Plan against actual - -| | item | status | where | -|---|---|---|---| -| F1, F3, F4, F5 | namespace table, tokens + lint, glyphs, primitives | **done** | #62 | -| F2 | eleven renderers | partial: the enum and mapping exist; composables are written per card | #62 | -| F6 | invariant guards | partial | #62 | -| F7 | CSD DSL (`state`, `each`) | **done** as the flow language: `states:` in every CSD with a drift test; `state`, `each`, `relation`, `count` assert on the matrix | #100, #97 | -| F8 | `scopes:` on the surface | not started. `check_csd_v3` rejects unknown surface keys and checks `flow_only` | #90 | -| N1–N10 | nav tree, rail, tabs, gating deleted, the spine | **done** | #63, #64 | -| S1 | receipt CSD | building, spec complete, flow written (CSD-006); the receipt no longer guesses a scope for a grant-less row | #101, #126 | -| S2 | moderation CSD | building (CSD-065); reviewed in batch 1; a `proposed:` tag and an unconfirmed field keep it off the promote list | #90, #124 | -| S3–S7 | card CSDs | **77 on main**: 71 building, 5 sketched, 1 envisioned; §3 generated from code; 21 spec-complete with a flow | #90, #107, #111, #128 | -| B1, B2 | People, receipt sheet | **done** | #62 | -| B3 | Files | **done**: Files holds files, Notes to self in Just me › Chats; CSD-007/008 | #77 | -| B4 | Just me | partial, every card built: Files, Notes, Data (erase, receipts), Manage Consent (revoke), Consent (partnership), Interact reviewed; closes when its flows are `testable` | #77, #123, #112, #120, #124, #126 | -| B5 | Family | partial, built and reviewed: the household in the hub, members on People; quorum M and rename are upstream | #122, #126 | -| B6 | Neighbours | partial: affiliations hub, rooms in Chats (CSD-110), key verification, chats at the node URL; Safety still has nothing a non-duty-holder may do | #121, #117, #126 | -| B7 | Communities and Businesses | partial: the community (Rules), rosters (People), rooms (Chats), moderator picker; ledgers, terms and the group book are upstream | #121, #126 | -| B8 | Everyone | not started beyond the hub fix (agent-mode control → Settings link) | #126 | -| B9 | instruments | partial: This agent / This node split; six Network tiles; CSD-045 surface; the trust-root detail (CSD-105) at the node URL; admin acts read `owner_delegations` (0.5.218) | #93, #124, #126 | -| B10 | setup wizard, new shape | partial: loads templates, adapters and the tool disclosure, connect-node works; the new shape is not started; CSD-082's flow is the one "not complete" | #92, #124 | -| B11 | multi-self | partial: device labels, revoked devices, release a node; CSD-093/094 sketched against 0.5.218 | #94, #98 | -| B12 | contacts, groups, rosters | partial: share my contact code (CSD-092 building, route ships in 0.5.218), community rosters, household members | #113, #121, #122 | -| G1 | moderation exposure contract | partial: tags specified in CSD-065; moderation reviewed; not yet a surface | #90, #124 | -| G2, G4 | atlas; nav contract | **done** | #64 | -| G3 | per-circle CSD verification | **done** as a mechanism: the runner on five legs, the two-node fixture for flows that need a second person; one flow live | #97, #130 | -| — | gap-closing review, batches 1 and 2 | **done** | #124, #126 | -| — | promotions | **next**: 21 flows to run at 0.5.225; 13 floors to flip | — | -| — | release 0.5.225 | in review | #131 | - -## Upstream: what each lane is waiting on - -Grouped by the lane the client work sits in, from the open issues in CIRISServer, CIRISAgent, -CIRISPersist and CIRISConstitution on 2026-09-29. The first row gates four merged cards and two -sketched ones; the second stops a flow. - -| lane | owed | issues | -|---|---|---| -| **the 0.5.218 cut** (server 0.5.217 is latest) | a person's contact code; a second device that opens old files and replicates; withdraw a `consent:replication` grant; the owner's `delegation_id` on the wire; the OAuth redirect check | **Server#673, #678, #657, #676, #672**; on the same slate: #655 (occurrences unauthenticated), #646/#647/#622 (replication kinds, family cohort, cohort blob), #692 (expired device-code grants never pruned) | -| **the matrix** | two released nodes keying a pair room (peers stay advisory; the KeyPackage never replicates); which of `/v1/media/policy`'s two caps the write door enforces | **Server#698**, #696 | -| people / chats / files | renditions and the file descriptor; bytes on your own devices; files signed by the person; peer SAS that can match, and a record when it doesn't; a list of peering grants; rooms after a restart; self-room state that survives a reboot | Server#614, #641, #683, #684, #680, #623, #630, #653; Edge#646, #675; Agent#1210 | -| consent / data | erase yourself from the app; erasable minting; DSAR without an agent; a DSAR receipt; deletion receipts across a key rotation; consent that speaks scopes; `consent:scope:analyze` readable; erase-traces returns its audit row | Server#677, #671, #685, #659, #661; Persist#914; Agent#1207, #1212, #1219, #1220, #1204, #1221 | -| households / communities | absolute-M quorum; pending quorum changes and rename; N-member communities above the substrate; a cohort-scoped roster read; claim into all seven cohort scopes; a founder can appoint a moderator; ledgers, terms, the group book; a non-duty-holder can report; the missing registry families | Server#686, #687, #594, #662, #666, #688, #648, #649, #650, #665; CC#105, #109, #110; Agent#1205 | -| accord / trust root | a signed trust root; re-rooting from a phone; `accepted:false` for the root the node is under; supersede names a route that exists; `canonical/add` (#441); a Wise Authority surface for owner recovery; the lineage-head cosign; the v2 accord invocation label reaching v1 agents | Server#248, #652, #681, #682, #664, #693, #536, #537; CC#118–#121, #127; Agent#1227 | -| node ops (This node) | the compulsion declaration visible to peers; two node facts with no node route; the transport key on identity; a bare node can take a node code; a config cohort envelope; an audit read; run-without-AI reported as such; deferrals on the node | Server#675, #668, #670, #694, #660, #658, #656, #574, #573, #669, #547 | -| agent surfaces (This agent) | users without emails; skill import that fails closed; a WA "modify" that is a rejection; inventory reads that match the card; what the agent thinks with, readable; connectors that report honestly and record a delegation; tickets that check transitions; wallet idempotency; billing that doesn't invent credits | Agent#1202, #1203, #1206, #1208, #1209, #1211, #1222, #1223, #1224, #1225, #1226, #945 | -| setup / identity | is a remote node already owned; a dedicated home with a dedicated identity; a TPM that isn't silently software; a pre-#659 key row detected; device-grant chains; idempotent setup/complete; `claim_pin_file` on setup status | Server#667, #621, #639, #608, #595, #490, #663; Agent#1193–#1196, #1110, #1123, #1152 | - -**Closed since 2026-09-25:** CIRISAgent#1213 (shipped in agent 2.12.1); CIRISPersist#907, #910, -#916 (second-device re-wrap), #919 (self room on a second device), #809, #937–#939; -CIRISConstitution#106–#108. Client #108 and #109 closed in #124. - -**Shipped upstream and now used by the client:** the drive plane, `/v1/media/policy`, content -digests and `/meta` (#77); households (#122); communities and rooms (#121); peer key verification -(#117); trace erasure (#123); `/v1/vocabulary` and `/v1/operations` (#124); the Tier S -self-standing routes (CSD-045); `/v1/trust-root` and `canonical/supersede` (#126). - -## Client backlog outside the lanes - +- **Seven CSDs are `testable`** (#133): 005 People, 006 Receipt, 008 Notes to self, 047 Network + content, 057 Wallet, 068 Provision an accord holder, 092 Share contact code. Their flows passed on + all five legs in run 36775704425 (Linux, macOS, Windows desktop; Android emulator; iOS simulator) + against released floors `>=0.5.224`/`>=0.5.225`. CSD-101's flow passed too; it stays `building` + on `x_private:membership_invitation` (CIRISPersist#955). Getting there took seven matrix runs and + fixed real client bugs on the way: a stale-circle tab hop, a disabled button that swallowed + automation clicks, a receipt sheet whose Close was below the fold on a phone, and a claim PIN the + Android app could not reach. +- **Next: the 30 drafts** under `testing/flows/drafts/` — the same route: a released floor, a nav + hop, a green matrix run. - Open since the bug lane: #50 (iOS SIGABRT; 0.5.224 writes a Kotlin crash log to read on the next failure), #51 (logout on a phone with no agent; addressed by #64, needs a phone check), #39, #33, #65 and #78 (bundle keys for 0.5.215/0.5.216; the routes are now called, the ids are not From 80bbb1ff382b2f5b2ca1f300b77f9ca5d1ee0f96 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Wed, 30 Sep 2026 16:58:10 -0500 Subject: [PATCH 27/27] docs(roadmap): restore the sections the last edit cut, keep the promotion The previous commit replaced two bullets with a slice that ran to the next top-level bullet and took 152 lines of the roadmap with it. Rebuilt from the version before it, replacing only the stage counts and the promotion bullets, and keeping the #698 blocker note that sat in the replaced text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0155SkTGdbwnSnR6tWBqJvUM --- docs/ROADMAP.md | 138 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3aa02198..c7fe94d2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -52,7 +52,143 @@ and how far along each part is. The item IDs (F, S, N, B, G) are the execution p automation clicks, a receipt sheet whose Close was below the fold on a phone, and a claim PIN the Android app could not reach. - **Next: the 30 drafts** under `testing/flows/drafts/` — the same route: a released floor, a nav - hop, a green matrix run. + hop, a green matrix run. **CIRISServer#698** still stops CSD-091's chat flow (two released 0.5.217 + nodes never key a pair room; recorded in `evidence/blocked_upstream.tsv`). + +## Dependencies + +Arrows read "must come before". Upstream items (red hexagons, dotted arrows) are what another repo +still owes: the lane can start, but can't fully close without it. Done work is folded into the +green boxes. + +```mermaid +flowchart TD + classDef done fill:#1D7F45,stroke:#1D7F45,color:#fff + classDef part fill:#F8EEDC,stroke:#B8791C,color:#000 + classDef todo fill:#fff,stroke:#6E6875,color:#000 + classDef next fill:#E3EEF3,stroke:#2B6E8C,stroke-width:3px,color:#000 + classDef review fill:#EDE7F6,stroke:#5B4FC0,stroke-width:2px,color:#000 + classDef up fill:#F6E2E2,stroke:#B23A3A,color:#000 + + BASE["Done: F1 F3 F4 F5 foundation, N1-N10 shell and spine, G2 atlas, G4 nav contract, B1 People, B2 receipt sheet, bug lane released in 0.5.224, a CSD for every card PR 90, fix wave PRs 92-95"]:::done + WAVE["Done 09-26 to 09-28: state tags and drift test PR 100, route gate PRs 111 119, citations PR 107, QR PR 99, consent withdraw PR 112, contact code PR 113, key verification PRs 117 118, partnership and connectors PR 120, erase traces PR 123, review batch 1 PR 124"]:::done + WAVE2["Done 09-28 to 09-29: B3 Files and Notes PR 77, communities PR 121, households PR 122, review batch 2 PR 126 with CSD-105, Codex fixes PR 129, translations PR 127, promotion-readiness list PR 128"]:::done + FLOWS["Done: F7 and G3, the flow runner on the five-platform matrix PR 97, one live flow, 37 drafts"]:::done + TWO_NODE["Done: two-node fixture on all five legs PR 130"]:::done + REL["Release 0.5.225, PR 131, tag follows CI"]:::review + PROMOTE["Next: flip the unreleased floors to 0.5.225 and run the 21 written flows on the matrix, each green on every leg is testable"]:::next + F2["F2 renderers, per card"]:::part + F6["F6 invariant guards"]:::part + F8["F8 scopes on the surface"]:::todo + S1["S1 receipt CSD-006, spec complete, flow written"]:::part + G1["G1 moderation exposure contract, tag table in CSD-065, moderation reviewed"]:::part + B4["B4 Just me: Files, Notes, Data erase, consent revoke, partnership, interact reviewed"]:::part + B5["B5 Family: household and members built, reviewed in batch 2"]:::part + B6["B6 Neighbours: affiliations hub, rooms, key verification, chats at the node URL"]:::part + B7["B7 Communities and Businesses: community, rosters, rooms, moderator picker"]:::part + B8["B8 Everyone: agent-mode control replaced by a Settings link"]:::todo + B9["B9 instruments: This agent and This node, six Network tiles, CSD-045, trust root detail CSD-105"]:::part + B10["B10 setup wizard: loads templates adapters tool disclosure, connect-node works, new shape not started"]:::part + B11["B11 multi-self: devices in PR 94, CSD-093 094 sketched"]:::part + B12["B12 contacts, groups, rosters: contact code, community rosters, household members"]:::part + + SRV218{{"Server 0.5.218 cut: 673 contact code, 678 second device, 657 withdraw, 676 delegation_id, 672 OAuth redirect"}}:::up + SRV698{{"Server 698: two released nodes cannot key a pair room, Server 696 media policy caps"}}:::up + UB3{{"Server 614 renditions, 641 descriptor, Edge 646 own devices, Edge 675 person-signed files"}}:::up + UB5{{"Server 686 quorum M, 687 pending changes and rename"}}:::up + UB6{{"Server 665 safety reports, 688 moderator chain, 594 N-member communities, 683 684 peer SAS"}}:::up + UB7{{"Server 648 ledgers, 649 terms, 650 group book, 662 cohort roster, CC 105 registry"}}:::up + UB8{{"Server 651 shared knowledge, 652 trust root from a phone, 248 signed root, 681 accepted false, 682 supersede"}}:::up + UERA{{"Server 677 self-erasure, Persist 914 erasable minting, Server 671 DSAR, Agent 1207 1220 receipts"}}:::up + USELF{{"Server 675 canary visible to peers, 668 670 node facts, 694 add-from-code, 692 grant pruning; Agent 1202-1212 1219-1227"}}:::up + + BASE --> WAVE --> WAVE2 + WAVE2 --> REL --> PROMOTE + FLOWS --> PROMOTE + TWO_NODE --> PROMOTE + BASE --> F2 & F6 & B10 + WAVE2 --> B4 & B5 & B6 & B7 & B9 & B12 + PROMOTE --> B4 & B5 & B6 & B7 + B7 --> B8 + F8 --> S1 + G1 --> B6 + F6 --> B6 + B4 --> B11 + B5 --> B12 + SRV218 -.-> B4 & B9 & B11 & B12 + SRV698 -.-> PROMOTE & B4 + UB3 -.-> B4 + UB5 -.-> B5 + UB6 -.-> B6 + UB7 -.-> B7 + UB8 -.-> B8 & B9 + UERA -.-> B4 + USELF -.-> B9 +``` + +Green = done · purple = in review · amber = partial · blue outline = next · white = not started · +red hexagon = owed upstream. + +## Plan against actual + +| | item | status | where | +|---|---|---|---| +| F1, F3, F4, F5 | namespace table, tokens + lint, glyphs, primitives | **done** | #62 | +| F2 | eleven renderers | partial: the enum and mapping exist; composables are written per card | #62 | +| F6 | invariant guards | partial | #62 | +| F7 | CSD DSL (`state`, `each`) | **done** as the flow language: `states:` in every CSD with a drift test; `state`, `each`, `relation`, `count` assert on the matrix | #100, #97 | +| F8 | `scopes:` on the surface | not started. `check_csd_v3` rejects unknown surface keys and checks `flow_only` | #90 | +| N1–N10 | nav tree, rail, tabs, gating deleted, the spine | **done** | #63, #64 | +| S1 | receipt CSD | building, spec complete, flow written (CSD-006); the receipt no longer guesses a scope for a grant-less row | #101, #126 | +| S2 | moderation CSD | building (CSD-065); reviewed in batch 1; a `proposed:` tag and an unconfirmed field keep it off the promote list | #90, #124 | +| S3–S7 | card CSDs | **77 on main**: 71 building, 5 sketched, 1 envisioned; §3 generated from code; 21 spec-complete with a flow | #90, #107, #111, #128 | +| B1, B2 | People, receipt sheet | **done** | #62 | +| B3 | Files | **done**: Files holds files, Notes to self in Just me › Chats; CSD-007/008 | #77 | +| B4 | Just me | partial, every card built: Files, Notes, Data (erase, receipts), Manage Consent (revoke), Consent (partnership), Interact reviewed; closes when its flows are `testable` | #77, #123, #112, #120, #124, #126 | +| B5 | Family | partial, built and reviewed: the household in the hub, members on People; quorum M and rename are upstream | #122, #126 | +| B6 | Neighbours | partial: affiliations hub, rooms in Chats (CSD-110), key verification, chats at the node URL; Safety still has nothing a non-duty-holder may do | #121, #117, #126 | +| B7 | Communities and Businesses | partial: the community (Rules), rosters (People), rooms (Chats), moderator picker; ledgers, terms and the group book are upstream | #121, #126 | +| B8 | Everyone | not started beyond the hub fix (agent-mode control → Settings link) | #126 | +| B9 | instruments | partial: This agent / This node split; six Network tiles; CSD-045 surface; the trust-root detail (CSD-105) at the node URL; admin acts read `owner_delegations` (0.5.218) | #93, #124, #126 | +| B10 | setup wizard, new shape | partial: loads templates, adapters and the tool disclosure, connect-node works; the new shape is not started; CSD-082's flow is the one "not complete" | #92, #124 | +| B11 | multi-self | partial: device labels, revoked devices, release a node; CSD-093/094 sketched against 0.5.218 | #94, #98 | +| B12 | contacts, groups, rosters | partial: share my contact code (CSD-092 building, route ships in 0.5.218), community rosters, household members | #113, #121, #122 | +| G1 | moderation exposure contract | partial: tags specified in CSD-065; moderation reviewed; not yet a surface | #90, #124 | +| G2, G4 | atlas; nav contract | **done** | #64 | +| G3 | per-circle CSD verification | **done** as a mechanism: the runner on five legs, the two-node fixture for flows that need a second person; one flow live | #97, #130 | +| — | gap-closing review, batches 1 and 2 | **done** | #124, #126 | +| — | promotions | **next**: 21 flows to run at 0.5.225; 13 floors to flip | — | +| — | release 0.5.225 | in review | #131 | + +## Upstream: what each lane is waiting on + +Grouped by the lane the client work sits in, from the open issues in CIRISServer, CIRISAgent, +CIRISPersist and CIRISConstitution on 2026-09-29. The first row gates four merged cards and two +sketched ones; the second stops a flow. + +| lane | owed | issues | +|---|---|---| +| **the 0.5.218 cut** (server 0.5.217 is latest) | a person's contact code; a second device that opens old files and replicates; withdraw a `consent:replication` grant; the owner's `delegation_id` on the wire; the OAuth redirect check | **Server#673, #678, #657, #676, #672**; on the same slate: #655 (occurrences unauthenticated), #646/#647/#622 (replication kinds, family cohort, cohort blob), #692 (expired device-code grants never pruned) | +| **the matrix** | two released nodes keying a pair room (peers stay advisory; the KeyPackage never replicates); which of `/v1/media/policy`'s two caps the write door enforces | **Server#698**, #696 | +| people / chats / files | renditions and the file descriptor; bytes on your own devices; files signed by the person; peer SAS that can match, and a record when it doesn't; a list of peering grants; rooms after a restart; self-room state that survives a reboot | Server#614, #641, #683, #684, #680, #623, #630, #653; Edge#646, #675; Agent#1210 | +| consent / data | erase yourself from the app; erasable minting; DSAR without an agent; a DSAR receipt; deletion receipts across a key rotation; consent that speaks scopes; `consent:scope:analyze` readable; erase-traces returns its audit row | Server#677, #671, #685, #659, #661; Persist#914; Agent#1207, #1212, #1219, #1220, #1204, #1221 | +| households / communities | absolute-M quorum; pending quorum changes and rename; N-member communities above the substrate; a cohort-scoped roster read; claim into all seven cohort scopes; a founder can appoint a moderator; ledgers, terms, the group book; a non-duty-holder can report; the missing registry families | Server#686, #687, #594, #662, #666, #688, #648, #649, #650, #665; CC#105, #109, #110; Agent#1205 | +| accord / trust root | a signed trust root; re-rooting from a phone; `accepted:false` for the root the node is under; supersede names a route that exists; `canonical/add` (#441); a Wise Authority surface for owner recovery; the lineage-head cosign; the v2 accord invocation label reaching v1 agents | Server#248, #652, #681, #682, #664, #693, #536, #537; CC#118–#121, #127; Agent#1227 | +| node ops (This node) | the compulsion declaration visible to peers; two node facts with no node route; the transport key on identity; a bare node can take a node code; a config cohort envelope; an audit read; run-without-AI reported as such; deferrals on the node | Server#675, #668, #670, #694, #660, #658, #656, #574, #573, #669, #547 | +| agent surfaces (This agent) | users without emails; skill import that fails closed; a WA "modify" that is a rejection; inventory reads that match the card; what the agent thinks with, readable; connectors that report honestly and record a delegation; tickets that check transitions; wallet idempotency; billing that doesn't invent credits | Agent#1202, #1203, #1206, #1208, #1209, #1211, #1222, #1223, #1224, #1225, #1226, #945 | +| setup / identity | is a remote node already owned; a dedicated home with a dedicated identity; a TPM that isn't silently software; a pre-#659 key row detected; device-grant chains; idempotent setup/complete; `claim_pin_file` on setup status | Server#667, #621, #639, #608, #595, #490, #663; Agent#1193–#1196, #1110, #1123, #1152 | + +**Closed since 2026-09-25:** CIRISAgent#1213 (shipped in agent 2.12.1); CIRISPersist#907, #910, +#916 (second-device re-wrap), #919 (self room on a second device), #809, #937–#939; +CIRISConstitution#106–#108. Client #108 and #109 closed in #124. + +**Shipped upstream and now used by the client:** the drive plane, `/v1/media/policy`, content +digests and `/meta` (#77); households (#122); communities and rooms (#121); peer key verification +(#117); trace erasure (#123); `/v1/vocabulary` and `/v1/operations` (#124); the Tier S +self-standing routes (CSD-045); `/v1/trust-root` and `canonical/supersede` (#126). + +## Client backlog outside the lanes + - Open since the bug lane: #50 (iOS SIGABRT; 0.5.224 writes a Kotlin crash log to read on the next failure), #51 (logout on a phone with no agent; addressed by #64, needs a phone check), #39, #33, #65 and #78 (bundle keys for 0.5.215/0.5.216; the routes are now called, the ids are not