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/FSD/CSD/CSD-005-people.md b/FSD/CSD/CSD-005-people.md index 3ae17f53..f5d4d67c 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. 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 4e68132d..95673505 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 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 1f4f1e0f..14730574 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. 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-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..a0abe535 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. 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-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..382c3645 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`, @@ -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 @@ -158,7 +161,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. 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 0c15739a..ceb80554 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. 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-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..57ad67dc 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. 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-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..610d28c1 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`. 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/client/VENDORING.md b/client/VENDORING.md index 08a3dc81..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:** `1ab9a22b7e23dea8b1e2dc3aac8a8c6e3cc95c5ca7d6783a4d73bf40321f2bbd` +**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/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/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/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/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/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)) + } +} 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" ], diff --git a/testing/flows/README.md b/testing/flows/README.md index ce19cb8a..5c596763 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 @@ -66,6 +68,35 @@ 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 | 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`. 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 | verdict | when | leg | @@ -133,14 +164,33 @@ 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. 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 @@ -164,9 +214,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: @@ -198,7 +260,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 82% rename from testing/flows/drafts/csd-005-people.yaml rename to testing/flows/csd-005-people.yaml index 49f87cbb..4e54cbf7 100644 --- a/testing/flows/drafts/csd-005-people.yaml +++ b/testing/flows/csd-005-people.yaml @@ -15,13 +15,19 @@ 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. # 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 @@ -111,12 +117,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 91% rename from testing/flows/drafts/csd-057-wallet.yaml rename to testing/flows/csd-057-wallet.yaml index 3b97a61e..ebb0cf6e 100644 --- a/testing/flows/drafts/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/drafts/csd-068-provision-accord-holder.yaml b/testing/flows/csd-068-provision-accord-holder.yaml similarity index 94% rename from testing/flows/drafts/csd-068-provision-accord-holder.yaml rename to testing/flows/csd-068-provision-accord-holder.yaml index 40d79a15..c306caf1 100644 --- a/testing/flows/drafts/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] diff --git a/testing/flows/drafts/csd-092-share-contact-code.yaml b/testing/flows/csd-092-share-contact-code.yaml similarity index 83% rename from testing/flows/drafts/csd-092-share-contact-code.yaml rename to testing/flows/csd-092-share-contact-code.yaml index 1d668615..f19180b0 100644 --- a/testing/flows/drafts/csd-092-share-contact-code.yaml +++ b/testing/flows/csd-092-share-contact-code.yaml @@ -8,14 +8,21 @@ 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" +# 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 @@ -93,8 +100,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 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: 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/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/gate/flow_helper.py b/testing/gate/flow_helper.py index 7b5b12f1..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 -------------------------------------------------------------- @@ -96,6 +144,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: @@ -107,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 548c2b9d..44e55d4f 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 @@ -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"} @@ -275,10 +283,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 +332,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 +413,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 +575,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. @@ -683,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) @@ -730,14 +773,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" @@ -760,6 +812,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) @@ -824,7 +910,7 @@ async def run(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: @@ -857,6 +943,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 8b30e3cd..2d4d3733 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: @@ -220,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/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/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_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. 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 diff --git a/testing/test_flows.py b/testing/test_flows.py index 1be8ba70..4a255c21 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): @@ -309,6 +311,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 +343,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] @@ -360,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 @@ -516,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={}) @@ -538,10 +738,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(): 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" 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"