From a0cc88f31224cf6c427efd3f3edababdd85f507a Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 29 Sep 2026 10:13:14 -0500 Subject: [PATCH 01/12] =?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/12] 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/12] 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/12] 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/12] =?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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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: